Pre-conf work 2025

This commit is contained in:
2025-09-02 08:25:41 -04:00
parent 550021a18e
commit 4acbec6288
75 changed files with 8047 additions and 5228 deletions

View File

@@ -0,0 +1,61 @@
"use client";
import { DesignerRoot } from "~/components/experiments/designer/DesignerRoot";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import type { ExperimentStep } from "~/lib/experiment-designer/types";
interface DesignerPageClientProps {
experiment: {
id: string;
name: string;
description: string | null;
study: {
id: string;
name: string;
};
};
initialDesign?: {
id: string;
name: string;
description: string;
steps: ExperimentStep[];
version: number;
lastSaved: Date;
};
}
export function DesignerPageClient({
experiment,
initialDesign,
}: DesignerPageClientProps) {
// Set breadcrumbs
useBreadcrumbsEffect([
{
label: "Dashboard",
href: "/",
},
{
label: "Studies",
href: "/studies",
},
{
label: experiment.study.name,
href: `/studies/${experiment.study.id}`,
},
{
label: "Experiments",
href: `/studies/${experiment.study.id}/experiments`,
},
{
label: experiment.name,
href: `/experiments/${experiment.id}`,
},
{
label: "Designer",
},
]);
return (
<DesignerRoot experimentId={experiment.id} initialDesign={initialDesign} />
);
}

View File

@@ -1,5 +1,4 @@
import { notFound } from "next/navigation";
import { DesignerRoot } from "~/components/experiments/designer/DesignerRoot";
import type {
ExperimentStep,
ExperimentAction,
@@ -8,6 +7,7 @@ import type {
ExecutionDescriptor,
} from "~/lib/experiment-designer/types";
import { api } from "~/trpc/server";
import { DesignerPageClient } from "./DesignerPageClient";
interface ExperimentDesignerPageProps {
params: Promise<{
@@ -239,8 +239,8 @@ export default async function ExperimentDesignerPage({
}
return (
<DesignerRoot
experimentId={experiment.id}
<DesignerPageClient
experiment={experiment}
initialDesign={initialDesign}
/>
);

View File

@@ -52,7 +52,7 @@ export default async function DashboardLayout({
<BreadcrumbDisplay />
</div>
</header>
<div className="flex min-w-0 flex-1 flex-col gap-4 overflow-x-hidden overflow-y-auto p-4 pt-0">
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-4 overflow-hidden p-4 pt-0">
{children}
</div>
</SidebarInset>

View File

@@ -95,6 +95,26 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
{ enabled: !!resolvedParams?.id },
);
const { data: experimentsData } = api.experiments.list.useQuery(
{ studyId: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
const { data: participantsData } = api.participants.list.useQuery(
{ studyId: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
const { data: trialsData } = api.trials.list.useQuery(
{ studyId: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
const { data: activityData } = api.studies.getActivity.useQuery(
{ studyId: resolvedParams?.id ?? "", limit: 5 },
{ enabled: !!resolvedParams?.id },
);
useEffect(() => {
if (studyData) {
setStudy(studyData);
@@ -124,12 +144,19 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
const statusInfo = statusConfig[study.status as keyof typeof statusConfig];
// TODO: Get actual stats from API
const mockStats = {
experiments: 0,
totalTrials: 0,
participants: 0,
completionRate: "—",
const experiments = experimentsData ?? [];
const participants = participantsData?.participants ?? [];
const trials = trialsData ?? [];
const activities = activityData?.activities ?? [];
const completedTrials = trials.filter((trial: { status: string }) => trial.status === "completed").length;
const totalTrials = trials.length;
const stats = {
experiments: experiments.length,
totalTrials: totalTrials,
participants: participants.length,
completionRate: totalTrials > 0 ? `${Math.round((completedTrials / totalTrials) * 100)}%` : "—",
};
return (
@@ -207,27 +234,128 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
</Button>
}
>
<EmptyState
icon="FlaskConical"
title="No Experiments Yet"
description="Create your first experiment to start designing research protocols"
action={
<Button asChild>
<Link href={`/experiments/new?studyId=${study.id}`}>
Create First Experiment
</Link>
</Button>
}
/>
{experiments.length === 0 ? (
<EmptyState
icon="FlaskConical"
title="No Experiments Yet"
description="Create your first experiment to start designing research protocols"
action={
<Button asChild>
<Link href={`/experiments/new?studyId=${study.id}`}>
Create First Experiment
</Link>
</Button>
}
/>
) : (
<div className="space-y-4">
{experiments.map((experiment) => (
<div
key={experiment.id}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex-1">
<div className="flex items-center space-x-3">
<h4 className="font-medium">
<Link
href={`/experiments/${experiment.id}`}
className="hover:underline"
>
{experiment.name}
</Link>
</h4>
<span
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
experiment.status === "draft"
? "bg-gray-100 text-gray-800"
: experiment.status === "ready"
? "bg-green-100 text-green-800"
: "bg-blue-100 text-blue-800"
}`}
>
{experiment.status}
</span>
</div>
{experiment.description && (
<p className="mt-1 text-sm text-muted-foreground">
{experiment.description}
</p>
)}
<div className="mt-2 flex items-center space-x-4 text-xs text-muted-foreground">
<span>
Created {formatDistanceToNow(experiment.createdAt, { addSuffix: true })}
</span>
{experiment.estimatedDuration && (
<span>
Est. {experiment.estimatedDuration} min
</span>
)}
</div>
</div>
<div className="flex items-center space-x-2">
<Button asChild variant="outline" size="sm">
<Link href={`/experiments/${experiment.id}/designer`}>
Design
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/experiments/${experiment.id}`}>
View
</Link>
</Button>
</div>
</div>
))}
</div>
)}
</EntityViewSection>
{/* Recent Activity */}
<EntityViewSection title="Recent Activity" icon="BarChart3">
<EmptyState
icon="Calendar"
title="No Recent Activity"
description="Activity will appear here once you start working on this study"
/>
{activities.length === 0 ? (
<EmptyState
icon="Calendar"
title="No Recent Activity"
description="Activity will appear here once you start working on this study"
/>
) : (
<div className="space-y-3">
{activities.map((activity) => (
<div
key={activity.id}
className="flex items-start space-x-3 rounded-lg border p-3"
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100">
<span className="text-sm font-medium text-blue-600">
{activity.user?.name?.charAt(0) ?? activity.user?.email?.charAt(0) ?? "?"}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">
{activity.user?.name ?? activity.user?.email ?? "Unknown User"}
</p>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(activity.createdAt, { addSuffix: true })}
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{activity.description}
</p>
</div>
</div>
))}
{activityData && activityData.pagination.total > 5 && (
<div className="pt-2">
<Button asChild variant="outline" size="sm" className="w-full">
<Link href={`/studies/${study.id}/activity`}>
View All Activity ({activityData.pagination.total})
</Link>
</Button>
</div>
)}
</div>
)}
</EntityViewSection>
</div>
@@ -280,19 +408,19 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
stats={[
{
label: "Experiments",
value: mockStats.experiments,
value: stats.experiments,
},
{
label: "Total Trials",
value: mockStats.totalTrials,
value: stats.totalTrials,
},
{
label: "Participants",
value: mockStats.participants,
value: stats.participants,
},
{
label: "Completion Rate",
value: mockStats.completionRate,
value: stats.completionRate,
color: "success",
},
]}

View File

@@ -5,7 +5,6 @@ import {
BarChart3,
Bot,
Camera,
CheckCircle,
Clock,
Download,
FileText,
@@ -21,6 +20,11 @@ import { notFound, redirect } from "next/navigation";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import {
EntityView,
EntityViewHeader,
EntityViewSection,
} from "~/components/ui/entity-view";
import { Progress } from "~/components/ui/progress";
import { Separator } from "~/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
@@ -44,7 +48,7 @@ export default async function AnalysisPage({ params }: AnalysisPageProps) {
let trial;
try {
trial = await api.trials.get({ id: trialId });
} catch (_error) {
} catch {
notFound();
}
@@ -65,7 +69,12 @@ export default async function AnalysisPage({ params }: AnalysisPageProps) {
: 0;
// Mock experiment steps - in real implementation, fetch from experiment API
const experimentSteps: any[] = [];
const experimentSteps: Array<{
id: string;
name: string;
description?: string;
order: number;
}> = [];
// Mock analysis data - in real implementation, this would come from API
const analysisData = {
@@ -82,33 +91,18 @@ export default async function AnalysisPage({ params }: AnalysisPageProps) {
};
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<div className="border-b border-slate-200 bg-white px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/trials/${trial.id}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trial
</Link>
</Button>
<Separator orientation="vertical" className="h-6" />
<div>
<h1 className="text-2xl font-bold text-slate-900">
Trial Analysis
</h1>
<p className="mt-1 text-sm text-slate-600">
{trial.experiment.name} Participant:{" "}
{trial.participant.participantCode}
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<Badge className="bg-green-100 text-green-800" variant="secondary">
<CheckCircle className="mr-1 h-3 w-3" />
Completed
</Badge>
<EntityView>
<EntityViewHeader
title="Trial Analysis"
subtitle={`${trial.experiment.name} • Participant: ${trial.participant.participantCode}`}
icon="BarChart3"
status={{
label: "Completed",
variant: "default",
icon: "CheckCircle",
}}
actions={
<>
<Button variant="outline">
<Download className="mr-2 h-4 w-4" />
Export Data
@@ -117,417 +111,414 @@ export default async function AnalysisPage({ params }: AnalysisPageProps) {
<Share className="mr-2 h-4 w-4" />
Share Results
</Button>
</div>
</div>
</div>
<Button asChild variant="ghost">
<Link href={`/trials/${trial.id}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trial
</Link>
</Button>
</>
}
/>
<div className="space-y-6 p-6">
{/* Trial Summary Cards */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardContent className="p-4">
<div className="space-y-8">
{/* Trial Summary Stats */}
<EntityViewSection title="Trial Summary" icon="Target">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<div className="bg-card rounded-lg border p-3">
<div className="flex items-center space-x-2">
<Timer className="h-4 w-4 text-blue-600" />
<div>
<p className="text-sm font-medium text-slate-600">Duration</p>
<p className="text-muted-foreground text-xs">Duration</p>
<p className="text-lg font-semibold">{duration} min</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
</div>
<div className="bg-card rounded-lg border p-3">
<div className="flex items-center space-x-2">
<Target className="h-4 w-4 text-green-600" />
<div>
<p className="text-sm font-medium text-slate-600">
<p className="text-muted-foreground text-xs">
Completion Rate
</p>
<p className="text-lg font-semibold">
<p className="text-lg font-semibold text-green-600">
{analysisData.completionRate}%
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
</div>
<div className="bg-card rounded-lg border p-3">
<div className="flex items-center space-x-2">
<Activity className="h-4 w-4 text-purple-600" />
<div>
<p className="text-sm font-medium text-slate-600">
Total Events
</p>
<p className="text-muted-foreground text-xs">Total Events</p>
<p className="text-lg font-semibold">
{analysisData.totalEvents}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
</div>
<div className="bg-card rounded-lg border p-3">
<div className="flex items-center space-x-2">
<TrendingUp className="h-4 w-4 text-orange-600" />
<div>
<p className="text-sm font-medium text-slate-600">
Success Rate
</p>
<p className="text-lg font-semibold">
<p className="text-muted-foreground text-xs">Success Rate</p>
<p className="text-lg font-semibold text-green-600">
{analysisData.successRate}%
</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</EntityViewSection>
{/* Main Analysis Content */}
<Tabs defaultValue="overview" className="space-y-6">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="timeline">Timeline</TabsTrigger>
<TabsTrigger value="interactions">Interactions</TabsTrigger>
<TabsTrigger value="media">Media</TabsTrigger>
<TabsTrigger value="export">Export</TabsTrigger>
</TabsList>
<EntityViewSection title="Detailed Analysis" icon="Activity">
<Tabs defaultValue="overview" className="space-y-6">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="timeline">Timeline</TabsTrigger>
<TabsTrigger value="interactions">Interactions</TabsTrigger>
<TabsTrigger value="media">Media</TabsTrigger>
<TabsTrigger value="export">Export</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Performance Metrics */}
<TabsContent value="overview" className="space-y-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Performance Metrics */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<BarChart3 className="h-5 w-5" />
<span>Performance Metrics</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div>
<div className="mb-1 flex justify-between text-sm">
<span>Task Completion</span>
<span>{analysisData.completionRate}%</span>
</div>
<Progress
value={analysisData.completionRate}
className="h-2"
/>
</div>
<div>
<div className="mb-1 flex justify-between text-sm">
<span>Success Rate</span>
<span>{analysisData.successRate}%</span>
</div>
<Progress
value={analysisData.successRate}
className="h-2"
/>
</div>
<div>
<div className="mb-1 flex justify-between text-sm">
<span>Response Time (avg)</span>
<span>{analysisData.averageResponseTime}s</span>
</div>
<Progress value={75} className="h-2" />
</div>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4 text-center">
<div>
<div className="text-lg font-semibold text-green-600">
{experimentSteps.length}
</div>
<div className="text-xs text-slate-600">
Steps Completed
</div>
</div>
<div>
<div className="text-lg font-semibold text-red-600">
{analysisData.errorCount}
</div>
<div className="text-xs text-slate-600">Errors</div>
</div>
</div>
</CardContent>
</Card>
{/* Event Breakdown */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Activity className="h-5 w-5" />
<span>Event Breakdown</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Bot className="h-4 w-4 text-green-600" />
<span className="text-sm">Robot Actions</span>
</div>
<Badge variant="outline">
{analysisData.robotActions}
</Badge>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<User className="h-4 w-4 text-blue-600" />
<span className="text-sm">Wizard Interventions</span>
</div>
<Badge variant="outline">
{analysisData.wizardInterventions}
</Badge>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<MessageSquare className="h-4 w-4 text-purple-600" />
<span className="text-sm">Participant Responses</span>
</div>
<Badge variant="outline">
{analysisData.participantResponses}
</Badge>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Camera className="h-4 w-4 text-indigo-600" />
<span className="text-sm">Media Captures</span>
</div>
<Badge variant="outline">
{analysisData.mediaCaptures}
</Badge>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<FileText className="h-4 w-4 text-orange-600" />
<span className="text-sm">Annotations</span>
</div>
<Badge variant="outline">
{analysisData.annotations}
</Badge>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Trial Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<BarChart3 className="h-5 w-5" />
<span>Performance Metrics</span>
<FileText className="h-5 w-5" />
<span>Trial Information</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<div>
<div className="mb-1 flex justify-between text-sm">
<span>Task Completion</span>
<span>{analysisData.completionRate}%</span>
</div>
<Progress
value={analysisData.completionRate}
className="h-2"
/>
</div>
<div>
<div className="mb-1 flex justify-between text-sm">
<span>Success Rate</span>
<span>{analysisData.successRate}%</span>
</div>
<Progress
value={analysisData.successRate}
className="h-2"
/>
</div>
<div>
<div className="mb-1 flex justify-between text-sm">
<span>Response Time (avg)</span>
<span>{analysisData.averageResponseTime}s</span>
</div>
<Progress value={75} className="h-2" />
</div>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4 text-center">
<div>
<div className="text-lg font-semibold text-green-600">
{experimentSteps.length}
</div>
<div className="text-xs text-slate-600">
Steps Completed
</div>
<label className="text-sm font-medium text-slate-600">
Started
</label>
<p className="text-sm">
{trial.startedAt
? format(trial.startedAt, "PPP 'at' p")
: "N/A"}
</p>
</div>
<div>
<div className="text-lg font-semibold text-red-600">
{analysisData.errorCount}
</div>
<div className="text-xs text-slate-600">Errors</div>
<label className="text-sm font-medium text-slate-600">
Completed
</label>
<p className="text-sm">
{trial.completedAt
? format(trial.completedAt, "PPP 'at' p")
: "N/A"}
</p>
</div>
<div>
<label className="text-sm font-medium text-slate-600">
Participant
</label>
<p className="text-sm">
{trial.participant.participantCode}
</p>
</div>
<div>
<label className="text-sm font-medium text-slate-600">
Wizard
</label>
<p className="text-sm">N/A</p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Event Breakdown */}
<TabsContent value="timeline" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Activity className="h-5 w-5" />
<span>Event Breakdown</span>
<Clock className="h-5 w-5" />
<span>Event Timeline</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Bot className="h-4 w-4 text-green-600" />
<span className="text-sm">Robot Actions</span>
</div>
<Badge variant="outline">
{analysisData.robotActions}
</Badge>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<User className="h-4 w-4 text-blue-600" />
<span className="text-sm">Wizard Interventions</span>
</div>
<Badge variant="outline">
{analysisData.wizardInterventions}
</Badge>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<MessageSquare className="h-4 w-4 text-purple-600" />
<span className="text-sm">Participant Responses</span>
</div>
<Badge variant="outline">
{analysisData.participantResponses}
</Badge>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Camera className="h-4 w-4 text-indigo-600" />
<span className="text-sm">Media Captures</span>
</div>
<Badge variant="outline">
{analysisData.mediaCaptures}
</Badge>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<FileText className="h-4 w-4 text-orange-600" />
<span className="text-sm">Annotations</span>
</div>
<Badge variant="outline">
{analysisData.annotations}
</Badge>
</div>
<CardContent>
<div className="py-12 text-center text-slate-500">
<Clock className="mx-auto mb-4 h-12 w-12 opacity-50" />
<h3 className="mb-2 text-lg font-medium">
Timeline Analysis
</h3>
<p className="text-sm">
Detailed timeline visualization and event analysis will be
available here. This would show the sequence of all trial
events with timestamps.
</p>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
{/* Trial Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<FileText className="h-5 w-5" />
<span>Trial Information</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<div>
<label className="text-sm font-medium text-slate-600">
Started
</label>
<TabsContent value="interactions" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<MessageSquare className="h-5 w-5" />
<span>Interaction Analysis</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="py-12 text-center text-slate-500">
<MessageSquare className="mx-auto mb-4 h-12 w-12 opacity-50" />
<h3 className="mb-2 text-lg font-medium">
Interaction Patterns
</h3>
<p className="text-sm">
{trial.startedAt
? format(trial.startedAt, "PPP 'at' p")
: "N/A"}
Analysis of participant-robot interactions, communication
patterns, and behavioral observations will be displayed
here.
</p>
</div>
<div>
<label className="text-sm font-medium text-slate-600">
Completed
</label>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="media" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Camera className="h-5 w-5" />
<span>Media Recordings</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="py-12 text-center text-slate-500">
<Camera className="mx-auto mb-4 h-12 w-12 opacity-50" />
<h3 className="mb-2 text-lg font-medium">Media Gallery</h3>
<p className="text-sm">
{trial.completedAt
? format(trial.completedAt, "PPP 'at' p")
: "N/A"}
Video recordings, audio captures, and sensor data
visualizations from the trial will be available for review
here.
</p>
</div>
<div>
<label className="text-sm font-medium text-slate-600">
Participant
</label>
<p className="text-sm">
{trial.participant.participantCode}
</p>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="export" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Download className="h-5 w-5" />
<span>Export Data</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-slate-600">
Export trial data in various formats for further analysis or
reporting.
</p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Button
variant="outline"
className="h-auto justify-start p-4"
>
<div className="flex items-start space-x-3">
<FileText className="mt-0.5 h-5 w-5" />
<div className="text-left">
<div className="font-medium">Trial Report (PDF)</div>
<div className="mt-1 text-xs text-slate-500">
Complete analysis report with visualizations
</div>
</div>
</div>
</Button>
<Button
variant="outline"
className="h-auto justify-start p-4"
>
<div className="flex items-start space-x-3">
<BarChart3 className="mt-0.5 h-5 w-5" />
<div className="text-left">
<div className="font-medium">Raw Data (CSV)</div>
<div className="mt-1 text-xs text-slate-500">
Event data, timestamps, and measurements
</div>
</div>
</div>
</Button>
<Button
variant="outline"
className="h-auto justify-start p-4"
>
<div className="flex items-start space-x-3">
<Camera className="mt-0.5 h-5 w-5" />
<div className="text-left">
<div className="font-medium">Media Archive (ZIP)</div>
<div className="mt-1 text-xs text-slate-500">
All video, audio, and sensor recordings
</div>
</div>
</div>
</Button>
<Button
variant="outline"
className="h-auto justify-start p-4"
>
<div className="flex items-start space-x-3">
<MessageSquare className="mt-0.5 h-5 w-5" />
<div className="text-left">
<div className="font-medium">Annotations (JSON)</div>
<div className="mt-1 text-xs text-slate-500">
Researcher notes and coded observations
</div>
</div>
</div>
</Button>
</div>
<div>
<label className="text-sm font-medium text-slate-600">
Wizard
</label>
<p className="text-sm">N/A</p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="timeline" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Clock className="h-5 w-5" />
<span>Event Timeline</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="py-12 text-center text-slate-500">
<Clock className="mx-auto mb-4 h-12 w-12 opacity-50" />
<h3 className="mb-2 text-lg font-medium">
Timeline Analysis
</h3>
<p className="text-sm">
Detailed timeline visualization and event analysis will be
available here. This would show the sequence of all trial
events with timestamps.
</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="interactions" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<MessageSquare className="h-5 w-5" />
<span>Interaction Analysis</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="py-12 text-center text-slate-500">
<MessageSquare className="mx-auto mb-4 h-12 w-12 opacity-50" />
<h3 className="mb-2 text-lg font-medium">
Interaction Patterns
</h3>
<p className="text-sm">
Analysis of participant-robot interactions, communication
patterns, and behavioral observations will be displayed
here.
</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="media" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Camera className="h-5 w-5" />
<span>Media Recordings</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="py-12 text-center text-slate-500">
<Camera className="mx-auto mb-4 h-12 w-12 opacity-50" />
<h3 className="mb-2 text-lg font-medium">Media Gallery</h3>
<p className="text-sm">
Video recordings, audio captures, and sensor data
visualizations from the trial will be available for review
here.
</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="export" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Download className="h-5 w-5" />
<span>Export Data</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-slate-600">
Export trial data in various formats for further analysis or
reporting.
</p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Button
variant="outline"
className="h-auto justify-start p-4"
>
<div className="flex items-start space-x-3">
<FileText className="mt-0.5 h-5 w-5" />
<div className="text-left">
<div className="font-medium">Trial Report (PDF)</div>
<div className="mt-1 text-xs text-slate-500">
Complete analysis report with visualizations
</div>
</div>
</div>
</Button>
<Button
variant="outline"
className="h-auto justify-start p-4"
>
<div className="flex items-start space-x-3">
<BarChart3 className="mt-0.5 h-5 w-5" />
<div className="text-left">
<div className="font-medium">Raw Data (CSV)</div>
<div className="mt-1 text-xs text-slate-500">
Event data, timestamps, and measurements
</div>
</div>
</div>
</Button>
<Button
variant="outline"
className="h-auto justify-start p-4"
>
<div className="flex items-start space-x-3">
<Camera className="mt-0.5 h-5 w-5" />
<div className="text-left">
<div className="font-medium">Media Archive (ZIP)</div>
<div className="mt-1 text-xs text-slate-500">
All video, audio, and sensor recordings
</div>
</div>
</div>
</Button>
<Button
variant="outline"
className="h-auto justify-start p-4"
>
<div className="flex items-start space-x-3">
<MessageSquare className="mt-0.5 h-5 w-5" />
<div className="text-left">
<div className="font-medium">Annotations (JSON)</div>
<div className="mt-1 text-xs text-slate-500">
Researcher notes and coded observations
</div>
</div>
</div>
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</EntityViewSection>
</div>
</div>
</EntityView>
);
}
// Generate metadata for the page
export async function generateMetadata({ params }: AnalysisPageProps) {
export async function generateMetadata({
params,
}: AnalysisPageProps): Promise<{ title: string; description: string }> {
try {
const { trialId } = await params;
const trial = await api.trials.get({ id: trialId });

View File

@@ -1,16 +1,9 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import {
AlertCircle,
Calendar,
CheckCircle,
Eye,
Info,
Play,
Zap,
} from "lucide-react";
import { AlertCircle, Eye, Info, Play, Zap } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
@@ -101,6 +94,8 @@ export default function TrialDetailPage({
searchParams,
}: TrialDetailPageProps) {
const { data: session } = useSession();
const router = useRouter();
const startTrialMutation = api.trials.start.useMutation();
const [trial, setTrial] = useState<Trial | null>(null);
const [events, setEvents] = useState<TrialEvent[]>([]);
const [loading, setLoading] = useState(true);
@@ -192,6 +187,12 @@ export default function TrialDetailPage({
const canControl =
userRoles.includes("wizard") || userRoles.includes("researcher");
const handleStartTrial = async () => {
if (!trial) return;
await startTrialMutation.mutateAsync({ id: trial.id });
router.push(`/trials/${trial.id}/wizard`);
};
const displayName = `Trial #${trial.id.slice(-6)}`;
const experimentName = trial.experiment?.name ?? "Unknown Experiment";
@@ -219,12 +220,21 @@ export default function TrialDetailPage({
actions={
<>
{canControl && trial.status === "scheduled" && (
<Button asChild>
<Link href={`/trials/${trial.id}/wizard`}>
<>
<Button
onClick={handleStartTrial}
disabled={startTrialMutation.isPending}
>
<Play className="mr-2 h-4 w-4" />
Start Trial
</Link>
</Button>
{startTrialMutation.isPending ? "Starting..." : "Start"}
</Button>
<Button asChild variant="outline">
<Link href={`/trials/${trial.id}/start`}>
<Zap className="mr-2 h-4 w-4" />
Preflight
</Link>
</Button>
</>
)}
{canControl && trial.status === "in_progress" && (
<Button asChild variant="secondary">
@@ -238,7 +248,7 @@ export default function TrialDetailPage({
<Button asChild variant="outline">
<Link href={`/trials/${trial.id}/analysis`}>
<Info className="mr-2 h-4 w-4" />
View Analysis
Analysis
</Link>
</Button>
)}

View File

@@ -0,0 +1,243 @@
import { formatDistanceToNow } from "date-fns";
import {
AlertTriangle,
ArrowLeft,
CheckCircle2,
Clock,
FlaskConical,
Play,
TestTube,
User,
} from "lucide-react";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator";
import { auth } from "~/server/auth";
import { api } from "~/trpc/server";
interface StartPageProps {
params: Promise<{
trialId: string;
}>;
}
export default async function StartTrialPage({ params }: StartPageProps) {
const session = await auth();
if (!session) {
redirect("/auth/signin");
}
const role = session.user.roles?.[0]?.role ?? "observer";
if (!["wizard", "researcher", "administrator"].includes(role)) {
redirect("/trials?error=insufficient_permissions");
}
const { trialId } = await params;
let trial: Awaited<ReturnType<typeof api.trials.get>>;
try {
trial = await api.trials.get({ id: trialId });
} catch {
notFound();
}
// Guard: Only allow start from scheduled; if in progress, go to wizard; if completed, go to analysis
if (trial.status === "in_progress") {
redirect(`/trials/${trialId}/wizard`);
}
if (trial.status === "completed") {
redirect(`/trials/${trialId}/analysis`);
}
if (!["scheduled"].includes(trial.status)) {
redirect(`/trials/${trialId}?error=trial_not_startable`);
}
// Server Action: start trial and redirect to wizard
async function startTrial() {
"use server";
// Confirm auth on action too
const s = await auth();
if (!s) redirect("/auth/signin");
const r = s.user.roles?.[0]?.role ?? "observer";
if (!["wizard", "researcher", "administrator"].includes(r)) {
redirect(`/trials/${trialId}?error=insufficient_permissions`);
}
await api.trials.start({ id: trialId });
redirect(`/trials/${trialId}/wizard`);
}
const scheduled =
trial.scheduledAt instanceof Date
? trial.scheduledAt
: trial.scheduledAt
? new Date(trial.scheduledAt)
: null;
const hasWizardAssigned = Boolean(trial.wizardId);
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<div className="border-b border-slate-200 bg-white px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button asChild variant="ghost" size="sm">
<Link href={`/trials/${trial.id}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trial
</Link>
</Button>
<Separator orientation="vertical" className="h-6" />
<div>
<h1 className="text-2xl font-bold text-slate-900">Start Trial</h1>
<p className="mt-1 text-sm text-slate-600">
{trial.experiment.name} Participant:{" "}
{trial.participant.participantCode}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-blue-50 text-blue-700">
Scheduled
</Badge>
</div>
</div>
</div>
{/* Content */}
<div className="mx-auto max-w-5xl space-y-6 p-6">
{/* Summary */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-700">
Experiment
</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-2">
<FlaskConical className="h-4 w-4 text-slate-600" />
<div className="text-sm font-semibold text-slate-900">
{trial.experiment.name}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-700">
Participant
</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-2">
<User className="h-4 w-4 text-slate-600" />
<div className="text-sm font-semibold text-slate-900">
{trial.participant.participantCode}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-700">
Scheduled
</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-2">
<Clock className="h-4 w-4 text-slate-600" />
<div className="text-sm font-semibold text-slate-900">
{scheduled
? `${formatDistanceToNow(scheduled, { addSuffix: true })}`
: "Not set"}
</div>
</CardContent>
</Card>
</div>
{/* Preflight Checks */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<TestTube className="h-4 w-4 text-slate-700" />
Preflight Checklist
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-start gap-3 rounded-md border border-slate-200 bg-white p-3">
<CheckCircle2 className="mt-0.5 h-4 w-4 text-green-600" />
<div className="text-sm">
<div className="font-medium text-slate-900">Permissions</div>
<div className="text-slate-600">
You have sufficient permissions to start this trial.
</div>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border border-slate-200 bg-white p-3">
{hasWizardAssigned ? (
<CheckCircle2 className="mt-0.5 h-4 w-4 text-green-600" />
) : (
<AlertTriangle className="mt-0.5 h-4 w-4 text-amber-600" />
)}
<div className="text-sm">
<div className="font-medium text-slate-900">Wizard</div>
<div className="text-slate-600">
{hasWizardAssigned
? "A wizard has been assigned to this trial."
: "No wizard assigned. You can still start, but consider assigning a wizard for clarity."}
</div>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border border-slate-200 bg-white p-3">
<CheckCircle2 className="mt-0.5 h-4 w-4 text-green-600" />
<div className="text-sm">
<div className="font-medium text-slate-900">Status</div>
<div className="text-slate-600">
Trial is currently scheduled and ready to start.
</div>
</div>
</div>
</CardContent>
</Card>
{/* Actions */}
<div className="flex items-center justify-between">
<Button asChild variant="ghost">
<Link href={`/trials/${trial.id}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Cancel
</Link>
</Button>
<form action={startTrial}>
<Button type="submit" className="shadow-sm">
<Play className="mr-2 h-4 w-4" />
Start Trial
</Button>
</form>
</div>
</div>
</div>
);
}
export async function generateMetadata({
params,
}: StartPageProps): Promise<{ title: string; description: string }> {
try {
const { trialId } = await params;
const trial = await api.trials.get({ id: trialId });
return {
title: `Start Trial - ${trial.experiment.name} | HRIStudio`,
description: `Preflight and start trial for participant ${trial.participant.participantCode}`,
};
} catch {
return {
title: "Start Trial | HRIStudio",
description: "Preflight checklist to start an HRI trial",
};
}
}

View File

@@ -29,7 +29,7 @@ export default async function WizardPage({ params }: WizardPageProps) {
let trial;
try {
trial = await api.trials.get({ id: trialId });
} catch (_error) {
} catch {
notFound();
}
@@ -38,51 +38,29 @@ export default async function WizardPage({ params }: WizardPageProps) {
redirect(`/trials/${trialId}?error=trial_not_active`);
}
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<div className="border-b border-slate-200 bg-white px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">
Wizard Control Interface
</h1>
<p className="mt-1 text-sm text-slate-600">
{trial.experiment.name} Participant:{" "}
{trial.participant.participantCode}
</p>
</div>
<div className="flex items-center space-x-2">
<div
className={`flex items-center space-x-2 rounded-full px-3 py-1 text-sm font-medium ${
trial.status === "in_progress"
? "bg-green-100 text-green-800"
: "bg-blue-100 text-blue-800"
}`}
>
<div
className={`h-2 w-2 rounded-full ${
trial.status === "in_progress"
? "animate-pulse bg-green-500"
: "bg-blue-500"
}`}
></div>
{trial.status === "in_progress"
? "Trial Active"
: "Ready to Start"}
</div>
</div>
</div>
</div>
const normalizedTrial = {
...trial,
metadata:
typeof trial.metadata === "object" && trial.metadata !== null
? (trial.metadata as Record<string, unknown>)
: null,
participant: {
...trial.participant,
demographics:
typeof trial.participant.demographics === "object" &&
trial.participant.demographics !== null
? (trial.participant.demographics as Record<string, unknown>)
: null,
},
};
{/* Main Wizard Interface */}
<WizardInterface trial={trial} userRole={userRole} />
</div>
);
return <WizardInterface trial={normalizedTrial} userRole={userRole} />;
}
// Generate metadata for the page
export async function generateMetadata({ params }: WizardPageProps) {
export async function generateMetadata({
params,
}: WizardPageProps): Promise<{ title: string; description: string }> {
try {
const { trialId } = await params;
const trial = await api.trials.get({ id: trialId });

View File

@@ -1,43 +1,391 @@
import { type NextRequest } from "next/server";
export const runtime = "edge";
// Store active WebSocket connections (for external WebSocket server)
// These would be used by a separate WebSocket implementation
// const connections = new Map<string, Set<WebSocket>>();
// const userConnections = new Map<
// string,
// { userId: string; trialId: string; role: string }
// >();
declare global {
var WebSocketPair: new () => { 0: WebSocket; 1: WebSocket };
export const runtime = "nodejs";
interface WebSocket {
accept(): void;
}
export async function GET(request: NextRequest) {
const url = new URL(request.url);
const trialId = url.searchParams.get("trialId");
const token = url.searchParams.get("token");
interface ResponseInit {
webSocket?: WebSocket;
}
}
type Json = Record<string, unknown>;
interface ClientInfo {
userId: string | null;
role: "wizard" | "researcher" | "administrator" | "observer" | "unknown";
connectedAt: number;
}
interface TrialState {
trial: {
id: string;
status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
startedAt: string | null;
completedAt: string | null;
};
currentStepIndex: number;
updatedAt: number;
}
declare global {
// Per-trial subscriber sets
// Using globalThis for ephemeral in-memory broadcast in the current Edge isolate
// (not shared globally across regions/instances)
var __trialRooms: Map<string, Set<WebSocket>> | undefined;
var __trialState: Map<string, TrialState> | undefined;
}
const rooms = (globalThis.__trialRooms ??= new Map<string, Set<WebSocket>>());
const states = (globalThis.__trialState ??= new Map<string, TrialState>());
function safeJSON<T>(v: T): string {
try {
return JSON.stringify(v);
} catch {
return '{"type":"error","data":{"message":"serialization_error"}}';
}
}
function send(ws: WebSocket, message: { type: string; data?: Json }) {
try {
ws.send(safeJSON(message));
} catch {
// swallow send errors
}
}
function broadcast(trialId: string, message: { type: string; data?: Json }) {
const room = rooms.get(trialId);
if (!room) return;
const payload = safeJSON(message);
for (const client of room) {
try {
client.send(payload);
} catch {
// ignore individual client send failure
}
}
}
function ensureTrialState(trialId: string): TrialState {
let state = states.get(trialId);
if (!state) {
state = {
trial: {
id: trialId,
status: "scheduled",
startedAt: null,
completedAt: null,
},
currentStepIndex: 0,
updatedAt: Date.now(),
};
states.set(trialId, state);
}
return state;
}
function updateTrialStatus(
trialId: string,
patch: Partial<TrialState["trial"]> &
Partial<Pick<TrialState, "currentStepIndex">>,
) {
const state = ensureTrialState(trialId);
if (typeof patch.currentStepIndex === "number") {
state.currentStepIndex = patch.currentStepIndex;
}
state.trial = {
...state.trial,
...(patch.status !== undefined ? { status: patch.status } : {}),
...(patch.startedAt !== undefined
? { startedAt: patch.startedAt ?? null }
: {}),
...(patch.completedAt !== undefined
? { completedAt: patch.completedAt ?? null }
: {}),
};
state.updatedAt = Date.now();
states.set(trialId, state);
return state;
}
// Very lightweight token parse (base64-encoded JSON per client hook)
// In production, replace with properly signed JWT verification.
function parseToken(token: string | null): ClientInfo {
if (!token) {
return { userId: null, role: "unknown", connectedAt: Date.now() };
}
try {
const decodedUnknown = JSON.parse(atob(token)) as unknown;
const userId =
typeof decodedUnknown === "object" &&
decodedUnknown !== null &&
"userId" in decodedUnknown &&
typeof (decodedUnknown as Record<string, unknown>).userId === "string"
? ((decodedUnknown as Record<string, unknown>).userId as string)
: null;
const connectedAt = Date.now();
const role: ClientInfo["role"] = "wizard"; // default role for live trial control context
return { userId, role, connectedAt };
} catch {
return { userId: null, role: "unknown", connectedAt: Date.now() };
}
}
export async function GET(req: Request): Promise<Response> {
const { searchParams } = new URL(req.url);
const trialId = searchParams.get("trialId");
const token = searchParams.get("token");
if (!trialId) {
return new Response("Missing trialId parameter", { status: 400 });
}
if (!token) {
return new Response("Missing authentication token", { status: 401 });
// If this isn't a WebSocket upgrade, return a small JSON descriptor
const upgrade = req.headers.get("upgrade") ?? "";
if (upgrade.toLowerCase() !== "websocket") {
return new Response(
safeJSON({
message: "WebSocket endpoint",
trialId,
info: "Open a WebSocket connection to this URL to receive live trial updates.",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
// For WebSocket upgrade, we need to handle this differently in Next.js
// This is a simplified version - in production you'd use a separate WebSocket server
// Create WebSocket pair (typed) and destructure endpoints
const pair = new WebSocketPair();
const client = pair[0];
const server = pair[1];
return new Response(
JSON.stringify({
message: "WebSocket endpoint available",
// Register server-side handlers
server.accept();
const clientInfo = parseToken(token);
// Join room
const room = rooms.get(trialId) ?? new Set<WebSocket>();
room.add(server);
rooms.set(trialId, room);
// Immediately acknowledge connection and provide current trial status snapshot
const state = ensureTrialState(trialId);
send(server, {
type: "connection_established",
data: {
trialId,
endpoint: `/api/websocket?trialId=${trialId}&token=${token}`,
instructions: "Use WebSocket client to connect to this endpoint",
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
},
userId: clientInfo.userId,
role: clientInfo.role,
connectedAt: clientInfo.connectedAt,
},
);
});
send(server, {
type: "trial_status",
data: {
trial: state.trial,
current_step_index: state.currentStepIndex,
timestamp: Date.now(),
},
});
server.addEventListener("message", (ev: MessageEvent<string>) => {
let parsed: unknown;
try {
parsed = JSON.parse(typeof ev.data === "string" ? ev.data : "{}");
} catch {
send(server, {
type: "error",
data: { message: "invalid_json" },
});
return;
}
const maybeObj =
typeof parsed === "object" && parsed !== null
? (parsed as Record<string, unknown>)
: {};
const type = typeof maybeObj.type === "string" ? maybeObj.type : "";
const data: Json =
maybeObj.data &&
typeof maybeObj.data === "object" &&
maybeObj.data !== null
? (maybeObj.data as Record<string, unknown>)
: {};
const now = Date.now();
const getString = (key: string, fallback = ""): string => {
const v = (data as Record<string, unknown>)[key];
return typeof v === "string" ? v : fallback;
};
const getNumber = (key: string): number | undefined => {
const v = (data as Record<string, unknown>)[key];
return typeof v === "number" ? v : undefined;
};
switch (type) {
case "heartbeat": {
send(server, { type: "heartbeat_response", data: { timestamp: now } });
break;
}
case "request_trial_status": {
const s = ensureTrialState(trialId);
send(server, {
type: "trial_status",
data: {
trial: s.trial,
current_step_index: s.currentStepIndex,
timestamp: now,
},
});
break;
}
case "trial_action": {
// Supports: start_trial, complete_trial, abort_trial, and generic actions
const actionType = getString("actionType", "unknown");
let updated: TrialState | null = null;
if (actionType === "start_trial") {
const stepIdx = getNumber("step_index") ?? 0;
updated = updateTrialStatus(trialId, {
status: "in_progress",
startedAt: new Date().toISOString(),
currentStepIndex: stepIdx,
});
} else if (actionType === "complete_trial") {
updated = updateTrialStatus(trialId, {
status: "completed",
completedAt: new Date().toISOString(),
});
} else if (actionType === "abort_trial") {
updated = updateTrialStatus(trialId, {
status: "aborted",
completedAt: new Date().toISOString(),
});
}
// Broadcast the action execution event
broadcast(trialId, {
type: "trial_action_executed",
data: {
action_type: actionType,
timestamp: now,
userId: clientInfo.userId,
...data,
},
});
// If trial state changed, broadcast status
if (updated) {
broadcast(trialId, {
type: "trial_status",
data: {
trial: updated.trial,
current_step_index: updated.currentStepIndex,
timestamp: now,
},
});
}
break;
}
case "wizard_intervention": {
// Log/broadcast a wizard intervention (note, correction, manual control)
broadcast(trialId, {
type: "intervention_logged",
data: {
timestamp: now,
userId: clientInfo.userId,
...data,
},
});
break;
}
case "step_transition": {
// Update step index and broadcast
const from = getNumber("from_step");
const to = getNumber("to_step");
if (typeof to !== "number" || !Number.isFinite(to)) {
send(server, {
type: "error",
data: { message: "invalid_step_transition" },
});
return;
}
const updated = updateTrialStatus(trialId, {
currentStepIndex: to,
});
broadcast(trialId, {
type: "step_changed",
data: {
timestamp: now,
userId: clientInfo.userId,
from_step:
typeof from === "number" ? from : updated.currentStepIndex,
to_step: updated.currentStepIndex,
...data,
},
});
break;
}
default: {
// Relay unknown/custom messages to participants in the same trial room
broadcast(trialId, {
type: type !== "" ? type : "message",
data: {
timestamp: now,
userId: clientInfo.userId,
...data,
},
});
break;
}
}
});
server.addEventListener("close", () => {
const room = rooms.get(trialId);
if (room) {
room.delete(server);
if (room.size === 0) {
rooms.delete(trialId);
}
}
});
server.addEventListener("error", () => {
try {
server.close();
} catch {
// ignore
}
const room = rooms.get(trialId);
if (room) {
room.delete(server);
if (room.size === 0) {
rooms.delete(trialId);
}
}
});
// Hand over the client end of the socket to the response
return new Response(null, {
status: 101,
webSocket: client,
});
}

View File

@@ -16,7 +16,10 @@ interface AdminContentProps {
userEmail: string;
}
export function AdminContent({ userName, userEmail }: AdminContentProps) {
export function AdminContent({
userName,
userEmail: _userEmail,
}: AdminContentProps) {
const quickActions = [
{
title: "Manage Users",
@@ -27,9 +30,17 @@ export function AdminContent({ userName, userEmail }: AdminContentProps) {
},
];
const stats: any[] = [];
const stats: Array<{
title: string;
value: string | number;
description?: string;
}> = [];
const alerts: any[] = [];
const alerts: Array<{
type: "info" | "warning" | "error";
title: string;
message: string;
}> = [];
const recentActivity = (
<div className="space-y-6">

View File

@@ -3,15 +3,15 @@
import { formatDistanceToNow } from "date-fns";
import { Calendar, FlaskConical, Plus, Settings, Users } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Separator } from "~/components/ui/separator";
import { api } from "~/trpc/react";
@@ -173,8 +173,6 @@ function ExperimentCard({ experiment }: ExperimentCardProps) {
}
export function ExperimentsGrid() {
const [refreshKey, setRefreshKey] = useState(0);
const {
data: experimentsData,
isLoading,
@@ -189,11 +187,6 @@ export function ExperimentsGrid() {
const experiments = experimentsData?.experiments ?? [];
const handleExperimentCreated = () => {
setRefreshKey((prev) => prev + 1);
void refetch();
};
if (isLoading) {
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
@@ -295,10 +288,10 @@ export function ExperimentsGrid() {
Failed to Load Experiments
</h3>
<p className="mb-4 text-slate-600">
{error.message ||
{error?.message ??
"An error occurred while loading your experiments."}
</p>
<Button onClick={() => refetch()} variant="outline">
<Button onClick={() => void refetch()} variant="outline">
Try Again
</Button>
</div>
@@ -320,52 +313,54 @@ export function ExperimentsGrid() {
{/* Grid */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{/* Create New Experiment Card */}
<Card className="border-2 border-dashed border-slate-300 transition-colors hover:border-slate-400">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-blue-100">
<Plus className="h-8 w-8 text-blue-600" />
</div>
<CardTitle>Create New Experiment</CardTitle>
<CardDescription>Design a new experimental protocol</CardDescription>
</CardHeader>
<CardContent>
<Button asChild className="w-full">
<Link href="/experiments/new">Create Experiment</Link>
</Button>
</CardContent>
</Card>
{/* Experiments */}
{experiments.map((experiment) => (
<ExperimentCard key={experiment.id} experiment={experiment} />
))}
{/* Empty State */}
{experiments.length === 0 && (
<Card className="md:col-span-2 lg:col-span-2">
<CardContent className="pt-6">
<div className="text-center">
<div className="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-lg bg-slate-100">
<FlaskConical className="h-12 w-12 text-slate-400" />
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-900">
No Experiments Yet
</h3>
<p className="mb-4 text-slate-600">
Create your first experiment to start designing HRI protocols.
Experiments define the structure and flow of your research
trials.
</p>
<Button asChild>
<Link href="/experiments/new">
Create Your First Experiment
</Link>
</Button>
{/* Create New Experiment Card */}
<Card className="border-2 border-dashed border-slate-300 transition-colors hover:border-slate-400">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-blue-100">
<Plus className="h-8 w-8 text-blue-600" />
</div>
<CardTitle>Create New Experiment</CardTitle>
<CardDescription>
Design a new experimental protocol
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild className="w-full">
<Link href="/experiments/new">Create Experiment</Link>
</Button>
</CardContent>
</Card>
)}
{/* Experiments */}
{experiments.map((experiment) => (
<ExperimentCard key={experiment.id} experiment={experiment} />
))}
{/* Empty State */}
{experiments.length === 0 && (
<Card className="md:col-span-2 lg:col-span-2">
<CardContent className="pt-6">
<div className="text-center">
<div className="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-lg bg-slate-100">
<FlaskConical className="h-12 w-12 text-slate-400" />
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-900">
No Experiments Yet
</h3>
<p className="mb-4 text-slate-600">
Create your first experiment to start designing HRI protocols.
Experiments define the structure and flow of your research
trials.
</p>
<Button asChild>
<Link href="/experiments/new">
Create Your First Experiment
</Link>
</Button>
</div>
</CardContent>
</Card>
)}
</div>
</div>
);

View File

@@ -1,250 +0,0 @@
"use client";
import React, { useState } from "react";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { cn } from "~/lib/utils";
import { useActionRegistry } from "./ActionRegistry";
import type { ActionDefinition } from "~/lib/experiment-designer/types";
import {
Plus,
User,
Bot,
GitBranch,
Eye,
GripVertical,
Zap,
MessageSquare,
Hand,
Navigation,
Volume2,
Clock,
Timer,
MousePointer,
Mic,
Activity,
Play,
} from "lucide-react";
import { useDraggable } from "@dnd-kit/core";
// Local icon map (duplicated minimal map for isolation to avoid circular imports)
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
MessageSquare,
Hand,
Navigation,
Volume2,
Clock,
Eye,
Bot,
User,
Zap,
Timer,
MousePointer,
Mic,
Activity,
Play,
};
interface DraggableActionProps {
action: ActionDefinition;
}
function DraggableAction({ action }: DraggableActionProps) {
const [showTooltip, setShowTooltip] = useState(false);
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
id: `action-${action.id}`,
data: { action },
});
const style = {
transform: transform
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
: undefined,
};
const IconComponent = iconMap[action.icon] ?? Zap;
const categoryColors: Record<ActionDefinition["category"], string> = {
wizard: "bg-blue-500",
robot: "bg-emerald-500",
control: "bg-amber-500",
observation: "bg-purple-500",
};
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className={cn(
"group hover:bg-accent/50 relative flex cursor-grab items-center gap-2 rounded-md border p-2 text-xs transition-colors",
isDragging && "opacity-50",
)}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
draggable={false}
>
<div
className={cn(
"flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-white",
categoryColors[action.category],
)}
>
<IconComponent className="h-3 w-3" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1 truncate font-medium">
{action.source.kind === "plugin" ? (
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-emerald-600 text-[8px] font-bold text-white">
P
</span>
) : (
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-slate-500 text-[8px] font-bold text-white">
C
</span>
)}
{action.name}
</div>
<div className="text-muted-foreground truncate text-xs">
{action.description ?? ""}
</div>
</div>
<div className="text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100">
<GripVertical className="h-3 w-3" />
</div>
{showTooltip && (
<div className="bg-popover absolute top-0 left-full z-50 ml-2 max-w-xs rounded-md border p-2 text-xs shadow-md">
<div className="font-medium">{action.name}</div>
<div className="text-muted-foreground">{action.description}</div>
<div className="mt-1 text-xs opacity-75">
Category: {action.category} ID: {action.id}
</div>
{action.parameters.length > 0 && (
<div className="mt-1 text-xs opacity-75">
Parameters: {action.parameters.map((p) => p.name).join(", ")}
</div>
)}
</div>
)}
</div>
);
}
export interface ActionLibraryProps {
className?: string;
}
export function ActionLibrary({ className }: ActionLibraryProps) {
const registry = useActionRegistry();
const [activeCategory, setActiveCategory] =
useState<ActionDefinition["category"]>("wizard");
const categories: Array<{
key: ActionDefinition["category"];
label: string;
icon: React.ComponentType<{ className?: string }>;
color: string;
}> = [
{
key: "wizard",
label: "Wizard",
icon: User,
color: "bg-blue-500",
},
{
key: "robot",
label: "Robot",
icon: Bot,
color: "bg-emerald-500",
},
{
key: "control",
label: "Control",
icon: GitBranch,
color: "bg-amber-500",
},
{
key: "observation",
label: "Observe",
icon: Eye,
color: "bg-purple-500",
},
];
return (
<div className={cn("flex h-full flex-col", className)}>
{/* Category tabs */}
<div className="border-b p-2">
<div className="grid grid-cols-2 gap-1">
{categories.map((category) => {
const IconComponent = category.icon;
const isActive = activeCategory === category.key;
return (
<Button
key={category.key}
variant={isActive ? "default" : "ghost"}
size="sm"
className={cn(
"h-7 justify-start text-xs",
isActive && `${category.color} text-white hover:opacity-90`,
)}
onClick={() => setActiveCategory(category.key)}
>
<IconComponent className="mr-1 h-3 w-3" />
{category.label}
</Button>
);
})}
</div>
</div>
{/* Actions list */}
<ScrollArea className="flex-1">
<div className="space-y-1 p-2">
{registry.getActionsByCategory(activeCategory).length === 0 ? (
<div className="text-muted-foreground py-8 text-center">
<div className="bg-muted mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full">
<Plus className="h-4 w-4" />
</div>
<p className="text-sm">No actions available</p>
<p className="text-xs">Check plugin configuration</p>
</div>
) : (
registry
.getActionsByCategory(activeCategory)
.map((action) => (
<DraggableAction key={action.id} action={action} />
))
)}
</div>
</ScrollArea>
<div className="border-t p-2">
<div className="flex items-center justify-between">
<Badge variant="secondary" className="text-[10px]">
{registry.getAllActions().length} total
</Badge>
<Badge variant="outline" className="text-[10px]">
{registry.getActionsByCategory(activeCategory).length} in view
</Badge>
</div>
{/* Debug info */}
<div className="text-muted-foreground mt-1 text-[9px]">
W:{registry.getActionsByCategory("wizard").length} R:
{registry.getActionsByCategory("robot").length} C:
{registry.getActionsByCategory("control").length} O:
{registry.getActionsByCategory("observation").length}
</div>
<div className="text-muted-foreground text-[9px]">
Core loaded: {registry.getDebugInfo().coreActionsLoaded ? "✓" : "✗"}
Plugins loaded:{" "}
{registry.getDebugInfo().pluginActionsLoaded ? "✓" : "✗"}
</div>
</div>
</div>
);
}

View File

@@ -1,677 +0,0 @@
"use client";
/**
* @deprecated
* BlockDesigner is being phased out in favor of DesignerShell (see DesignerShell.tsx).
* TODO: Remove this file after full migration of add/update/delete handlers, hashing,
* validation, drift detection, and export logic to the new architecture.
*/
/**
* BlockDesigner (Modular Refactor)
*
* Responsibilities:
* - Own overall experiment design state (steps + actions)
* - Coordinate drag & drop between ActionLibrary (source) and StepFlow (targets)
* - Persist design via experiments.update mutation (optionally compiling execution graph)
* - Trigger server-side validation (experiments.validateDesign) to obtain integrity hash
* - Track & surface "hash drift" (design changed since last validation or mismatch with stored integrityHash)
*
* Extracted Modules:
* - ActionRegistry -> ./ActionRegistry.ts
* - ActionLibrary -> ./ActionLibrary.tsx
* - StepFlow -> ./StepFlow.tsx
* - PropertiesPanel -> ./PropertiesPanel.tsx
*
* Enhancements Added Here:
* - Hash drift indicator logic (Validated / Drift / Unvalidated)
* - Modular wiring replacing previous monolithic file
*/
import React, { useState, useCallback, useEffect, useMemo } from "react";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { arrayMove } from "@dnd-kit/sortable";
import { toast } from "sonner";
import { Save, Download, Play, Plus } from "lucide-react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { ScrollArea } from "~/components/ui/scroll-area";
import { PageHeader, ActionButton } from "~/components/ui/page-header";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import {
type ExperimentDesign,
type ExperimentStep,
type ExperimentAction,
type ActionDefinition,
} from "~/lib/experiment-designer/types";
import { api } from "~/trpc/react";
import { ActionLibrary } from "./ActionLibrary";
import { StepFlow } from "./StepFlow";
import { PropertiesPanel } from "./PropertiesPanel";
import { actionRegistry } from "./ActionRegistry";
/* -------------------------------------------------------------------------- */
/* Utilities */
/* -------------------------------------------------------------------------- */
/**
* Build a lightweight JSON string representing the current design for drift checks.
* We include full steps & actions; param value churn will intentionally flag drift
* (acceptable trade-off for now; can switch to structural signature if too noisy).
*/
function serializeDesignSteps(steps: ExperimentStep[]): string {
return JSON.stringify(
steps.map((s) => ({
id: s.id,
order: s.order,
type: s.type,
trigger: {
type: s.trigger.type,
conditionKeys: Object.keys(s.trigger.conditions).sort(),
},
actions: s.actions.map((a) => ({
id: a.id,
type: a.type,
sourceKind: a.source.kind,
pluginId: a.source.pluginId,
pluginVersion: a.source.pluginVersion,
transport: a.execution.transport,
parameterKeys: Object.keys(a.parameters).sort(),
})),
})),
);
}
/* -------------------------------------------------------------------------- */
/* Props */
/* -------------------------------------------------------------------------- */
interface BlockDesignerProps {
experimentId: string;
initialDesign?: ExperimentDesign;
onSave?: (design: ExperimentDesign) => void;
}
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
export function BlockDesigner({
experimentId,
initialDesign,
onSave,
}: BlockDesignerProps) {
/* ---------------------------- Experiment Query ---------------------------- */
const { data: experiment } = api.experiments.get.useQuery({
id: experimentId,
});
/* ------------------------------ Local Design ------------------------------ */
const [design, setDesign] = useState<ExperimentDesign>(() => {
const defaultDesign: ExperimentDesign = {
id: experimentId,
name: "New Experiment",
description: "",
steps: [],
version: 1,
lastSaved: new Date(),
};
return initialDesign ?? defaultDesign;
});
const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
const [selectedActionId, setSelectedActionId] = useState<string | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
/* ------------------------- Validation / Drift Tracking -------------------- */
const [isValidating, setIsValidating] = useState(false);
const [lastValidatedHash, setLastValidatedHash] = useState<string | null>(
null,
);
const [lastValidatedDesignJson, setLastValidatedDesignJson] = useState<
string | null
>(null);
// Recompute drift conditions
const currentDesignJson = useMemo(
() => serializeDesignSteps(design.steps),
[design.steps],
);
const hasIntegrityHash = !!experiment?.integrityHash;
const hashMismatch =
hasIntegrityHash &&
lastValidatedHash &&
experiment?.integrityHash !== lastValidatedHash;
const designChangedSinceValidation =
!!lastValidatedDesignJson && lastValidatedDesignJson !== currentDesignJson;
const drift =
hasIntegrityHash && (hashMismatch ? true : designChangedSinceValidation);
/* ---------------------------- Active Drag State --------------------------- */
// Removed unused activeId state (drag overlay removed in modular refactor)
/* ------------------------------- tRPC Mutations --------------------------- */
const updateExperiment = api.experiments.update.useMutation({
onSuccess: () => {
toast.success("Experiment saved");
setHasUnsavedChanges(false);
},
onError: (err) => {
toast.error(`Failed to save: ${err.message}`);
},
});
const trpcUtils = api.useUtils();
/* ------------------------------- Plugins Load ----------------------------- */
const { data: studyPlugins } = api.robots.plugins.getStudyPlugins.useQuery(
{ studyId: experiment?.studyId ?? "" },
{ enabled: !!experiment?.studyId },
);
/* ---------------------------- Registry Loading ---------------------------- */
useEffect(() => {
actionRegistry.loadCoreActions().catch((err) => {
console.error("Core actions load failed:", err);
toast.error("Failed to load core action library");
});
}, []);
useEffect(() => {
if (experiment?.studyId && (studyPlugins?.length ?? 0) > 0) {
actionRegistry.loadPluginActions(
experiment.studyId,
(studyPlugins ?? []).map((sp) => ({
plugin: {
id: sp.plugin.id,
robotId: sp.plugin.robotId,
version: sp.plugin.version,
actionDefinitions: Array.isArray(sp.plugin.actionDefinitions)
? sp.plugin.actionDefinitions
: undefined,
},
})) ?? [],
);
}
}, [experiment?.studyId, studyPlugins]);
/* ------------------------------ Breadcrumbs ------------------------------- */
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{
label: experiment?.study?.name ?? "Study",
href: `/studies/${experiment?.studyId}`,
},
{ label: "Experiments", href: `/studies/${experiment?.studyId}` },
{ label: design.name, href: `/experiments/${experimentId}` },
{ label: "Designer" },
]);
/* ------------------------------ DnD Sensors ------------------------------- */
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
);
const handleDragStart = useCallback((_event: DragStartEvent) => {
// activeId tracking removed (drag overlay no longer used)
}, []);
/* ------------------------------ Helpers ----------------------------------- */
const addActionToStep = useCallback(
(stepId: string, def: ActionDefinition) => {
const newAction: ExperimentAction = {
id: `action_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
type: def.type,
name: def.name,
parameters: {},
category: def.category,
source: def.source,
execution: def.execution ?? { transport: "internal" },
parameterSchemaRaw: def.parameterSchemaRaw,
};
// Default param values
def.parameters.forEach((p) => {
if (p.value !== undefined) {
newAction.parameters[p.id] = p.value;
}
});
setDesign((prev) => ({
...prev,
steps: prev.steps.map((s) =>
s.id === stepId ? { ...s, actions: [...s.actions, newAction] } : s,
),
}));
setHasUnsavedChanges(true);
toast.success(`Added ${def.name}`);
},
[],
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
// activeId reset removed (no longer tracked)
if (!over) return;
const activeIdStr = active.id.toString();
const overIdStr = over.id.toString();
// From library to step droppable
if (activeIdStr.startsWith("action-") && overIdStr.startsWith("step-")) {
const actionId = activeIdStr.replace("action-", "");
const stepId = overIdStr.replace("step-", "");
const def = actionRegistry.getAction(actionId);
if (def) {
addActionToStep(stepId, def);
}
return;
}
// Step reorder (both plain ids of steps)
if (
!activeIdStr.startsWith("action-") &&
!overIdStr.startsWith("step-") &&
!overIdStr.startsWith("action-")
) {
const oldIndex = design.steps.findIndex((s) => s.id === activeIdStr);
const newIndex = design.steps.findIndex((s) => s.id === overIdStr);
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
setDesign((prev) => ({
...prev,
steps: arrayMove(prev.steps, oldIndex, newIndex).map(
(s, index) => ({ ...s, order: index }),
),
}));
setHasUnsavedChanges(true);
}
return;
}
// Action reorder (within same step)
if (
!activeIdStr.startsWith("action-") &&
!overIdStr.startsWith("step-") &&
activeIdStr !== overIdStr
) {
// Identify which step these actions belong to
const containingStep = design.steps.find((s) =>
s.actions.some((a) => a.id === activeIdStr),
);
const targetStep = design.steps.find((s) =>
s.actions.some((a) => a.id === overIdStr),
);
if (
containingStep &&
targetStep &&
containingStep.id === targetStep.id
) {
const oldActionIndex = containingStep.actions.findIndex(
(a) => a.id === activeIdStr,
);
const newActionIndex = containingStep.actions.findIndex(
(a) => a.id === overIdStr,
);
if (
oldActionIndex !== -1 &&
newActionIndex !== -1 &&
oldActionIndex !== newActionIndex
) {
setDesign((prev) => ({
...prev,
steps: prev.steps.map((s) =>
s.id === containingStep.id
? {
...s,
actions: arrayMove(
s.actions,
oldActionIndex,
newActionIndex,
),
}
: s,
),
}));
setHasUnsavedChanges(true);
}
}
}
},
[design.steps, addActionToStep],
);
const addStep = useCallback(() => {
const newStep: ExperimentStep = {
id: `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
name: `Step ${design.steps.length + 1}`,
description: "",
type: "sequential",
order: design.steps.length,
trigger: {
type: design.steps.length === 0 ? "trial_start" : "previous_step",
conditions: {},
},
actions: [],
expanded: true,
};
setDesign((prev) => ({
...prev,
steps: [...prev.steps, newStep],
}));
setHasUnsavedChanges(true);
}, [design.steps.length]);
const updateStep = useCallback(
(stepId: string, updates: Partial<ExperimentStep>) => {
setDesign((prev) => ({
...prev,
steps: prev.steps.map((s) =>
s.id === stepId ? { ...s, ...updates } : s,
),
}));
setHasUnsavedChanges(true);
},
[],
);
const deleteStep = useCallback(
(stepId: string) => {
setDesign((prev) => ({
...prev,
steps: prev.steps.filter((s) => s.id !== stepId),
}));
if (selectedStepId === stepId) setSelectedStepId(null);
setHasUnsavedChanges(true);
},
[selectedStepId],
);
const updateAction = useCallback(
(stepId: string, actionId: string, updates: Partial<ExperimentAction>) => {
setDesign((prev) => ({
...prev,
steps: prev.steps.map((s) =>
s.id === stepId
? {
...s,
actions: s.actions.map((a) =>
a.id === actionId ? { ...a, ...updates } : a,
),
}
: s,
),
}));
setHasUnsavedChanges(true);
},
[],
);
const deleteAction = useCallback(
(stepId: string, actionId: string) => {
setDesign((prev) => ({
...prev,
steps: prev.steps.map((s) =>
s.id === stepId
? {
...s,
actions: s.actions.filter((a) => a.id !== actionId),
}
: s,
),
}));
if (selectedActionId === actionId) setSelectedActionId(null);
setHasUnsavedChanges(true);
},
[selectedActionId],
);
/* ------------------------------- Validation ------------------------------- */
const runValidation = useCallback(async () => {
setIsValidating(true);
try {
const result = await trpcUtils.experiments.validateDesign.fetch({
experimentId,
visualDesign: { steps: design.steps },
});
if (!result.valid) {
toast.error(
`Validation failed: ${result.issues.slice(0, 3).join(", ")}${
result.issues.length > 3 ? "…" : ""
}`,
);
return;
}
if (result.integrityHash) {
setLastValidatedHash(result.integrityHash);
setLastValidatedDesignJson(currentDesignJson);
toast.success(
`Validated • Hash: ${result.integrityHash.slice(0, 10)}`,
);
} else {
toast.success("Validated (no hash produced)");
}
} catch (err) {
toast.error(
`Validation error: ${
err instanceof Error ? err.message : "Unknown error"
}`,
);
} finally {
setIsValidating(false);
}
}, [experimentId, design.steps, trpcUtils, currentDesignJson]);
/* --------------------------------- Saving --------------------------------- */
const saveDesign = useCallback(() => {
const visualDesign = {
steps: design.steps,
version: design.version,
lastSaved: new Date().toISOString(),
};
updateExperiment.mutate({
id: experimentId,
visualDesign,
createSteps: true,
compileExecution: true,
});
const updatedDesign = { ...design, lastSaved: new Date() };
setDesign(updatedDesign);
onSave?.(updatedDesign);
}, [design, experimentId, onSave, updateExperiment]);
/* --------------------------- Selection Resolution ------------------------- */
const selectedStep = design.steps.find((s) => s.id === selectedStepId);
const selectedAction = selectedStep?.actions.find(
(a) => a.id === selectedActionId,
);
/* ------------------------------- Header Badges ---------------------------- */
const validationBadge = drift ? (
<Badge
variant="destructive"
className="text-xs"
title="Design has drifted since last validation or differs from stored hash"
>
Drift
</Badge>
) : lastValidatedHash ? (
<Badge
variant="outline"
className="border-green-400 text-xs text-green-700 dark:text-green-400"
title="Design matches last validated structure"
>
Validated
</Badge>
) : (
<Badge variant="outline" className="text-xs" title="Not yet validated">
Unvalidated
</Badge>
);
/* ---------------------------------- Render -------------------------------- */
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="space-y-4">
<PageHeader
title={design.name}
description="Design your experiment using steps and categorized actions"
icon={Play}
actions={
<div className="flex flex-wrap items-center gap-2">
{validationBadge}
{experiment?.integrityHash && (
<Badge variant="outline" className="text-xs">
Hash: {experiment.integrityHash.slice(0, 10)}
</Badge>
)}
{experiment?.executionGraphSummary && (
<Badge variant="outline" className="text-xs">
Exec: {experiment.executionGraphSummary.steps ?? 0}s /
{experiment.executionGraphSummary.actions ?? 0}a
</Badge>
)}
{Array.isArray(experiment?.pluginDependencies) &&
experiment.pluginDependencies.length > 0 && (
<Badge variant="secondary" className="text-xs">
{experiment.pluginDependencies.length} plugins
</Badge>
)}
<Badge variant="secondary" className="text-xs">
{design.steps.length} steps
</Badge>
{hasUnsavedChanges && (
<Badge
variant="outline"
className="border-orange-300 text-orange-600"
>
Unsaved
</Badge>
)}
<ActionButton
onClick={saveDesign}
disabled={!hasUnsavedChanges || updateExperiment.isPending}
>
<Save className="mr-2 h-4 w-4" />
{updateExperiment.isPending ? "Saving…" : "Save"}
</ActionButton>
<ActionButton
variant="outline"
onClick={() => {
setHasUnsavedChanges(false); // immediate feedback
void runValidation();
}}
disabled={isValidating}
>
<Play className="mr-2 h-4 w-4" />
{isValidating ? "Validating…" : "Revalidate"}
</ActionButton>
<ActionButton variant="outline">
<Download className="mr-2 h-4 w-4" />
Export
</ActionButton>
</div>
}
/>
<div className="grid grid-cols-12 gap-4">
{/* Action Library */}
<div className="col-span-3">
<Card className="h-[calc(100vh-12rem)]">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Plus className="h-4 w-4" />
Action Library
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ActionLibrary />
</CardContent>
</Card>
</div>
{/* Flow */}
<div className="col-span-6">
<StepFlow
steps={design.steps}
selectedStepId={selectedStepId}
selectedActionId={selectedActionId}
onStepSelect={(id) => {
setSelectedStepId(id);
setSelectedActionId(null);
}}
onStepDelete={deleteStep}
onStepUpdate={updateStep}
onActionSelect={(actionId) => setSelectedActionId(actionId)}
onActionDelete={deleteAction}
emptyState={
<div className="py-8 text-center">
<Play className="text-muted-foreground/50 mx-auto h-8 w-8" />
<h3 className="mt-2 text-sm font-medium">No steps yet</h3>
<p className="text-muted-foreground mt-1 text-xs">
Add your first step to begin designing
</p>
<Button className="mt-2" size="sm" onClick={addStep}>
<Plus className="mr-1 h-3 w-3" />
Add First Step
</Button>
</div>
}
headerRight={
<Button size="sm" onClick={addStep} className="h-6 text-xs">
<Plus className="mr-1 h-3 w-3" />
Add Step
</Button>
}
/>
</div>
{/* Properties */}
<div className="col-span-3">
<Card className="h-[calc(100vh-12rem)]">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
Properties
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<ScrollArea className="h-full pr-1">
<PropertiesPanel
design={design}
selectedStep={selectedStep}
selectedAction={selectedAction}
onActionUpdate={updateAction}
onStepUpdate={updateStep}
/>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
</div>
</DndContext>
);
}

View File

@@ -420,7 +420,7 @@ export function DependencyInspector({
dependencies.some((d) => d.status !== "available") || drifts.length > 0;
return (
<Card className={cn("h-[calc(100vh-12rem)]", className)}>
<Card className={cn("h-full", className)}>
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">

View File

@@ -1,17 +1,11 @@
"use client";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Play } from "lucide-react";
import { PageHeader } from "~/components/ui/page-header";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { api } from "~/trpc/react";
@@ -176,7 +170,7 @@ export function DesignerRoot({
const recomputeHash = useDesignerStore((s) => s.recomputeHash);
const lastPersistedHash = useDesignerStore((s) => s.lastPersistedHash);
const currentDesignHash = useDesignerStore((s) => s.currentDesignHash);
const lastValidatedHash = useDesignerStore((s) => s.lastValidatedHash);
const setPersistedHash = useDesignerStore((s) => s.setPersistedHash);
const setValidatedHash = useDesignerStore((s) => s.setValidatedHash);
const upsertStep = useDesignerStore((s) => s.upsertStep);
@@ -236,6 +230,7 @@ export function DesignerRoot({
const [isSaving, setIsSaving] = useState(false);
const [isValidating, setIsValidating] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [lastSavedAt, setLastSavedAt] = useState<Date | undefined>(undefined);
const [inspectorTab, setInspectorTab] = useState<
"properties" | "issues" | "dependencies"
@@ -324,12 +319,6 @@ export function DesignerRoot({
const hasUnsavedChanges =
!!currentDesignHash && lastPersistedHash !== currentDesignHash;
const driftStatus = useMemo<"unvalidated" | "drift" | "validated">(() => {
if (!currentDesignHash || !lastValidatedHash) return "unvalidated";
if (currentDesignHash !== lastValidatedHash) return "drift";
return "validated";
}, [currentDesignHash, lastValidatedHash]);
/* ------------------------------- Step Ops -------------------------------- */
const createNewStep = useCallback(() => {
const newStep: ExperimentStep = {
@@ -364,7 +353,7 @@ export function DesignerRoot({
actionDefinitions: actionRegistry.getAllActions(),
});
// Debug: log validation results for troubleshooting
// eslint-disable-next-line no-console
console.debug("[DesignerRoot] validation", {
valid: result.valid,
errors: result.errorCount,
@@ -689,7 +678,7 @@ export function DesignerRoot({
}
return (
<div className="flex h-[calc(100vh-6rem)] flex-col gap-3">
<div className="space-y-4">
<PageHeader
title={designMeta.name}
description="Compose ordered steps with provenance-aware actions."
@@ -718,7 +707,7 @@ export function DesignerRoot({
}
/>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border">
<div className="flex h-[calc(100vh-12rem)] w-full max-w-full flex-col overflow-hidden rounded-md border">
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
@@ -727,23 +716,22 @@ export function DesignerRoot({
onDragCancel={() => toggleLibraryScrollLock(false)}
>
<PanelsContainer
showDividers
className="min-h-0 flex-1"
left={
<div ref={libraryRootRef} data-library-root>
<div ref={libraryRootRef} data-library-root className="h-full">
<ActionLibraryPanel />
</div>
}
center={<FlowWorkspace />}
right={
<InspectorPanel
activeTab={inspectorTab}
onTabChange={setInspectorTab}
/>
<div className="h-full">
<InspectorPanel
activeTab={inspectorTab}
onTabChange={setInspectorTab}
/>
</div>
}
initialLeftWidth={260}
initialRightWidth={260}
minRightWidth={240}
maxRightWidth={300}
className="flex-1"
/>
<DragOverlay>
{dragOverlayAction ? (
@@ -753,15 +741,17 @@ export function DesignerRoot({
) : null}
</DragOverlay>
</DndContext>
<BottomStatusBar
onSave={() => persist()}
onValidate={() => validateDesign()}
onExport={() => handleExport()}
lastSavedAt={lastSavedAt}
saving={isSaving}
validating={isValidating}
exporting={isExporting}
/>
<div className="flex-shrink-0 border-t">
<BottomStatusBar
onSave={() => persist()}
onValidate={() => validateDesign()}
onExport={() => handleExport()}
lastSavedAt={lastSavedAt}
saving={isSaving}
validating={isValidating}
exporting={isExporting}
/>
</div>
</div>
</div>
);

View File

@@ -1,734 +0,0 @@
"use client";
/**
* DesignerShell
*
* High-level orchestration component for the Experiment Designer redesign.
* Replaces prior monolithic `BlockDesigner` responsibilities and delegates:
* - Data loading (experiment + study plugins)
* - Store initialization (steps, persisted/validated hashes)
* - Hash & drift status display
* - Save / validate / export actions (callback props)
* - Layout composition (Action Library | Step Flow | Properties Panel)
*
* This file intentionally does NOT contain:
* - Raw drag & drop logic (belongs to StepFlow & related internal modules)
* - Parameter field rendering logic (PropertiesPanel / ParameterFieldFactory)
* - Action registry loading internals (ActionRegistry singleton)
*
* Future Extensions:
* - Conflict modal
* - Bulk drift reconciliation
* - Command palette (action insertion)
* - Auto-save throttle controls
*/
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Play, Save, Download, RefreshCw } from "lucide-react";
import { DndContext, closestCenter } from "@dnd-kit/core";
import type { DragEndEvent, DragOverEvent } from "@dnd-kit/core";
import { toast } from "sonner";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { PageHeader, ActionButton } from "~/components/ui/page-header";
import { api } from "~/trpc/react";
import type {
ExperimentDesign,
ExperimentStep,
ExperimentAction,
ActionDefinition,
} from "~/lib/experiment-designer/types";
import { useDesignerStore } from "./state/store";
import { computeDesignHash } from "./state/hashing";
import { actionRegistry } from "./ActionRegistry";
import { ActionLibrary } from "./ActionLibrary";
import { StepFlow } from "./StepFlow";
import { PropertiesPanel } from "./PropertiesPanel";
import { ValidationPanel } from "./ValidationPanel";
import { DependencyInspector } from "./DependencyInspector";
import { validateExperimentDesign } from "./state/validators";
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
export interface DesignerShellProps {
experimentId: string;
initialDesign?: ExperimentDesign;
/**
* Called after a successful persisted save (server acknowledged).
*/
onPersist?: (design: ExperimentDesign) => void;
/**
* Whether to auto-run compilation on save.
*/
autoCompile?: boolean;
}
/* -------------------------------------------------------------------------- */
/* Utility */
/* -------------------------------------------------------------------------- */
function buildEmptyDesign(
experimentId: string,
name?: string,
description?: string | null,
): ExperimentDesign {
return {
id: experimentId,
name: name?.trim().length ? name : "Untitled Experiment",
description: description ?? "",
version: 1,
steps: [],
lastSaved: new Date(),
};
}
function adaptExistingDesign(experiment: {
id: string;
name: string;
description: string | null;
visualDesign: unknown;
}): ExperimentDesign | undefined {
if (
!experiment?.visualDesign ||
typeof experiment.visualDesign !== "object" ||
!("steps" in (experiment.visualDesign as Record<string, unknown>))
) {
return undefined;
}
const vd = experiment.visualDesign as {
steps?: ExperimentStep[];
version?: number;
lastSaved?: string;
};
if (!vd.steps || !Array.isArray(vd.steps)) return undefined;
return {
id: experiment.id,
name: experiment.name,
description: experiment.description ?? "",
steps: vd.steps,
version: vd.version ?? 1,
lastSaved:
vd.lastSaved && typeof vd.lastSaved === "string"
? new Date(vd.lastSaved)
: new Date(),
};
}
/* -------------------------------------------------------------------------- */
/* DesignerShell */
/* -------------------------------------------------------------------------- */
export function DesignerShell({
experimentId,
initialDesign,
onPersist,
autoCompile = true,
}: DesignerShellProps) {
/* ---------------------------- Remote Experiment --------------------------- */
const {
data: experiment,
isLoading: loadingExperiment,
refetch: refetchExperiment,
} = api.experiments.get.useQuery({ id: experimentId });
/* ------------------------------ Store Access ------------------------------ */
const steps = useDesignerStore((s) => s.steps);
const setSteps = useDesignerStore((s) => s.setSteps);
const recomputeHash = useDesignerStore((s) => s.recomputeHash);
const currentDesignHash = useDesignerStore((s) => s.currentDesignHash);
const lastPersistedHash = useDesignerStore((s) => s.lastPersistedHash);
const lastValidatedHash = useDesignerStore((s) => s.lastValidatedHash);
const setPersistedHash = useDesignerStore((s) => s.setPersistedHash);
const setValidatedHash = useDesignerStore((s) => s.setValidatedHash);
const selectedActionId = useDesignerStore((s) => s.selectedActionId);
const selectedStepId = useDesignerStore((s) => s.selectedStepId);
const selectStep = useDesignerStore((s) => s.selectStep);
const selectAction = useDesignerStore((s) => s.selectAction);
const validationIssues = useDesignerStore((s) => s.validationIssues);
const actionSignatureDrift = useDesignerStore((s) => s.actionSignatureDrift);
const upsertStep = useDesignerStore((s) => s.upsertStep);
const removeStep = useDesignerStore((s) => s.removeStep);
const upsertAction = useDesignerStore((s) => s.upsertAction);
const removeAction = useDesignerStore((s) => s.removeAction);
/* ------------------------------ Step Creation ------------------------------ */
const createNewStep = useCallback(() => {
const newStep: ExperimentStep = {
id: `step-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name: `Step ${steps.length + 1}`,
description: "",
type: "sequential",
order: steps.length,
trigger: {
type: "trial_start",
conditions: {},
},
actions: [],
expanded: true,
};
upsertStep(newStep);
selectStep(newStep.id);
toast.success(`Created ${newStep.name}`);
}, [steps.length, upsertStep, selectStep]);
/* ------------------------------ DnD Handlers ------------------------------ */
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
// Handle action drag to step
if (
active.id.toString().startsWith("action-") &&
over.id.toString().startsWith("step-")
) {
const actionData = active.data.current?.action as ActionDefinition;
const stepId = over.id.toString().replace("step-", "");
if (!actionData) return;
const step = steps.find((s) => s.id === stepId);
if (!step) return;
// Create new action instance
const newAction: ExperimentAction = {
id: `action-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
type: actionData.type,
name: actionData.name,
category: actionData.category,
parameters: {},
source: actionData.source,
execution: actionData.execution ?? {
transport: "internal",
retryable: false,
},
};
upsertAction(stepId, newAction);
selectStep(stepId);
selectAction(stepId, newAction.id);
toast.success(`Added ${actionData.name} to ${step.name}`);
}
},
[steps, upsertAction, selectStep, selectAction],
);
const handleDragOver = useCallback((_event: DragOverEvent) => {
// This could be used for visual feedback during drag
}, []);
/* ------------------------------- Local State ------------------------------ */
const [designMeta, setDesignMeta] = useState<{
name: string;
description: string;
version: number;
}>(() => {
const init =
initialDesign ??
(experiment ? adaptExistingDesign(experiment) : undefined) ??
buildEmptyDesign(experimentId, experiment?.name, experiment?.description);
return {
name: init.name,
description: init.description,
version: init.version,
};
});
const [isValidating, setIsValidating] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [initialized, setInitialized] = useState(false);
/* ----------------------------- Experiment Update -------------------------- */
const updateExperiment = api.experiments.update.useMutation({
onSuccess: async () => {
toast.success("Experiment saved");
await refetchExperiment();
},
onError: (err) => {
toast.error(`Save failed: ${err.message}`);
},
});
/* ------------------------------ Plugin Loading ---------------------------- */
const { data: studyPlugins } = api.robots.plugins.getStudyPlugins.useQuery(
{ studyId: experiment?.studyId ?? "" },
{ enabled: !!experiment?.studyId },
);
// Load core actions once
useEffect(() => {
actionRegistry
.loadCoreActions()
.catch((err) => console.error("Core action load failed:", err));
}, []);
// Load study plugin actions when available
useEffect(() => {
if (!experiment?.studyId) return;
if (!studyPlugins || studyPlugins.length === 0) return;
actionRegistry.loadPluginActions(
experiment.studyId,
studyPlugins.map((sp) => ({
plugin: {
id: sp.plugin.id,
robotId: sp.plugin.robotId,
version: sp.plugin.version,
actionDefinitions: Array.isArray(sp.plugin.actionDefinitions)
? sp.plugin.actionDefinitions
: undefined,
},
})),
);
}, [experiment?.studyId, studyPlugins]);
/* ------------------------- Initialize Store Steps ------------------------- */
useEffect(() => {
if (initialized) return;
if (loadingExperiment) return;
const resolvedInitial =
initialDesign ??
(experiment ? adaptExistingDesign(experiment) : undefined) ??
buildEmptyDesign(experimentId, experiment?.name, experiment?.description);
setDesignMeta({
name: resolvedInitial.name,
description: resolvedInitial.description,
version: resolvedInitial.version,
});
setSteps(resolvedInitial.steps);
// Set persisted hash if experiment already has integrityHash
if (experiment?.integrityHash) {
setPersistedHash(experiment.integrityHash);
setValidatedHash(experiment.integrityHash);
}
setInitialized(true);
// Kick off first hash compute
void recomputeHash();
}, [
initialized,
loadingExperiment,
experiment,
initialDesign,
experimentId,
setSteps,
setPersistedHash,
setValidatedHash,
recomputeHash,
]);
/* ----------------------------- Drift Computation -------------------------- */
const driftState = useMemo(() => {
if (!lastValidatedHash || !currentDesignHash) {
return {
status: "unvalidated" as const,
drift: false,
};
}
if (currentDesignHash !== lastValidatedHash) {
return { status: "drift" as const, drift: true };
}
return { status: "validated" as const, drift: false };
}, [lastValidatedHash, currentDesignHash]);
/* ------------------------------ Derived Flags ----------------------------- */
const hasUnsavedChanges =
!!currentDesignHash && lastPersistedHash !== currentDesignHash;
const totalActions = steps.reduce((sum, s) => sum + s.actions.length, 0);
/* ------------------------------- Validation ------------------------------- */
const validateDesign = useCallback(async () => {
if (!experimentId) return;
setIsValidating(true);
try {
// Run local validation
const validationResult = validateExperimentDesign(steps, {
steps,
actionDefinitions: actionRegistry.getAllActions(),
});
// Compute hash for integrity
const hash = await computeDesignHash(steps);
setValidatedHash(hash);
if (validationResult.valid) {
toast.success(`Validated • ${hash.slice(0, 10)}… • No issues found`);
} else {
toast.warning(
`Validated with ${validationResult.errorCount} errors, ${validationResult.warningCount} warnings`,
);
}
} catch (err) {
toast.error(
`Validation error: ${
err instanceof Error ? err.message : "Unknown error"
}`,
);
} finally {
setIsValidating(false);
}
}, [experimentId, steps, setValidatedHash]);
/* ---------------------------------- Save ---------------------------------- */
const persist = useCallback(async () => {
if (!experimentId) return;
setIsSaving(true);
try {
const visualDesign = {
steps,
version: designMeta.version,
lastSaved: new Date().toISOString(),
};
updateExperiment.mutate({
id: experimentId,
visualDesign,
createSteps: true,
compileExecution: autoCompile,
});
// Optimistic hash recompute to reflect state
await recomputeHash();
onPersist?.({
id: experimentId,
name: designMeta.name,
description: designMeta.description,
version: designMeta.version,
steps,
lastSaved: new Date(),
});
} finally {
setIsSaving(false);
}
}, [
experimentId,
steps,
designMeta,
recomputeHash,
updateExperiment,
onPersist,
autoCompile,
]);
/* -------------------------------- Export ---------------------------------- */
const handleExport = useCallback(async () => {
setIsExporting(true);
try {
const designHash = currentDesignHash ?? (await computeDesignHash(steps));
const bundle = {
format: "hristudio.design.v1",
exportedAt: new Date().toISOString(),
experiment: {
id: experimentId,
name: designMeta.name,
version: designMeta.version,
integrityHash: designHash,
steps,
pluginDependencies:
experiment?.pluginDependencies?.slice().sort() ?? [],
},
compiled: null, // Will be implemented when execution graph is available
};
const blob = new Blob([JSON.stringify(bundle, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${designMeta.name
.replace(/[^a-z0-9-_]+/gi, "_")
.toLowerCase()}_design.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast.success("Exported design bundle");
} catch (err) {
toast.error(
`Export failed: ${
err instanceof Error ? err.message : "Unknown error"
}`,
);
} finally {
setIsExporting(false);
}
}, [
currentDesignHash,
steps,
experimentId,
designMeta,
experiment?.pluginDependencies,
]);
/* ---------------------------- Incremental Hashing ------------------------- */
// Optionally re-hash after step mutations (basic heuristic)
useEffect(() => {
if (!initialized) return;
void recomputeHash();
}, [steps.length, initialized, recomputeHash]);
/* ------------------------------- Header Badges ---------------------------- */
const hashBadge =
driftState.status === "drift" ? (
<Badge variant="destructive" title="Design drift detected">
Drift
</Badge>
) : driftState.status === "validated" ? (
<Badge
variant="outline"
className="border-green-400 text-green-700 dark:text-green-400"
title="Design validated"
>
Validated
</Badge>
) : (
<Badge variant="outline" title="Not validated">
Unvalidated
</Badge>
);
/* ------------------------------- Render ----------------------------------- */
if (loadingExperiment && !initialized) {
return (
<div className="py-24 text-center">
<p className="text-muted-foreground text-sm">
Loading experiment design
</p>
</div>
);
}
return (
<div className="space-y-4">
<PageHeader
title={designMeta.name}
description="Design your experiment by composing ordered steps with provenance-aware actions."
icon={Play}
actions={
<div className="flex flex-wrap items-center gap-2">
{hashBadge}
{experiment?.integrityHash && (
<Badge variant="outline" className="text-xs">
Hash: {experiment.integrityHash.slice(0, 10)}
</Badge>
)}
<Badge variant="secondary" className="text-xs">
{steps.length} steps
</Badge>
<Badge variant="secondary" className="text-xs">
{totalActions} actions
</Badge>
{hasUnsavedChanges && (
<Badge
variant="outline"
className="border-orange-300 text-orange-600"
>
Unsaved
</Badge>
)}
<ActionButton
onClick={persist}
disabled={!hasUnsavedChanges || isSaving}
>
<Save className="mr-2 h-4 w-4" />
{isSaving ? "Saving…" : "Save"}
</ActionButton>
<ActionButton
variant="outline"
onClick={validateDesign}
disabled={isValidating}
>
<RefreshCw className="mr-2 h-4 w-4" />
{isValidating ? "Validating…" : "Validate"}
</ActionButton>
<ActionButton
variant="outline"
onClick={handleExport}
disabled={isExporting}
>
<Download className="mr-2 h-4 w-4" />
{isExporting ? "Exporting…" : "Export"}
</ActionButton>
</div>
}
/>
<DndContext
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
>
<div className="grid grid-cols-12 gap-4">
{/* Action Library */}
<div className="col-span-3">
<Card className="h-[calc(100vh-12rem)]">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
Action Library
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ActionLibrary />
</CardContent>
</Card>
</div>
{/* Step Flow */}
<div className="col-span-6">
<StepFlow
steps={steps}
selectedStepId={selectedStepId ?? null}
selectedActionId={selectedActionId ?? null}
onStepSelect={(id: string) => selectStep(id)}
onActionSelect={(id: string) =>
selectedStepId && id
? selectAction(selectedStepId, id)
: undefined
}
onStepDelete={(stepId: string) => {
removeStep(stepId);
toast.success("Step deleted");
}}
onStepUpdate={(
stepId: string,
updates: Partial<ExperimentStep>,
) => {
const step = steps.find((s) => s.id === stepId);
if (!step) return;
upsertStep({ ...step, ...updates });
}}
onActionDelete={(stepId: string, actionId: string) => {
removeAction(stepId, actionId);
toast.success("Action deleted");
}}
emptyState={
<div className="text-muted-foreground py-10 text-center text-sm">
Add your first step to begin designing.
</div>
}
headerRight={
<Button
size="sm"
className="h-6 text-xs"
onClick={createNewStep}
>
+ Step
</Button>
}
/>
</div>
{/* Properties Panel */}
<div className="col-span-3">
<Tabs defaultValue="properties" className="h-[calc(100vh-12rem)]">
<Card className="h-full">
<CardHeader className="pb-2">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="properties" className="text-xs">
Properties
</TabsTrigger>
<TabsTrigger value="validation" className="text-xs">
Issues
</TabsTrigger>
<TabsTrigger value="dependencies" className="text-xs">
Dependencies
</TabsTrigger>
</TabsList>
</CardHeader>
<CardContent className="p-0">
<TabsContent value="properties" className="m-0 h-full">
<ScrollArea className="h-full p-3">
<PropertiesPanel
design={{
id: experimentId,
name: designMeta.name,
description: designMeta.description,
version: designMeta.version,
steps,
lastSaved: new Date(),
}}
selectedStep={steps.find(
(s) => s.id === selectedStepId,
)}
selectedAction={
steps
.find(
(s: ExperimentStep) => s.id === selectedStepId,
)
?.actions.find(
(a: ExperimentAction) =>
a.id === selectedActionId,
) ?? undefined
}
onActionUpdate={(stepId, actionId, updates) => {
const step = steps.find((s) => s.id === stepId);
if (!step) return;
const action = step.actions.find(
(a) => a.id === actionId,
);
if (!action) return;
upsertAction(stepId, { ...action, ...updates });
}}
onStepUpdate={(stepId, updates) => {
const step = steps.find((s) => s.id === stepId);
if (!step) return;
upsertStep({ ...step, ...updates });
}}
/>
</ScrollArea>
</TabsContent>
<TabsContent value="validation" className="m-0 h-full">
<ValidationPanel
issues={validationIssues}
onIssueClick={(issue) => {
if (issue.stepId) {
selectStep(issue.stepId);
if (issue.actionId) {
selectAction(issue.stepId, issue.actionId);
}
}
}}
/>
</TabsContent>
<TabsContent value="dependencies" className="m-0 h-full">
<DependencyInspector
steps={steps}
actionSignatureDrift={actionSignatureDrift}
actionDefinitions={actionRegistry.getAllActions()}
onReconcileAction={(actionId) => {
// TODO: Implement drift reconciliation
toast.info(
`Reconciliation for action ${actionId} - TODO`,
);
}}
onRefreshDependencies={() => {
// TODO: Implement dependency refresh
toast.info("Dependency refresh - TODO");
}}
onInstallPlugin={(pluginId) => {
// TODO: Implement plugin installation
toast.info(`Install plugin ${pluginId} - TODO`);
}}
/>
</TabsContent>
</CardContent>
</Card>
</Tabs>
</div>
</div>
</DndContext>
</div>
);
}
export default DesignerShell;

View File

@@ -1,470 +0,0 @@
"use client";
import React, { useState } from "react";
import {
Save,
Download,
Upload,
AlertCircle,
Clock,
GitBranch,
RefreshCw,
CheckCircle,
AlertTriangle,
} from "lucide-react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label";
import { Separator } from "~/components/ui/separator";
import { cn } from "~/lib/utils";
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
export type VersionStrategy = "manual" | "auto_minor" | "auto_patch";
export type SaveState = "clean" | "dirty" | "saving" | "conflict" | "error";
export interface SaveBarProps {
/**
* Current save state
*/
saveState: SaveState;
/**
* Whether auto-save is enabled
*/
autoSaveEnabled: boolean;
/**
* Current version strategy
*/
versionStrategy: VersionStrategy;
/**
* Number of unsaved changes
*/
dirtyCount: number;
/**
* Current design hash for integrity
*/
currentHash?: string;
/**
* Last persisted hash
*/
persistedHash?: string;
/**
* Last save timestamp
*/
lastSaved?: Date;
/**
* Whether there's a conflict with server state
*/
hasConflict?: boolean;
/**
* Current experiment version
*/
currentVersion: number;
/**
* Called when user manually saves
*/
onSave: () => void;
/**
* Called when user exports the design
*/
onExport: () => void;
/**
* Called when user imports a design
*/
onImport?: (file: File) => void;
/**
* Called when auto-save setting changes
*/
onAutoSaveChange: (enabled: boolean) => void;
/**
* Called when version strategy changes
*/
onVersionStrategyChange: (strategy: VersionStrategy) => void;
/**
* Called when user resolves a conflict
*/
onResolveConflict?: () => void;
/**
* Called when user wants to validate the design
*/
onValidate?: () => void;
className?: string;
}
/* -------------------------------------------------------------------------- */
/* Save State Configuration */
/* -------------------------------------------------------------------------- */
const saveStateConfig = {
clean: {
icon: CheckCircle,
color: "text-green-600 dark:text-green-400",
label: "Saved",
description: "All changes saved",
},
dirty: {
icon: AlertCircle,
color: "text-amber-600 dark:text-amber-400",
label: "Unsaved",
description: "You have unsaved changes",
},
saving: {
icon: RefreshCw,
color: "text-blue-600 dark:text-blue-400",
label: "Saving",
description: "Saving changes...",
},
conflict: {
icon: AlertTriangle,
color: "text-red-600 dark:text-red-400",
label: "Conflict",
description: "Server conflict detected",
},
error: {
icon: AlertTriangle,
color: "text-red-600 dark:text-red-400",
label: "Error",
description: "Save failed",
},
} as const;
/* -------------------------------------------------------------------------- */
/* Version Strategy Options */
/* -------------------------------------------------------------------------- */
const versionStrategyOptions = [
{
value: "manual" as const,
label: "Manual",
description: "Only increment version when explicitly requested",
},
{
value: "auto_minor" as const,
label: "Auto Minor",
description: "Auto-increment minor version on structural changes",
},
{
value: "auto_patch" as const,
label: "Auto Patch",
description: "Auto-increment patch version on any save",
},
];
/* -------------------------------------------------------------------------- */
/* Utility Functions */
/* -------------------------------------------------------------------------- */
function formatLastSaved(date?: Date): string {
if (!date) return "Never";
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
function getNextVersion(
current: number,
strategy: VersionStrategy,
hasStructuralChanges = false,
): number {
switch (strategy) {
case "manual":
return current;
case "auto_minor":
return hasStructuralChanges ? current + 1 : current;
case "auto_patch":
return current + 1;
default:
return current;
}
}
/* -------------------------------------------------------------------------- */
/* Import Handler */
/* -------------------------------------------------------------------------- */
function ImportButton({ onImport }: { onImport?: (file: File) => void }) {
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file && onImport) {
onImport(file);
}
// Reset input to allow re-selecting the same file
event.target.value = "";
};
if (!onImport) return null;
return (
<div>
<input
type="file"
accept=".json"
onChange={handleFileSelect}
className="hidden"
id="import-design"
/>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => document.getElementById("import-design")?.click()}
>
<Upload className="mr-2 h-3 w-3" />
Import
</Button>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* SaveBar Component */
/* -------------------------------------------------------------------------- */
export function SaveBar({
saveState,
autoSaveEnabled,
versionStrategy,
dirtyCount,
currentHash,
persistedHash,
lastSaved,
hasConflict,
currentVersion,
onSave,
onExport,
onImport,
onAutoSaveChange,
onVersionStrategyChange,
onResolveConflict,
onValidate,
className,
}: SaveBarProps) {
const [showSettings, setShowSettings] = useState(false);
const config = saveStateConfig[saveState];
const IconComponent = config.icon;
const hasUnsavedChanges = saveState === "dirty" || dirtyCount > 0;
const canSave = hasUnsavedChanges && saveState !== "saving";
const hashesMatch =
currentHash && persistedHash && currentHash === persistedHash;
return (
<Card className={cn("rounded-t-none border-t-0", className)}>
<div className="flex items-center justify-between p-3">
{/* Left: Save Status & Info */}
<div className="flex items-center gap-3">
{/* Save State Indicator */}
<div className="flex items-center gap-2">
<IconComponent
className={cn(
"h-4 w-4",
config.color,
saveState === "saving" && "animate-spin",
)}
/>
<div className="text-sm">
<span className="font-medium">{config.label}</span>
{dirtyCount > 0 && (
<span className="text-muted-foreground ml-1">
({dirtyCount} changes)
</span>
)}
</div>
</div>
<Separator orientation="vertical" className="h-4" />
{/* Version Info */}
<div className="flex items-center gap-2 text-sm">
<GitBranch className="text-muted-foreground h-3 w-3" />
<span className="text-muted-foreground">Version</span>
<Badge variant="outline" className="h-5 text-xs">
v{currentVersion}
</Badge>
</div>
{/* Last Saved */}
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Clock className="h-3 w-3" />
<span>{formatLastSaved(lastSaved)}</span>
</div>
{/* Hash Status */}
{currentHash && (
<div className="flex items-center gap-1">
<Badge
variant={hashesMatch ? "outline" : "secondary"}
className="h-5 font-mono text-[10px]"
>
{currentHash.slice(0, 8)}
</Badge>
</div>
)}
</div>
{/* Right: Actions */}
<div className="flex items-center gap-2">
{/* Conflict Resolution */}
{hasConflict && onResolveConflict && (
<Button
variant="destructive"
size="sm"
className="h-8"
onClick={onResolveConflict}
>
<AlertTriangle className="mr-2 h-3 w-3" />
Resolve Conflict
</Button>
)}
{/* Validate */}
{onValidate && (
<Button
variant="outline"
size="sm"
className="h-8"
onClick={onValidate}
>
<CheckCircle className="mr-2 h-3 w-3" />
Validate
</Button>
)}
{/* Import */}
<ImportButton onImport={onImport} />
{/* Export */}
<Button
variant="outline"
size="sm"
className="h-8"
onClick={onExport}
>
<Download className="mr-2 h-3 w-3" />
Export
</Button>
{/* Save */}
<Button
variant={canSave ? "default" : "outline"}
size="sm"
className="h-8"
onClick={onSave}
disabled={!canSave}
>
<Save className="mr-2 h-3 w-3" />
{saveState === "saving" ? "Saving..." : "Save"}
</Button>
{/* Settings Toggle */}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => setShowSettings(!showSettings)}
>
<RefreshCw className="h-3 w-3" />
</Button>
</div>
</div>
{/* Settings Panel */}
{showSettings && (
<>
<Separator />
<div className="bg-muted/30 space-y-3 p-3">
<div className="grid grid-cols-2 gap-4">
{/* Auto-Save Toggle */}
<div className="space-y-2">
<Label className="text-xs font-medium">Auto-Save</Label>
<div className="flex items-center space-x-2">
<Switch
id="auto-save"
checked={autoSaveEnabled}
onCheckedChange={onAutoSaveChange}
/>
<Label
htmlFor="auto-save"
className="text-muted-foreground text-xs"
>
Save automatically when idle
</Label>
</div>
</div>
{/* Version Strategy */}
<div className="space-y-2">
<Label className="text-xs font-medium">Version Strategy</Label>
<Select
value={versionStrategy}
onValueChange={onVersionStrategyChange}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{versionStrategyOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div>
<div className="font-medium">{option.label}</div>
<div className="text-muted-foreground text-xs">
{option.description}
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Preview Next Version */}
{versionStrategy !== "manual" && (
<div className="text-muted-foreground text-xs">
Next save will create version{" "}
<Badge variant="outline" className="h-4 text-[10px]">
v
{getNextVersion(
currentVersion,
versionStrategy,
hasUnsavedChanges,
)}
</Badge>
</div>
)}
{/* Status Details */}
<div className="text-muted-foreground text-xs">
{config.description}
{hasUnsavedChanges && autoSaveEnabled && (
<span> Auto-save enabled</span>
)}
</div>
</div>
</>
)}
</Card>
);
}

View File

@@ -1,443 +0,0 @@
"use client";
import React from "react";
import { useDroppable } from "@dnd-kit/core";
import {
useSortable,
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { ScrollArea } from "~/components/ui/scroll-area";
import {
GripVertical,
ChevronDown,
ChevronRight,
Plus,
Trash2,
Zap,
MessageSquare,
Hand,
Navigation,
Volume2,
Clock,
Eye,
Bot,
User,
Timer,
MousePointer,
Mic,
Activity,
Play,
GitBranch,
} from "lucide-react";
import { cn } from "~/lib/utils";
import type {
ExperimentStep,
ExperimentAction,
} from "~/lib/experiment-designer/types";
import { actionRegistry } from "./ActionRegistry";
/* -------------------------------------------------------------------------- */
/* Icon Map (localized to avoid cross-file re-render dependencies) */
/* -------------------------------------------------------------------------- */
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
MessageSquare,
Hand,
Navigation,
Volume2,
Clock,
Eye,
Bot,
User,
Zap,
Timer,
MousePointer,
Mic,
Activity,
Play,
GitBranch,
};
/* -------------------------------------------------------------------------- */
/* DroppableStep */
/* -------------------------------------------------------------------------- */
interface DroppableStepProps {
stepId: string;
children: React.ReactNode;
isEmpty?: boolean;
}
function DroppableStep({ stepId, children, isEmpty }: DroppableStepProps) {
const { isOver, setNodeRef } = useDroppable({
id: `step-${stepId}`,
});
return (
<div
ref={setNodeRef}
className={cn(
"min-h-[60px] rounded border-2 border-dashed transition-colors",
isOver
? "border-blue-500 bg-blue-50 dark:bg-blue-950/20"
: "border-transparent",
isEmpty && "bg-muted/20",
)}
>
{isEmpty ? (
<div className="flex items-center justify-center p-4 text-center">
<div className="text-muted-foreground">
<Plus className="mx-auto mb-1 h-5 w-5" />
<p className="text-xs">Drop actions here</p>
</div>
</div>
) : (
children
)}
</div>
);
}
/* -------------------------------------------------------------------------- */
/* SortableAction */
/* -------------------------------------------------------------------------- */
interface SortableActionProps {
action: ExperimentAction;
index: number;
isSelected: boolean;
onSelect: () => void;
onDelete: () => void;
}
function SortableAction({
action,
index,
isSelected,
onSelect,
onDelete,
}: SortableActionProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: action.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const def = actionRegistry.getAction(action.type);
const IconComponent = iconMap[def?.icon ?? "Zap"] ?? Zap;
const categoryColors = {
wizard: "bg-blue-500",
robot: "bg-emerald-500",
control: "bg-amber-500",
observation: "bg-purple-500",
} as const;
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
className={cn(
"group flex cursor-pointer items-center justify-between rounded border p-2 text-xs transition-colors",
isSelected
? "border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-950/30"
: "hover:bg-accent/50",
isDragging && "opacity-50",
)}
onClick={onSelect}
>
<div className="flex items-center gap-2">
<div
{...listeners}
className="text-muted-foreground/80 hover:text-foreground cursor-grab rounded p-0.5"
>
<GripVertical className="h-3 w-3" />
</div>
<Badge variant="outline" className="h-4 text-[10px]">
{index + 1}
</Badge>
{def && (
<div
className={cn(
"flex h-4 w-4 flex-shrink-0 items-center justify-center rounded text-white",
categoryColors[def.category],
)}
>
<IconComponent className="h-2.5 w-2.5" />
</div>
)}
<span className="flex items-center gap-1 truncate font-medium">
{action.source.kind === "plugin" ? (
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-emerald-600 text-[8px] font-bold text-white">
P
</span>
) : (
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-slate-500 text-[8px] font-bold text-white">
C
</span>
)}
{action.name}
</span>
<Badge variant="secondary" className="h-4 text-[10px] capitalize">
{(action.type ?? "").replace(/_/g, " ")}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* SortableStep */
/* -------------------------------------------------------------------------- */
interface SortableStepProps {
step: ExperimentStep;
index: number;
isSelected: boolean;
selectedActionId: string | null;
onSelect: () => void;
onDelete: () => void;
onUpdate: (updates: Partial<ExperimentStep>) => void;
onActionSelect: (actionId: string) => void;
onActionDelete: (actionId: string) => void;
}
function SortableStep({
step,
index,
isSelected,
selectedActionId,
onSelect,
onDelete,
onUpdate,
onActionSelect,
onActionDelete,
}: SortableStepProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: step.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const stepTypeColors: Record<ExperimentStep["type"], string> = {
sequential: "border-l-blue-500",
parallel: "border-l-emerald-500",
conditional: "border-l-amber-500",
loop: "border-l-purple-500",
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
<Card
className={cn(
"border-l-4 transition-all",
stepTypeColors[step.type],
isSelected
? "bg-blue-50/50 ring-2 ring-blue-500 dark:bg-blue-950/20 dark:ring-blue-400"
: "",
isDragging && "rotate-2 opacity-50 shadow-lg",
)}
>
<CardHeader className="cursor-pointer pb-2" onClick={() => onSelect()}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
onUpdate({ expanded: !step.expanded });
}}
>
{step.expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<Badge variant="outline" className="h-5 text-xs">
{index + 1}
</Badge>
<div>
<div className="text-sm font-medium">{step.name}</div>
<div className="text-muted-foreground text-xs">
{step.actions.length} actions {step.type}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-3 w-3" />
</Button>
<div {...listeners} className="cursor-grab p-1">
<GripVertical className="text-muted-foreground h-4 w-4" />
</div>
</div>
</div>
</CardHeader>
{step.expanded && (
<CardContent className="pt-0">
<DroppableStep stepId={step.id} isEmpty={step.actions.length === 0}>
{step.actions.length > 0 && (
<SortableContext
items={step.actions.map((a) => a.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{step.actions.map((action, actionIndex) => (
<SortableAction
key={action.id}
action={action}
index={actionIndex}
isSelected={selectedActionId === action.id}
onSelect={() => onActionSelect(action.id)}
onDelete={() => onActionDelete(action.id)}
/>
))}
</div>
</SortableContext>
)}
</DroppableStep>
</CardContent>
)}
</Card>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* StepFlow (Scrollable Container of Steps) */
/* -------------------------------------------------------------------------- */
export interface StepFlowProps {
steps: ExperimentStep[];
selectedStepId: string | null;
selectedActionId: string | null;
onStepSelect: (id: string) => void;
onStepDelete: (id: string) => void;
onStepUpdate: (id: string, updates: Partial<ExperimentStep>) => void;
onActionSelect: (actionId: string) => void;
onActionDelete: (stepId: string, actionId: string) => void;
onActionUpdate?: (
stepId: string,
actionId: string,
updates: Partial<ExperimentAction>,
) => void;
emptyState?: React.ReactNode;
headerRight?: React.ReactNode;
}
export function StepFlow({
steps,
selectedStepId,
selectedActionId,
onStepSelect,
onStepDelete,
onStepUpdate,
onActionSelect,
onActionDelete,
emptyState,
headerRight,
}: StepFlowProps) {
return (
<Card className="h-[calc(100vh-12rem)]">
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4" />
Experiment Flow
</div>
{headerRight}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ScrollArea className="h-full">
<div className="p-2">
{steps.length === 0 ? (
(emptyState ?? (
<div className="py-8 text-center">
<GitBranch className="text-muted-foreground/50 mx-auto h-8 w-8" />
<h3 className="mt-2 text-sm font-medium">No steps yet</h3>
<p className="text-muted-foreground mt-1 text-xs">
Add your first step to begin designing
</p>
</div>
))
) : (
<SortableContext
items={steps.map((s) => s.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{steps.map((step, index) => (
<div key={step.id}>
<SortableStep
step={step}
index={index}
isSelected={selectedStepId === step.id}
selectedActionId={selectedActionId}
onSelect={() => onStepSelect(step.id)}
onDelete={() => onStepDelete(step.id)}
onUpdate={(updates) => onStepUpdate(step.id, updates)}
onActionSelect={onActionSelect}
onActionDelete={(actionId) =>
onActionDelete(step.id, actionId)
}
/>
{index < steps.length - 1 && (
<div className="flex justify-center py-1">
<div className="bg-border h-2 w-px" />
</div>
)}
</div>
))}
</div>
</SortableContext>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
);
}

View File

@@ -12,8 +12,7 @@ import {
} from "lucide-react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Separator } from "~/components/ui/separator";
import { Input } from "~/components/ui/input";
import { cn } from "~/lib/utils";
@@ -62,24 +61,24 @@ const severityConfig = {
error: {
icon: AlertCircle,
color: "text-red-600 dark:text-red-400",
bgColor: "bg-red-50 dark:bg-red-950/20",
borderColor: "border-red-200 dark:border-red-800",
bgColor: "bg-red-100 dark:bg-red-950/60",
borderColor: "border-red-300 dark:border-red-700",
badgeVariant: "destructive" as const,
label: "Error",
},
warning: {
icon: AlertTriangle,
color: "text-amber-600 dark:text-amber-400",
bgColor: "bg-amber-50 dark:bg-amber-950/20",
borderColor: "border-amber-200 dark:border-amber-800",
bgColor: "bg-amber-100 dark:bg-amber-950/60",
borderColor: "border-amber-300 dark:border-amber-700",
badgeVariant: "secondary" as const,
label: "Warning",
},
info: {
icon: Info,
color: "text-blue-600 dark:text-blue-400",
bgColor: "bg-blue-50 dark:bg-blue-950/20",
borderColor: "border-blue-200 dark:border-blue-800",
bgColor: "bg-blue-100 dark:bg-blue-950/60",
borderColor: "border-blue-300 dark:border-blue-700",
badgeVariant: "outline" as const,
label: "Info",
},
@@ -103,15 +102,7 @@ function flattenIssues(issuesMap: Record<string, ValidationIssue[]>) {
return flattened;
}
function getEntityDisplayName(entityId: string): string {
if (entityId.startsWith("step-")) {
return `Step ${entityId.replace("step-", "")}`;
}
if (entityId.startsWith("action-")) {
return `Action ${entityId.replace("action-", "")}`;
}
return entityId;
}
/* -------------------------------------------------------------------------- */
/* Issue Item Component */
@@ -214,7 +205,7 @@ export function ValidationPanel({
const [severityFilter, setSeverityFilter] = useState<
"all" | "error" | "warning" | "info"
>("all");
const [categoryFilter, setCategoryFilter] = useState<
const [categoryFilter] = useState<
"all" | "structural" | "parameter" | "semantic" | "execution"
>("all");
const [search, setSearch] = useState("");
@@ -248,18 +239,11 @@ export function ValidationPanel({
React.useEffect(() => {
// Debug: surface validation state to console
// eslint-disable-next-line no-console
console.log("[ValidationPanel] issues", issues, { flatIssues, counts });
}, [issues, flatIssues, counts]);
// Available categories
const availableCategories = useMemo(() => {
const flat = flattenIssues(issues);
const categories = new Set(flat.map((i) => i.category).filter(Boolean));
return Array.from(categories) as Array<
"structural" | "parameter" | "semantic" | "execution"
>;
}, [issues]);
return (
<div
@@ -346,7 +330,7 @@ export function ValidationPanel({
</div>
{/* Issues List */}
<ScrollArea className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
<div className="flex min-w-0 flex-col gap-2 p-2 pr-2">
{counts.total === 0 ? (
<div className="py-8 text-center">
@@ -382,7 +366,7 @@ export function ValidationPanel({
))
)}
</div>
</ScrollArea>
</div>
</div>
);
}

View File

@@ -1,180 +0,0 @@
import React, { useCallback, useMemo } from "react";
import { useDesignerStore } from "../state/store";
import { StepFlow } from "../StepFlow";
import { useDroppable } from "@dnd-kit/core";
import type {
ExperimentAction,
ExperimentStep,
} from "~/lib/experiment-designer/types";
/**
* Hidden droppable anchors so actions dragged from the ActionLibraryPanel
* can land on steps even though StepFlow is still a legacy component.
* This avoids having to deeply modify StepFlow during the transitional phase.
*/
function HiddenDroppableAnchors({ stepIds }: { stepIds: string[] }) {
return (
<>
{stepIds.map((id) => (
<SingleAnchor key={id} id={id} />
))}
</>
);
}
function SingleAnchor({ id }: { id: string }) {
// Register a droppable area matching the StepFlow internal step id pattern
useDroppable({
id: `step-${id}`,
});
// Render nothing (zero-size element) DnD kit only needs the registration
return null;
}
/**
* FlowListView (Transitional)
*
* This component is a TEMPORARY compatibility wrapper around the legacy
* StepFlow component while the new virtualized / dual-mode (List vs Graph)
* flow workspace is implemented.
*
* Responsibilities (current):
* - Read step + selection state from the designer store
* - Provide mutation handlers (upsert, delete, reorder placeholder)
* - Emit structured callbacks (reserved for future instrumentation)
*
* Planned Enhancements:
* - Virtualization for large step counts
* - Inline step creation affordances between steps
* - Multi-select + bulk operations
* - Drag reordering at step level (currently delegated to DnD kit)
* - Graph mode toggle (will lift state to higher DesignerRoot)
* - Performance memoization / fine-grained selectors
*
* Until the new system is complete, this wrapper allows incremental
* replacement without breaking existing behavior.
*/
export interface FlowListViewProps {
/**
* Optional callbacks for higher-level orchestration (e.g. autosave triggers)
*/
onStepMutated?: (
step: ExperimentStep,
kind: "create" | "update" | "delete",
) => void;
onActionMutated?: (
action: ExperimentAction,
step: ExperimentStep,
kind: "create" | "update" | "delete",
) => void;
className?: string;
}
export function FlowListView({
onStepMutated,
onActionMutated,
className,
}: FlowListViewProps) {
/* ----------------------------- Store Selectors ---------------------------- */
const steps = useDesignerStore((s) => s.steps);
const selectedStepId = useDesignerStore((s) => s.selectedStepId);
const selectedActionId = useDesignerStore((s) => s.selectedActionId);
const selectStep = useDesignerStore((s) => s.selectStep);
const selectAction = useDesignerStore((s) => s.selectAction);
const upsertStep = useDesignerStore((s) => s.upsertStep);
const removeStep = useDesignerStore((s) => s.removeStep);
const upsertAction = useDesignerStore((s) => s.upsertAction);
const removeAction = useDesignerStore((s) => s.removeAction);
/* ------------------------------- Handlers --------------------------------- */
const handleStepUpdate = useCallback(
(stepId: string, updates: Partial<ExperimentStep>) => {
const existing = steps.find((s) => s.id === stepId);
if (!existing) return;
const next: ExperimentStep = { ...existing, ...updates };
upsertStep(next);
onStepMutated?.(next, "update");
},
[steps, upsertStep, onStepMutated],
);
const handleStepDelete = useCallback(
(stepId: string) => {
const existing = steps.find((s) => s.id === stepId);
if (!existing) return;
removeStep(stepId);
onStepMutated?.(existing, "delete");
},
[steps, removeStep, onStepMutated],
);
const handleActionDelete = useCallback(
(stepId: string, actionId: string) => {
const step = steps.find((s) => s.id === stepId);
const action = step?.actions.find((a) => a.id === actionId);
removeAction(stepId, actionId);
if (step && action) {
onActionMutated?.(action, step, "delete");
}
},
[steps, removeAction, onActionMutated],
);
const totalActions = useMemo(
() => steps.reduce((sum, s) => sum + s.actions.length, 0),
[steps],
);
/* ------------------------------- Render ----------------------------------- */
return (
<div className={className} data-flow-mode="list">
{/* NOTE: Header / toolbar will be hoisted into the main workspace toolbar in later iterations */}
<div className="flex items-center justify-between border-b px-3 py-2 text-xs">
<div className="flex items-center gap-3 font-medium">
<span className="text-muted-foreground">Flow (List View)</span>
<span className="text-muted-foreground/70">
{steps.length} steps {totalActions} actions
</span>
</div>
<div className="text-muted-foreground/60 text-[10px]">
Transitional component
</div>
</div>
<div className="h-[calc(100%-2.5rem)]">
{/* Hidden droppable anchors to enable dropping actions onto steps */}
<HiddenDroppableAnchors stepIds={steps.map((s) => s.id)} />
<StepFlow
steps={steps}
selectedStepId={selectedStepId ?? null}
selectedActionId={selectedActionId ?? null}
onStepSelect={(id) => selectStep(id)}
onActionSelect={(actionId) =>
selectedStepId && actionId
? selectAction(selectedStepId, actionId)
: undefined
}
onStepDelete={handleStepDelete}
onStepUpdate={handleStepUpdate}
onActionDelete={handleActionDelete}
emptyState={
<div className="text-muted-foreground py-10 text-center text-sm">
No steps yet. Use the + Step button to add your first step.
</div>
}
headerRight={
<div className="text-muted-foreground/70 text-[11px]">
(Add Step control will move to global toolbar)
</div>
}
/>
</div>
</div>
);
}
export default FlowListView;

View File

@@ -2,7 +2,6 @@
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
@@ -27,8 +26,6 @@ import {
Plus,
Trash2,
GitBranch,
Sparkles,
CircleDot,
Edit3,
} from "lucide-react";
import { cn } from "~/lib/utils";
@@ -88,9 +85,7 @@ function generateStepId(): string {
return `step-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function generateActionId(): string {
return `action-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function sortableStepId(stepId: string) {
return `s-step-${stepId}`;
@@ -165,7 +160,7 @@ function SortableActionChip({
className={cn(
"group relative flex w-full flex-col items-start gap-1 rounded border px-3 py-2 text-[11px]",
"bg-muted/40 hover:bg-accent/40 cursor-pointer",
isSelected && "border-blue-500 bg-blue-50 dark:bg-blue-950/30",
isSelected && "border-border bg-accent/30",
isDragging && "opacity-70 shadow-lg",
)}
onClick={onSelect}
@@ -245,7 +240,7 @@ export function FlowWorkspace({
overscan = 400,
onStepCreate,
onStepDelete,
onActionCreate,
onActionCreate: _onActionCreate,
}: FlowWorkspaceProps) {
/* Store selectors */
const steps = useDesignerStore((s) => s.steps);
@@ -256,7 +251,7 @@ export function FlowWorkspace({
const upsertStep = useDesignerStore((s) => s.upsertStep);
const removeStep = useDesignerStore((s) => s.removeStep);
const upsertAction = useDesignerStore((s) => s.upsertAction);
const removeAction = useDesignerStore((s) => s.removeAction);
const reorderStep = useDesignerStore((s) => s.reorderStep);
const reorderAction = useDesignerStore((s) => s.reorderAction);
@@ -266,12 +261,12 @@ export function FlowWorkspace({
const containerRef = useRef<HTMLDivElement | null>(null);
const measureRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const roRef = useRef<ResizeObserver | null>(null);
const pendingHeightsRef = useRef<Map<string, number> | null>(null);
const heightsRafRef = useRef<number | null>(null);
const [heights, setHeights] = useState<Map<string, number>>(new Map());
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(600);
const [containerWidth, setContainerWidth] = useState(0);
const [renamingStepId, setRenamingStepId] = useState<string | null>(null);
const [isDraggingLibraryAction, setIsDraggingLibraryAction] = useState(false);
// dragKind state removed (unused after refactor)
/* Parent lookup for action reorder */
@@ -293,41 +288,47 @@ export function FlowWorkspace({
for (const entry of entries) {
const cr = entry.contentRect;
setViewportHeight(cr.height);
setContainerWidth((prev) => {
if (Math.abs(prev - cr.width) > 0.5) {
// Invalidate cached heights on width change to force re-measure
setHeights(new Map());
}
return cr.width;
});
// Do not invalidate all heights on width change; per-step observers will update as needed
}
});
observer.observe(el);
const cr = el.getBoundingClientRect();
setViewportHeight(el.clientHeight);
setContainerWidth(cr.width);
return () => observer.disconnect();
}, []);
/* Per-step measurement observer (attach/detach on ref set) */
useLayoutEffect(() => {
roRef.current = new ResizeObserver((entries) => {
setHeights((prev) => {
const next = new Map(prev);
let changed = false;
for (const entry of entries) {
const id = entry.target.getAttribute("data-step-id");
if (!id) continue;
const h = entry.contentRect.height;
if (prev.get(id) !== h) {
next.set(id, h);
changed = true;
pendingHeightsRef.current ??= new Map();
for (const entry of entries) {
const id = entry.target.getAttribute("data-step-id");
if (!id) continue;
const h = entry.contentRect.height;
pendingHeightsRef.current.set(id, h);
}
heightsRafRef.current ??= requestAnimationFrame(() => {
const pending = pendingHeightsRef.current;
heightsRafRef.current = null;
pendingHeightsRef.current = null;
if (!pending) return;
setHeights((prev) => {
let changed = false;
const next = new Map(prev);
for (const [id, h] of pending) {
if (prev.get(id) !== h) {
next.set(id, h);
changed = true;
}
}
}
return changed ? next : prev;
return changed ? next : prev;
});
});
});
return () => {
if (heightsRafRef.current) cancelAnimationFrame(heightsRafRef.current);
heightsRafRef.current = null;
pendingHeightsRef.current = null;
roRef.current?.disconnect();
roRef.current = null;
};
@@ -430,29 +431,6 @@ export function FlowWorkspace({
[upsertStep],
);
const addActionToStep = useCallback(
(
stepId: string,
actionDef: { type: string; name: string; category: string },
) => {
const step = steps.find((s) => s.id === stepId);
if (!step) return;
const newAction: ExperimentAction = {
id: generateActionId(),
type: actionDef.type,
name: actionDef.name,
category: actionDef.category as ExperimentAction["category"],
parameters: {},
source: { kind: "core" },
execution: { transport: "internal" },
};
upsertAction(stepId, newAction);
onActionCreate?.(stepId, newAction);
void recomputeHash();
},
[steps, upsertAction, onActionCreate, recomputeHash],
);
const deleteAction = useCallback(
(stepId: string, actionId: string) => {
removeAction(stepId, actionId);
@@ -469,14 +447,13 @@ export function FlowWorkspace({
const handleLocalDragStart = useCallback((e: DragStartEvent) => {
const id = e.active.id.toString();
if (id.startsWith("action-")) {
setIsDraggingLibraryAction(true);
// no-op
}
}, []);
const handleLocalDragEnd = useCallback(
(e: DragEndEvent) => {
const { active, over } = e;
setIsDraggingLibraryAction(false);
if (!over || !active) {
return;
}
@@ -525,7 +502,7 @@ export function FlowWorkspace({
onDragStart: handleLocalDragStart,
onDragEnd: handleLocalDragEnd,
onDragCancel: () => {
setIsDraggingLibraryAction(false);
// no-op
},
});
@@ -578,9 +555,9 @@ export function FlowWorkspace({
<StepDroppableArea stepId={step.id} />
<div
className={cn(
"rounded border shadow-sm transition-colors mb-2",
"mb-2 rounded border shadow-sm transition-colors",
selectedStepId === step.id
? "border-blue-400/60 bg-blue-50/40 dark:bg-blue-950/20"
? "border-border bg-accent/30"
: "hover:bg-accent/30",
isDragging && "opacity-80 ring-1 ring-blue-300",
)}
@@ -590,7 +567,8 @@ export function FlowWorkspace({
onClick={(e) => {
// Avoid selecting step when interacting with controls or inputs
const tag = (e.target as HTMLElement).tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "button") return;
if (tag === "input" || tag === "textarea" || tag === "button")
return;
selectStep(step.id);
selectAction(step.id, undefined);
}}
@@ -718,7 +696,7 @@ export function FlowWorkspace({
</div>
{/* Persistent centered bottom drop hint */}
<div className="mt-3 flex w-full items-center justify-center">
<div className="text-muted-foreground border border-dashed border-muted-foreground/30 rounded px-2 py-1 text-[11px]">
<div className="text-muted-foreground border-muted-foreground/30 rounded border border-dashed px-2 py-1 text-[11px]">
Drop actions here
</div>
</div>
@@ -734,7 +712,7 @@ export function FlowWorkspace({
/* Render */
/* ------------------------------------------------------------------------ */
return (
<div className={cn("flex h-full flex-col", className)}>
<div className={cn("flex h-full min-h-0 flex-col", className)}>
<div className="flex items-center justify-between border-b px-3 py-2 text-xs">
<div className="flex items-center gap-3 font-medium">
<span className="text-muted-foreground flex items-center gap-1">
@@ -760,20 +738,24 @@ export function FlowWorkspace({
<div
ref={containerRef}
className="relative flex-1 overflow-y-auto"
className="relative h-0 min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
onScroll={onScroll}
>
{steps.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center p-6">
<div className="text-center">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full border">
<GitBranch className="h-6 w-6 text-muted-foreground" />
<GitBranch className="text-muted-foreground h-6 w-6" />
</div>
<p className="mb-2 text-sm font-medium">No steps yet</p>
<p className="text-muted-foreground mb-3 text-xs">
Create your first step to begin designing the flow.
</p>
<Button size="sm" className="h-7 px-2 text-[11px]" onClick={() => createStep()}>
<Button
size="sm"
className="h-7 px-2 text-[11px]"
onClick={() => createStep()}
>
<Plus className="mr-1 h-3 w-3" /> Add Step
</Button>
</div>

View File

@@ -1,382 +1,310 @@
"use client";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import * as React from "react";
import { cn } from "~/lib/utils";
type Edge = "left" | "right";
export interface PanelsContainerProps {
left?: React.ReactNode;
center: React.ReactNode;
right?: React.ReactNode;
/**
* Draw dividers between panels (applied to center only to avoid double borders).
* Defaults to true.
*/
showDividers?: boolean;
/** Class applied to the root container */
className?: string;
/** Class applied to each panel wrapper (left/center/right) */
panelClassName?: string;
/** Class applied to each panel's internal scroll container */
contentClassName?: string;
/** Accessible label for the overall layout */
"aria-label"?: string;
/** Min/Max fractional widths for left and right panels (0..1), clamped during drag */
minLeftPct?: number;
maxLeftPct?: number;
minRightPct?: number;
maxRightPct?: number;
/** Keyboard resize step (fractional) per arrow press; Shift increases by 2x */
keyboardStepPct?: number;
}
/**
* PanelsContainer
*
* Structural layout component for the Experiment Designer refactor.
* Provides:
* - Optional left + right side panels (resizable + collapsible)
* - Central workspace (always present)
* - Persistent panel widths (localStorage)
* - Keyboard-accessible resize handles
* - Minimal DOM repaint during drag (inline styles)
* Tailwind-first, grid-based panel layout with:
* - Drag-resizable left/right panels (no persistence)
* - Strict overflow containment (no page-level x-scroll)
* - Internal y-scroll for each panel
* - Optional visual dividers on the center panel only (prevents double borders)
*
* NOT responsible for:
* - Business logic or data fetching
* - Panel content semantics (passed via props)
*
* Accessibility:
* - Resize handles are <button> elements with aria-label
* - Keyboard: ArrowLeft / ArrowRight adjusts width by step
* Implementation details:
* - Uses CSS variables for column fractions and an explicit grid template:
* [minmax(0,var(--col-left)) minmax(0,var(--col-center)) minmax(0,var(--col-right))]
* - Resize handles are absolutely positioned over the grid at the left and right boundaries.
* - Fractions are clamped with configurable min/max so panels remain usable at all sizes.
*/
const STORAGE_KEY = "hristudio-designer-panels-v1";
interface PersistedLayout {
left: number;
right: number;
leftCollapsed: boolean;
rightCollapsed: boolean;
}
export interface PanelsContainerProps {
left?: ReactNode;
center: ReactNode;
right?: ReactNode;
/**
* Initial (non-collapsed) widths in pixels.
* If panels are omitted, their widths are ignored.
*/
initialLeftWidth?: number;
initialRightWidth?: number;
/**
* Minimum / maximum constraints to avoid unusable panels.
*/
minLeftWidth?: number;
minRightWidth?: number;
maxLeftWidth?: number;
maxRightWidth?: number;
/**
* Whether persistence to localStorage should be skipped (e.g. SSR preview)
*/
disablePersistence?: boolean;
/**
* ClassName pass-through for root container
*/
className?: string;
}
interface DragState {
edge: "left" | "right";
startX: number;
startWidth: number;
}
export function PanelsContainer({
left,
center,
right,
initialLeftWidth = 280,
initialRightWidth = 340,
minLeftWidth = 200,
minRightWidth = 260,
maxLeftWidth = 520,
maxRightWidth = 560,
disablePersistence = false,
showDividers = true,
className,
panelClassName,
contentClassName,
"aria-label": ariaLabel = "Designer panel layout",
minLeftPct = 0.12,
maxLeftPct = 0.33,
minRightPct = 0.12,
maxRightPct = 0.33,
keyboardStepPct = 0.02,
}: PanelsContainerProps) {
const hasLeft = Boolean(left);
const hasRight = Boolean(right);
const hasCenter = Boolean(center);
/* ------------------------------------------------------------------------ */
/* State */
/* ------------------------------------------------------------------------ */
// Fractions for side panels (center is derived as 1 - (left + right))
const [leftPct, setLeftPct] = React.useState<number>(hasLeft ? 0.2 : 0);
const [rightPct, setRightPct] = React.useState<number>(hasRight ? 0.24 : 0);
const [leftWidth, setLeftWidth] = useState(initialLeftWidth);
const [rightWidth, setRightWidth] = useState(initialRightWidth);
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
const rootRef = React.useRef<HTMLDivElement | null>(null);
const dragRef = React.useRef<{
edge: Edge;
startX: number;
startLeft: number;
startRight: number;
containerWidth: number;
} | null>(null);
const dragRef = useRef<DragState | null>(null);
const frameReq = useRef<number | null>(null);
const clamp = (v: number, lo: number, hi: number): number =>
Math.max(lo, Math.min(hi, v));
/* ------------------------------------------------------------------------ */
/* Persistence */
/* ------------------------------------------------------------------------ */
const recompute = React.useCallback(
(lp: number, rp: number) => {
if (!hasCenter) return { l: 0, c: 0, r: 0 };
useLayoutEffect(() => {
if (disablePersistence) return;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw) as PersistedLayout;
if (typeof parsed.left === "number") setLeftWidth(parsed.left);
if (typeof parsed.right === "number")
setRightWidth(Math.max(parsed.right, minRightWidth));
if (typeof parsed.leftCollapsed === "boolean") {
setLeftCollapsed(parsed.leftCollapsed);
if (hasLeft && hasRight) {
const l = clamp(lp, minLeftPct, maxLeftPct);
const r = clamp(rp, minRightPct, maxRightPct);
const c = Math.max(0.1, 1 - (l + r)); // always preserve some center space
return { l, c, r };
}
// Always start with right panel visible to avoid hidden inspector state
setRightCollapsed(false);
} catch {
/* noop */
}
}, [disablePersistence, minRightWidth]);
const persist = useCallback(
(next?: Partial<PersistedLayout>) => {
if (disablePersistence) return;
const snapshot: PersistedLayout = {
left: leftWidth,
right: rightWidth,
leftCollapsed,
rightCollapsed,
...next,
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch {
/* noop */
if (hasLeft && !hasRight) {
const l = clamp(lp, minLeftPct, maxLeftPct);
const c = Math.max(0.2, 1 - l);
return { l, c, r: 0 };
}
},
[disablePersistence, leftWidth, rightWidth, leftCollapsed, rightCollapsed],
);
useEffect(() => {
persist();
}, [leftWidth, rightWidth, leftCollapsed, rightCollapsed, persist]);
/* ------------------------------------------------------------------------ */
/* Drag Handlers */
/* ------------------------------------------------------------------------ */
const onPointerMove = useCallback(
(e: PointerEvent) => {
if (!dragRef.current) return;
const { edge, startX, startWidth } = dragRef.current;
const delta = e.clientX - startX;
if (edge === "left") {
let next = startWidth + delta;
next = Math.max(minLeftWidth, Math.min(maxLeftWidth, next));
if (next !== leftWidth) {
if (frameReq.current) cancelAnimationFrame(frameReq.current);
frameReq.current = requestAnimationFrame(() => setLeftWidth(next));
}
} else if (edge === "right") {
let next = startWidth - delta;
next = Math.max(minRightWidth, Math.min(maxRightWidth, next));
if (next !== rightWidth) {
if (frameReq.current) cancelAnimationFrame(frameReq.current);
frameReq.current = requestAnimationFrame(() => setRightWidth(next));
}
if (!hasLeft && hasRight) {
const r = clamp(rp, minRightPct, maxRightPct);
const c = Math.max(0.2, 1 - r);
return { l: 0, c, r };
}
// Center only
return { l: 0, c: 1, r: 0 };
},
[
leftWidth,
rightWidth,
minLeftWidth,
maxLeftWidth,
minRightWidth,
maxRightWidth,
hasCenter,
hasLeft,
hasRight,
minLeftPct,
maxLeftPct,
minRightPct,
maxRightPct,
],
);
const endDrag = useCallback(() => {
const { l, c, r } = recompute(leftPct, rightPct);
// Attach/detach global pointer handlers safely
const onPointerMove = React.useCallback(
(e: PointerEvent) => {
const d = dragRef.current;
if (!d || d.containerWidth <= 0) return;
const deltaPx = e.clientX - d.startX;
const deltaPct = deltaPx / d.containerWidth;
if (d.edge === "left" && hasLeft) {
const nextLeft = clamp(d.startLeft + deltaPct, minLeftPct, maxLeftPct);
setLeftPct(nextLeft);
} else if (d.edge === "right" && hasRight) {
// Dragging the right edge moves leftwards as delta increases
const nextRight = clamp(
d.startRight - deltaPct,
minRightPct,
maxRightPct,
);
setRightPct(nextRight);
}
},
[hasLeft, hasRight, minLeftPct, maxLeftPct, minRightPct, maxRightPct],
);
const endDrag = React.useCallback(() => {
dragRef.current = null;
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", endDrag);
}, [onPointerMove]);
const startDrag = useCallback(
(edge: "left" | "right", e: React.PointerEvent<HTMLButtonElement>) => {
const startDrag =
(edge: Edge) => (e: React.PointerEvent<HTMLButtonElement>) => {
if (!rootRef.current) return;
e.preventDefault();
if (edge === "left" && leftCollapsed) return;
if (edge === "right" && rightCollapsed) return;
const rect = rootRef.current.getBoundingClientRect();
dragRef.current = {
edge,
startX: e.clientX,
startWidth: edge === "left" ? leftWidth : rightWidth,
startLeft: leftPct,
startRight: rightPct,
containerWidth: rect.width,
};
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", endDrag);
},
[
leftWidth,
rightWidth,
leftCollapsed,
rightCollapsed,
onPointerMove,
endDrag,
],
};
React.useEffect(() => {
return () => {
// Cleanup if unmounted mid-drag
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", endDrag);
};
}, [onPointerMove, endDrag]);
// Keyboard resize for handles
const onKeyResize =
(edge: Edge) => (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
const step = (e.shiftKey ? 2 : 1) * keyboardStepPct;
if (edge === "left" && hasLeft) {
const next = clamp(
leftPct + (e.key === "ArrowRight" ? step : -step),
minLeftPct,
maxLeftPct,
);
setLeftPct(next);
} else if (edge === "right" && hasRight) {
const next = clamp(
rightPct + (e.key === "ArrowLeft" ? step : -step),
minRightPct,
maxRightPct,
);
setRightPct(next);
}
};
// CSS variables for the grid fractions
const styleVars: React.CSSProperties & Record<string, string> = hasCenter
? {
"--col-left": `${(hasLeft ? l : 0) * 100}%`,
"--col-center": `${c * 100}%`,
"--col-right": `${(hasRight ? r : 0) * 100}%`,
}
: {};
// Explicit grid template depending on which side panels exist
const gridCols =
hasLeft && hasRight
? "[grid-template-columns:minmax(0,var(--col-left))_minmax(0,var(--col-center))_minmax(0,var(--col-right))]"
: hasLeft && !hasRight
? "[grid-template-columns:minmax(0,var(--col-left))_minmax(0,var(--col-center))]"
: !hasLeft && hasRight
? "[grid-template-columns:minmax(0,var(--col-center))_minmax(0,var(--col-right))]"
: "[grid-template-columns:minmax(0,1fr)]";
// Dividers on the center panel only (prevents double borders if children have their own borders)
const centerDividers =
showDividers && hasCenter
? cn({
"border-l": hasLeft,
"border-r": hasRight,
})
: undefined;
const Panel: React.FC<React.PropsWithChildren<{ className?: string }>> = ({
className: panelCls,
children,
}) => (
<section
className={cn("min-w-0 overflow-hidden", panelCls, panelClassName)}
>
<div
className={cn(
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto",
contentClassName,
)}
>
{children}
</div>
</section>
);
/* ------------------------------------------------------------------------ */
/* Collapse / Expand */
/* ------------------------------------------------------------------------ */
const toggleLeft = useCallback(() => {
if (!hasLeft) return;
setLeftCollapsed((c) => {
const next = !c;
if (next === false && leftWidth < minLeftWidth) {
setLeftWidth(initialLeftWidth);
}
return next;
});
}, [hasLeft, leftWidth, minLeftWidth, initialLeftWidth]);
const toggleRight = useCallback(() => {
if (!hasRight) return;
setRightCollapsed((c) => {
const next = !c;
if (next === false && rightWidth < minRightWidth) {
setRightWidth(initialRightWidth);
}
return next;
});
}, [hasRight, rightWidth, minRightWidth, initialRightWidth]);
/* Keyboard resizing (focused handle) */
const handleKeyResize = useCallback(
(edge: "left" | "right", e: React.KeyboardEvent<HTMLButtonElement>) => {
const step = e.shiftKey ? 24 : 12;
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
if (edge === "left" && !leftCollapsed) {
setLeftWidth((w) => {
const delta = e.key === "ArrowLeft" ? -step : step;
return Math.max(minLeftWidth, Math.min(maxLeftWidth, w + delta));
});
} else if (edge === "right" && !rightCollapsed) {
setRightWidth((w) => {
const delta = e.key === "ArrowLeft" ? -step : step;
return Math.max(minRightWidth, Math.min(maxRightWidth, w + delta));
});
}
} else if (e.key === "Enter" || e.key === " ") {
if (edge === "left") toggleLeft();
else toggleRight();
}
},
[
leftCollapsed,
rightCollapsed,
minLeftWidth,
maxLeftWidth,
minRightWidth,
maxRightWidth,
toggleLeft,
toggleRight,
],
);
/* ------------------------------------------------------------------------ */
/* Render */
/* ------------------------------------------------------------------------ */
return (
<div
ref={rootRef}
aria-label={ariaLabel}
style={styleVars}
className={cn(
"flex h-full w-full overflow-hidden select-none",
"relative grid h-full min-h-0 w-full overflow-hidden select-none",
gridCols,
className,
)}
aria-label="Designer panel layout"
>
{/* Left Panel */}
{hasLeft && (
<div
className={cn(
"bg-background/50 relative flex h-full flex-shrink-0 flex-col border-r transition-[width] duration-150",
leftCollapsed ? "w-0 border-r-0" : "w-[var(--panel-left-width)]",
)}
style={
leftCollapsed
? undefined
: ({
["--panel-left-width" as string]: `${leftWidth}px`,
} as React.CSSProperties)
}
>
{!leftCollapsed && (
<div className="flex-1 overflow-hidden">{left}</div>
)}
</div>
)}
{hasLeft && <Panel>{left}</Panel>}
{/* Left Resize Handle */}
{hasLeft && !leftCollapsed && (
{hasCenter && <Panel className={centerDividers}>{center}</Panel>}
{hasRight && <Panel>{right}</Panel>}
{/* Resize handles (only render where applicable) */}
{hasCenter && hasLeft && (
<button
type="button"
aria-label="Resize left panel (Enter to toggle collapse)"
onPointerDown={(e) => startDrag("left", e)}
onDoubleClick={toggleLeft}
onKeyDown={(e) => handleKeyResize("left", e)}
className="hover:bg-accent/40 focus-visible:ring-ring relative z-10 h-full w-0 cursor-col-resize px-1 outline-none focus-visible:ring-2"
role="separator"
aria-label="Resize left panel"
aria-orientation="vertical"
onPointerDown={startDrag("left")}
onKeyDown={onKeyResize("left")}
className={cn(
"absolute inset-y-0 z-10 w-1 cursor-col-resize outline-none",
"focus-visible:ring-ring focus-visible:ring-2",
)}
// Position at the boundary between left and center
style={{ left: "var(--col-left)", transform: "translateX(-0.5px)" }}
tabIndex={0}
/>
)}
{/* Left collapse toggle removed to prevent breadcrumb overlap */}
{/* Center (Workspace) */}
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-hidden">{center}</div>
</div>
{/* Right Resize Handle */}
{hasRight && !rightCollapsed && (
{hasCenter && hasRight && (
<button
type="button"
aria-label="Resize right panel (Enter to toggle collapse)"
onPointerDown={(e) => startDrag("right", e)}
onDoubleClick={toggleRight}
onKeyDown={(e) => handleKeyResize("right", e)}
className="hover:bg-accent/40 focus-visible:ring-ring relative z-10 h-full w-1 cursor-col-resize outline-none focus-visible:ring-2"
role="separator"
aria-label="Resize right panel"
aria-orientation="vertical"
onPointerDown={startDrag("right")}
onKeyDown={onKeyResize("right")}
className={cn(
"absolute inset-y-0 z-10 w-1 cursor-col-resize outline-none",
"focus-visible:ring-ring focus-visible:ring-2",
)}
// Position at the boundary between center and right (offset from the right)
style={{ right: "var(--col-right)", transform: "translateX(0.5px)" }}
tabIndex={0}
/>
)}
{/* Right Panel */}
{hasRight && (
<div
className={cn(
"bg-background/50 relative flex h-full flex-shrink-0 flex-col transition-[width] duration-150",
rightCollapsed ? "w-0" : "w-[var(--panel-right-width)]",
)}
style={
rightCollapsed
? undefined
: ({
["--panel-right-width" as string]: `${rightWidth}px`,
} as React.CSSProperties)
}
>
{!rightCollapsed && (
<div className="min-w-0 flex-1 overflow-hidden">{right}</div>
)}
</div>
)}
{/* Minimal Right Toggle (top-right), non-intrusive like VSCode */}
{hasRight && (
<button
type="button"
aria-label={
rightCollapsed ? "Expand inspector" : "Collapse inspector"
}
onClick={toggleRight}
className={cn(
"text-muted-foreground hover:text-foreground absolute top-1 z-20 p-1 text-[10px]",
rightCollapsed ? "right-1" : "right-1",
)}
title={rightCollapsed ? "Show inspector" : "Hide inspector"}
>
{rightCollapsed ? "◀" : "▶"}
</button>
)}
</div>
);
}

View File

@@ -28,6 +28,7 @@ import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { ScrollArea } from "~/components/ui/scroll-area";
import { cn } from "~/lib/utils";
import { useActionRegistry } from "../ActionRegistry";
import type { ActionDefinition } from "~/lib/experiment-designer/types";
@@ -79,14 +80,17 @@ function DraggableAction({
onToggleFavorite,
highlight,
}: DraggableActionProps) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `action-${action.id}`,
data: { action },
});
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
id: `action-${action.id}`,
data: { action },
});
// Disable visual translation during drag so the list does not shift items.
// We still let dnd-kit manage the drag overlay internally (no manual transform).
const style: React.CSSProperties = {};
const style: React.CSSProperties = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
: {};
const IconComponent = iconMap[action.icon] ?? Sparkles;
@@ -104,12 +108,12 @@ function DraggableAction({
{...listeners}
style={style}
className={cn(
"group bg-background/60 hover:bg-accent/50 relative flex w-full cursor-grab touch-none flex-col gap-1 rounded border px-2 transition-colors select-none",
"group bg-background/60 hover:bg-accent/50 relative flex w-full cursor-grab touch-none flex-col gap-1 rounded border px-2 text-left transition-colors select-none",
compact ? "py-1.5 text-[11px]" : "py-2 text-[12px]",
isDragging && "ring-border opacity-60 ring-1",
isDragging && "opacity-50",
)}
draggable={false}
onDragStart={(e) => e.preventDefault()}
title={action.description ?? ""}
>
<button
type="button"
@@ -127,7 +131,7 @@ function DraggableAction({
)}
</button>
<div className="flex items-start gap-2 select-none">
<div className="flex min-w-0 items-start gap-2 select-none">
<div
className={cn(
"flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-white",
@@ -331,8 +335,8 @@ export function ActionLibraryPanel() {
).length;
return (
<div className="flex h-full max-w-[240px] flex-col overflow-hidden">
<div className="bg-background/60 border-b p-2">
<div className="flex h-full flex-col overflow-hidden">
<div className="bg-background/60 flex-shrink-0 border-b p-2">
<div className="relative mb-2">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" />
<Input
@@ -359,10 +363,11 @@ export function ActionLibraryPanel() {
)}
onClick={() => toggleCategory(cat.key)}
aria-pressed={active}
aria-label={cat.label}
>
<Icon className="h-3 w-3" />
{cat.label}
<span className="ml-auto text-[10px] font-normal opacity-80">
<span className="hidden md:inline">{cat.label}</span>
<span className="ml-auto hidden text-[10px] font-normal opacity-80 lg:inline">
{countsByCategory[cat.key]}
</span>
</Button>
@@ -374,17 +379,17 @@ export function ActionLibraryPanel() {
<Button
variant={showOnlyFavorites ? "default" : "outline"}
size="sm"
className="h-7 min-w-[80px] flex-1"
className="h-7 flex-1"
onClick={() => setShowOnlyFavorites((s) => !s)}
aria-pressed={showOnlyFavorites}
aria-label="Toggle favorites filter"
>
<Star className="mr-1 h-3 w-3" />
Fav
<Star className="h-3 w-3" />
<span className="ml-1 hidden sm:inline">Fav</span>
{showOnlyFavorites && (
<Badge
variant="secondary"
className="ml-1 h-4 px-1 text-[10px]"
className="ml-1 hidden h-4 px-1 text-[10px] sm:inline"
title="Visible favorites"
>
{visibleFavoritesCount}
@@ -394,7 +399,7 @@ export function ActionLibraryPanel() {
<Button
variant="outline"
size="sm"
className="h-7 min-w-[80px] flex-1"
className="h-7 flex-1"
onClick={() =>
setDensity((d) =>
d === "comfortable" ? "compact" : "comfortable",
@@ -402,18 +407,20 @@ export function ActionLibraryPanel() {
}
aria-label="Toggle density"
>
<SlidersHorizontal className="mr-1 h-3 w-3" />
{density === "comfortable" ? "Dense" : "Relax"}
<SlidersHorizontal className="h-3 w-3" />
<span className="ml-1 hidden sm:inline">
{density === "comfortable" ? "Dense" : "Relax"}
</span>
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 min-w-[60px] flex-1"
className="h-7 flex-1"
onClick={clearFilters}
aria-label="Clear filters"
>
<X className="h-3 w-3" />
Clear
<span className="ml-1 hidden sm:inline">Clear</span>
</Button>
</div>
@@ -432,8 +439,8 @@ export function ActionLibraryPanel() {
</div>
</div>
<ScrollArea className="flex-1 overflow-x-hidden overflow-y-auto">
<div className="flex flex-col gap-2 p-2">
<ScrollArea className="flex-1 overflow-hidden">
<div className="grid grid-cols-1 gap-2 p-2">
{filtered.length === 0 ? (
<div className="text-muted-foreground/70 flex flex-col items-center gap-2 py-10 text-center text-xs">
<Filter className="h-6 w-6" />
@@ -454,7 +461,7 @@ export function ActionLibraryPanel() {
</div>
</ScrollArea>
<div className="bg-background/60 border-t p-2">
<div className="bg-background/60 flex-shrink-0 border-t p-2">
<div className="flex items-center justify-between text-[10px]">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="h-4 px-1 text-[10px]">

View File

@@ -2,7 +2,7 @@
import React, { useMemo, useState, useCallback } from "react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import { ScrollArea } from "~/components/ui/scroll-area";
import { cn } from "~/lib/utils";
import { useDesignerStore } from "../state/store";
import { actionRegistry } from "../ActionRegistry";
@@ -200,7 +200,7 @@ export function InspectorPanel({
return (
<div
className={cn(
"bg-background/40 border-border relative flex h-full min-w-0 flex-col overflow-hidden border-l backdrop-blur-sm",
"bg-background/40 border-border relative flex h-full min-w-0 flex-col overflow-hidden break-words whitespace-normal backdrop-blur-sm",
className,
)}
style={{ contain: "layout paint size" }}
@@ -208,62 +208,51 @@ export function InspectorPanel({
aria-label="Inspector panel"
>
{/* Tab Header */}
<div className="border-b px-2 py-1.5">
<Tabs
value={effectiveTab}
onValueChange={handleTabChange}
className="w-full"
>
<TabsList className="flex h-9 w-full items-center gap-1 overflow-hidden">
<TabsTrigger
value="properties"
className="flex min-w-0 flex-1 items-center justify-center gap-1 truncate text-[11px]"
title="Properties (Step / Action)"
>
<Tabs
value={effectiveTab}
onValueChange={handleTabChange}
className="flex min-h-0 w-full flex-1 flex-col"
>
<div className="px-2 py-1.5">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="properties" title="Properties (Step / Action)">
<Settings className="h-3 w-3 flex-shrink-0" />
<span className="hidden sm:inline">Props</span>
<span className="hidden md:inline">Props</span>
</TabsTrigger>
<TabsTrigger
value="issues"
className="flex min-w-0 flex-1 items-center justify-center gap-1 truncate text-[11px]"
title="Validation Issues"
>
<TabsTrigger value="issues" title="Validation Issues">
<AlertTriangle className="h-3 w-3 flex-shrink-0" />
<span className="hidden sm:inline">
<span className="hidden md:inline">
Issues{issueCount > 0 ? ` (${issueCount})` : ""}
</span>
{issueCount > 0 && (
<span className="xs:hidden text-amber-600 dark:text-amber-400">
<span className="text-amber-600 md:hidden dark:text-amber-400">
{issueCount}
</span>
)}
</TabsTrigger>
<TabsTrigger
value="dependencies"
className="flex min-w-0 flex-1 items-center justify-center gap-1 truncate text-[11px]"
title="Dependencies / Drift"
>
<TabsTrigger value="dependencies" title="Dependencies / Drift">
<PackageSearch className="h-3 w-3 flex-shrink-0" />
<span className="hidden sm:inline">
<span className="hidden md:inline">
Deps{driftCount > 0 ? ` (${driftCount})` : ""}
</span>
{driftCount > 0 && (
<span className="xs:hidden text-purple-600 dark:text-purple-400">
<span className="text-purple-600 md:hidden dark:text-purple-400">
{driftCount}
</span>
)}
</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
{/* Content */}
<div className="flex min-h-0 flex-1 flex-col">
{/*
{/* Content */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{/*
Force consistent width for tab bodies to prevent reflow when
switching between content with different intrinsic widths.
*/}
<Tabs value={effectiveTab}>
{/* Properties */}
<TabsContent
value="properties"
@@ -282,8 +271,8 @@ export function InspectorPanel({
</div>
</div>
) : (
<ScrollArea className="flex-1">
<div className="w-full px-3 py-3">
<div className="flex-1 overflow-x-hidden overflow-y-auto">
<div className="w-full px-0 py-2 break-words whitespace-normal">
<PropertiesPanel
design={{
id: "design",
@@ -299,7 +288,7 @@ export function InspectorPanel({
onStepUpdate={handleStepUpdate}
/>
</div>
</ScrollArea>
</div>
)}
</TabsContent>
@@ -344,8 +333,8 @@ export function InspectorPanel({
value="dependencies"
className="m-0 flex min-h-0 flex-1 flex-col data-[state=inactive]:hidden"
>
<ScrollArea className="flex-1">
<div className="w-full px-3 py-3">
<div className="flex-1 overflow-x-hidden overflow-y-auto">
<div className="w-full px-3 py-3 break-words whitespace-normal">
<DependencyInspector
steps={steps}
actionSignatureDrift={actionSignatureDrift}
@@ -363,10 +352,10 @@ export function InspectorPanel({
}}
/>
</div>
</ScrollArea>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</Tabs>
{/* Footer (lightweight) */}
<div className="text-muted-foreground border-t px-3 py-1.5 text-[10px]">

View File

@@ -70,8 +70,8 @@ function canonicalize(value: unknown): CanonicalValue {
function bufferToHex(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let hex = "";
for (let i = 0; i < bytes.length; i++) {
const b = bytes[i]?.toString(16).padStart(2, "0");
for (const byte of bytes) {
const b = byte.toString(16).padStart(2, "0");
hex += b;
}
return hex;
@@ -90,8 +90,9 @@ async function hashString(input: string): Promise<string> {
// Fallback to Node (should not execute in Edge runtime)
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const nodeCrypto: typeof import("crypto") = require("crypto");
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const nodeCrypto = require("crypto");
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
return nodeCrypto.createHash("sha256").update(input).digest("hex");
} catch {
throw new Error("No suitable crypto implementation available for hashing.");

View File

@@ -434,7 +434,7 @@ export function validateParameters(
// Unknown parameter type
issues.push({
severity: "warning",
message: `Unknown parameter type '${paramDef.type}' for '${paramDef.name}'`,
message: `Unknown parameter type '${String(paramDef.type)}' for '${paramDef.name}'`,
category: "parameter",
field,
stepId,
@@ -723,9 +723,7 @@ export function groupIssuesByEntity(
issues.forEach((issue) => {
const entityId = issue.actionId ?? issue.stepId ?? "experiment";
if (!grouped[entityId]) {
grouped[entityId] = [];
}
grouped[entityId] ??= [];
grouped[entityId].push(issue);
});

View File

@@ -32,7 +32,7 @@ export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "hristudio-theme",
attribute = "class",
attribute: _attribute = "class",
enableSystem = true,
disableTransitionOnChange = false,
...props

View File

@@ -12,7 +12,7 @@ import {
import { useTheme } from "./theme-provider";
export function ThemeToggle() {
const { setTheme, theme } = useTheme();
const { setTheme } = useTheme();
return (
<DropdownMenu>

View File

@@ -268,7 +268,7 @@ function TrialCard({ trial, userRole, onTrialAction }: TrialCardProps) {
}
export function TrialsGrid() {
const [refreshKey, setRefreshKey] = useState(0);
const [statusFilter, setStatusFilter] = useState<string>("all");
const { data: userSession } = api.auth.me.useQuery();
@@ -282,7 +282,15 @@ export function TrialsGrid() {
{
page: 1,
limit: 50,
status: statusFilter === "all" ? undefined : (statusFilter as any),
status:
statusFilter === "all"
? undefined
: (statusFilter as
| "scheduled"
| "in_progress"
| "completed"
| "aborted"
| "failed"),
},
{
refetchOnWindowFocus: false,
@@ -309,16 +317,13 @@ export function TrialsGrid() {
}
};
const handleTrialCreated = () => {
setRefreshKey((prev) => prev + 1);
void refetch();
};
// Group trials by status for better organization
const upcomingTrials = trials.filter((t) => t.status === "scheduled");
const activeTrials = trials.filter((t) => t.status === "in_progress");
const completedTrials = trials.filter((t) => t.status === "completed");
const cancelledTrials = trials.filter((t) => t.status === "aborted");
if (isLoading) {
return (

View File

@@ -2,20 +2,35 @@
import { format, formatDistanceToNow } from "date-fns";
import {
Activity, AlertTriangle, ArrowRight, Bot, Camera, CheckCircle, Eye, Hand, MessageSquare, Pause, Play, Settings, User, Volume2, XCircle
Activity,
AlertTriangle,
ArrowRight,
Bot,
Camera,
CheckCircle,
Eye,
Hand,
MessageSquare,
Pause,
Play,
Settings,
User,
Volume2,
XCircle,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { ScrollArea } from "~/components/ui/scroll-area";
import { api } from "~/trpc/react";
import type { WebSocketMessage } from "~/hooks/useWebSocket";
interface EventsLogProps {
trialId: string;
refreshKey: number;
isLive: boolean;
maxEvents?: number;
realtimeEvents?: any[];
realtimeEvents?: WebSocketMessage[];
isWebSocketConnected?: boolean;
}
@@ -24,7 +39,7 @@ interface TrialEvent {
trialId: string;
eventType: string;
timestamp: Date;
data: any;
data: Record<string, unknown> | null;
notes: string | null;
createdAt: Date;
}
@@ -177,7 +192,17 @@ export function EventsLog({
{
trialId,
limit: maxEvents,
type: filter === "all" ? undefined : filter as "error" | "custom" | "trial_start" | "trial_end" | "step_start" | "step_end" | "wizard_intervention",
type:
filter === "all"
? undefined
: (filter as
| "error"
| "custom"
| "trial_start"
| "trial_end"
| "step_start"
| "step_end"
| "wizard_intervention"),
},
{
refetchInterval: isLive && !isWebSocketConnected ? 2000 : 10000, // Less frequent polling when WebSocket is active
@@ -186,23 +211,48 @@ export function EventsLog({
},
);
// Convert WebSocket events to trial events format
const convertWebSocketEvent = (wsEvent: any): TrialEvent => ({
id: `ws-${Date.now()}-${Math.random()}`,
trialId,
eventType:
wsEvent.type === "trial_action_executed"
? "wizard_action"
: wsEvent.type === "intervention_logged"
? "wizard_intervention"
: wsEvent.type === "step_changed"
? "step_transition"
: wsEvent.type || "system_event",
timestamp: new Date(wsEvent.data?.timestamp || Date.now()),
data: wsEvent.data || {},
notes: wsEvent.data?.notes || null,
createdAt: new Date(wsEvent.data?.timestamp || Date.now()),
});
// Convert WebSocket events to trial events format (type-safe)
const convertWebSocketEvent = useCallback(
(wsEvent: WebSocketMessage): TrialEvent => {
const eventType =
wsEvent.type === "trial_action_executed"
? "wizard_action"
: wsEvent.type === "intervention_logged"
? "wizard_intervention"
: wsEvent.type === "step_changed"
? "step_transition"
: wsEvent.type || "system_event";
const rawData = wsEvent.data;
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null;
const data: Record<string, unknown> | null = isRecord(rawData)
? rawData
: null;
const ts =
isRecord(rawData) && typeof rawData.timestamp === "number"
? rawData.timestamp
: Date.now();
const notes =
isRecord(rawData) && typeof rawData.notes === "string"
? rawData.notes
: null;
return {
id: `ws-${Date.now()}-${Math.random()}`,
trialId,
eventType,
timestamp: new Date(ts),
data,
notes,
createdAt: new Date(ts),
};
},
[trialId],
);
// Update events when data changes (prioritize WebSocket events)
useEffect(() => {
@@ -210,11 +260,26 @@ export function EventsLog({
// Add database events
if (eventsData) {
newEvents = eventsData.map((event) => ({
...event,
type ApiTrialEvent = {
id: string;
trialId: string;
eventType: string;
timestamp: string | Date;
data: unknown;
};
const apiEvents = (eventsData as unknown as ApiTrialEvent[]) ?? [];
newEvents = apiEvents.map((event) => ({
id: event.id,
trialId: event.trialId,
eventType: event.eventType,
timestamp: new Date(event.timestamp),
data:
typeof event.data === "object" && event.data !== null
? (event.data as Record<string, unknown>)
: null,
notes: null,
createdAt: new Date(event.timestamp),
notes: null, // Add required field
}));
}
@@ -240,7 +305,14 @@ export function EventsLog({
.slice(-maxEvents); // Keep only the most recent events
setEvents(uniqueEvents);
}, [eventsData, refreshKey, realtimeEvents, trialId, maxEvents]);
}, [
eventsData,
refreshKey,
realtimeEvents,
trialId,
maxEvents,
convertWebSocketEvent,
]);
// Auto-scroll to bottom when new events arrive
useEffect(() => {
@@ -256,41 +328,87 @@ export function EventsLog({
);
};
const formatEventData = (eventType: string, data: any) => {
const formatEventData = (
eventType: string,
data: Record<string, unknown> | null,
): string | null => {
if (!data) return null;
const str = (k: string): string | undefined => {
const v = data[k];
return typeof v === "string" ? v : undefined;
};
const num = (k: string): number | undefined => {
const v = data[k];
return typeof v === "number" ? v : undefined;
};
switch (eventType) {
case "step_transition":
return `Step ${data.from_step + 1} → Step ${data.to_step + 1}${data.step_name ? `: ${data.step_name}` : ""}`;
case "step_transition": {
const fromIdx = num("from_step");
const toIdx = num("to_step");
const stepName = str("step_name");
if (typeof toIdx === "number") {
const fromLabel =
typeof fromIdx === "number" ? `${fromIdx + 1}` : "";
const nameLabel = stepName ? `: ${stepName}` : "";
return `Step ${fromLabel}${toIdx + 1}${nameLabel}`;
}
return "Step changed";
}
case "wizard_action":
return `${data.action_type ? data.action_type.replace(/_/g, " ") : "Action executed"}${data.step_name ? ` in ${data.step_name}` : ""}`;
case "wizard_action": {
const actionType = str("action_type");
const stepName = str("step_name");
const actionLabel = actionType
? actionType.replace(/_/g, " ")
: "Action executed";
const inStep = stepName ? ` in ${stepName}` : "";
return `${actionLabel}${inStep}`;
}
case "robot_action":
return `${data.action_name || "Robot action"}${data.parameters ? ` with parameters` : ""}`;
case "robot_action": {
const actionName = str("action_name") ?? "Robot action";
const hasParams =
typeof data.parameters !== "undefined" && data.parameters !== null;
return `${actionName}${hasParams ? " with parameters" : ""}`;
}
case "emergency_action":
return `Emergency: ${data.emergency_type ? data.emergency_type.replace(/_/g, " ") : "Unknown"}`;
case "emergency_action": {
const emergency = str("emergency_type");
return `Emergency: ${
emergency ? emergency.replace(/_/g, " ") : "Unknown"
}`;
}
case "recording_control":
return `Recording ${data.action === "start_recording" ? "started" : "stopped"}`;
case "recording_control": {
const action = str("action");
return `Recording ${action === "start_recording" ? "started" : "stopped"}`;
}
case "video_control":
return `Video ${data.action === "video_on" ? "enabled" : "disabled"}`;
case "video_control": {
const action = str("action");
return `Video ${action === "video_on" ? "enabled" : "disabled"}`;
}
case "audio_control":
return `Audio ${data.action === "audio_on" ? "enabled" : "disabled"}`;
case "audio_control": {
const action = str("action");
return `Audio ${action === "audio_on" ? "enabled" : "disabled"}`;
}
case "wizard_intervention":
case "wizard_intervention": {
return (
data.content || data.intervention_type || "Intervention recorded"
str("content") ?? str("intervention_type") ?? "Intervention recorded"
);
}
default:
if (typeof data === "string") return data;
if (data.message) return data.message;
if (data.description) return data.description;
default: {
const message = str("message");
if (message) return message;
const description = str("description");
if (description) return description;
return null;
}
}
};
@@ -305,7 +423,8 @@ export function EventsLog({
if (
index === 0 ||
Math.abs(
event.timestamp.getTime() - (events[index - 1]?.timestamp.getTime() ?? 0),
event.timestamp.getTime() -
(events[index - 1]?.timestamp.getTime() ?? 0),
) > 30000
) {
groups.push([event]);
@@ -317,7 +436,7 @@ export function EventsLog({
[],
);
const uniqueEventTypes = Array.from(new Set(events.map((e) => e.eventType)));
// uniqueEventTypes removed (unused)
if (isLoading) {
return (
@@ -433,9 +552,11 @@ export function EventsLog({
</div>
<div className="h-px flex-1 bg-slate-200"></div>
<div className="text-xs text-slate-400">
{group[0] ? formatDistanceToNow(group[0].timestamp, {
addSuffix: true,
}) : ""}
{group[0]
? formatDistanceToNow(group[0].timestamp, {
addSuffix: true,
})
: ""}
</div>
</div>
@@ -503,20 +624,22 @@ export function EventsLog({
{event.notes && (
<p className="mt-1 text-xs text-slate-500 italic">
"{event.notes}"
{event.notes}
</p>
)}
{event.data && Object.keys(event.data).length > 0 && (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-blue-600 hover:text-blue-800">
View details
</summary>
<pre className="mt-1 overflow-x-auto rounded border bg-white p-2 text-xs text-slate-600">
{JSON.stringify(event.data, null, 2)}
</pre>
</details>
)}
{event.data &&
typeof event.data === "object" &&
Object.keys(event.data).length > 0 && (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-blue-600 hover:text-blue-800">
View details
</summary>
<pre className="mt-1 overflow-x-auto rounded border bg-white p-2 text-xs text-slate-600">
{JSON.stringify(event.data, null, 2)}
</pre>
</details>
)}
</div>
<div className="flex-shrink-0 text-xs text-slate-400">

View File

@@ -17,6 +17,7 @@ import {
User,
} from "lucide-react";
import Link from "next/link";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { Badge } from "~/components/ui/badge";
@@ -106,13 +107,19 @@ const statusConfig = {
};
function TrialActionsCell({ trial }: { trial: Trial }) {
const startTrialMutation = api.trials.start.useMutation();
const completeTrialMutation = api.trials.complete.useMutation();
const abortTrialMutation = api.trials.abort.useMutation();
// const deleteTrialMutation = api.trials.delete.useMutation();
const handleDelete = async () => {
if (
window.confirm(`Are you sure you want to delete trial "${trial.name}"?`)
) {
try {
// Delete trial functionality not yet implemented
toast.success("Trial deleted successfully");
// await deleteTrialMutation.mutateAsync({ id: trial.id });
toast.success("Trial deletion not yet implemented");
// window.location.reload();
} catch {
toast.error("Failed to delete trial");
}
@@ -124,14 +131,22 @@ function TrialActionsCell({ trial }: { trial: Trial }) {
toast.success("Trial ID copied to clipboard");
};
const handleStartTrial = () => {
window.location.href = `/trials/${trial.id}/wizard`;
const handleStartTrial = async () => {
try {
await startTrialMutation.mutateAsync({ id: trial.id });
toast.success("Trial started successfully");
window.location.href = `/trials/${trial.id}/wizard`;
} catch {
toast.error("Failed to start trial");
}
};
const handlePauseTrial = async () => {
try {
// Pause trial functionality not yet implemented
toast.success("Trial paused");
// For now, pausing means completing the trial
await completeTrialMutation.mutateAsync({ id: trial.id });
toast.success("Trial paused/completed");
window.location.reload();
} catch {
toast.error("Failed to pause trial");
}
@@ -140,8 +155,9 @@ function TrialActionsCell({ trial }: { trial: Trial }) {
const handleStopTrial = async () => {
if (window.confirm("Are you sure you want to stop this trial?")) {
try {
// Stop trial functionality not yet implemented
await abortTrialMutation.mutateAsync({ id: trial.id });
toast.success("Trial stopped");
window.location.reload();
} catch {
toast.error("Failed to stop trial");
}

View File

@@ -180,12 +180,18 @@ export function TrialsDataTable() {
<div className="space-y-6">
<PageHeader
title="Trials"
description="Monitor and manage trial execution for your HRI experiments"
description="Schedule and manage trials for your HRI studies"
icon={TestTube}
actions={
<ActionButton href="/trials/new">
<ActionButton
href={
selectedStudyId
? `/studies/${selectedStudyId}/trials/new`
: "/trials/new"
}
>
<Plus className="mr-2 h-4 w-4" />
New Trial
Schedule Trial
</ActionButton>
}
/>
@@ -210,12 +216,18 @@ export function TrialsDataTable() {
<div className="space-y-6">
<PageHeader
title="Trials"
description="Monitor and manage trial execution for your HRI experiments"
description="Schedule and manage trials for your HRI studies"
icon={TestTube}
actions={
<ActionButton href="/trials/new">
<ActionButton
href={
selectedStudyId
? `/studies/${selectedStudyId}/trials/new`
: "/trials/new"
}
>
<Plus className="mr-2 h-4 w-4" />
New Trial
Schedule Trial
</ActionButton>
}
/>

View File

@@ -1,43 +1,62 @@
"use client";
import {
AlertTriangle, Camera, Clock, Hand, HelpCircle, Lightbulb, MessageSquare, Pause,
Play,
RotateCcw, Target, Video,
VideoOff, Volume2,
VolumeX, Zap
AlertTriangle,
Camera,
Clock,
Hand,
HelpCircle,
Lightbulb,
MessageSquare,
Pause,
RotateCcw,
Target,
Video,
VideoOff,
Volume2,
VolumeX,
Zap,
} from "lucide-react";
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { Label } from "~/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Textarea } from "~/components/ui/textarea";
interface ActionControlsProps {
trialId: string;
currentStep: {
id: string;
name: string;
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional_branch";
parameters?: any;
actions?: any[];
type:
| "wizard_action"
| "robot_action"
| "parallel_steps"
| "conditional_branch";
description?: string;
parameters?: Record<string, unknown>;
duration?: number;
} | null;
onExecuteAction: (actionType: string, actionData: any) => Promise<void>;
trialId: string;
onActionComplete: (
actionId: string,
actionData: Record<string, unknown>,
) => void;
isConnected: boolean;
}
interface QuickAction {
@@ -50,7 +69,12 @@ interface QuickAction {
requiresConfirmation?: boolean;
}
export function ActionControls({ currentStep, onExecuteAction, trialId }: ActionControlsProps) {
export function ActionControls({
trialId: _trialId,
currentStep,
onActionComplete,
isConnected: _isConnected,
}: ActionControlsProps) {
const [isRecording, setIsRecording] = useState(false);
const [isVideoOn, setIsVideoOn] = useState(true);
const [isAudioOn, setIsAudioOn] = useState(true);
@@ -119,82 +143,71 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
{ value: "cut_power", label: "Emergency Power Cut" },
];
const handleQuickAction = async (action: QuickAction) => {
const handleQuickAction = (action: QuickAction) => {
if (action.requiresConfirmation) {
setShowEmergencyDialog(true);
return;
}
try {
await onExecuteAction(action.action, {
action_id: action.id,
step_id: currentStep?.id,
timestamp: new Date().toISOString(),
});
} catch (_error) {
console.error(`Failed to execute ${action.action}:`, _error);
}
onActionComplete(action.id, {
action_type: action.action,
notes: action.description,
timestamp: new Date().toISOString(),
});
};
const handleEmergencyAction = async () => {
const handleEmergencyAction = () => {
if (!selectedEmergencyAction) return;
try {
await onExecuteAction("emergency_action", {
emergency_type: selectedEmergencyAction,
step_id: currentStep?.id,
timestamp: new Date().toISOString(),
severity: "high",
});
setShowEmergencyDialog(false);
setSelectedEmergencyAction("");
} catch (_error) {
console.error("Failed to execute emergency action:", _error);
}
onActionComplete("emergency_action", {
emergency_type: selectedEmergencyAction,
notes: interventionNote || "Emergency action executed",
timestamp: new Date().toISOString(),
});
setShowEmergencyDialog(false);
setSelectedEmergencyAction("");
setInterventionNote("");
};
const handleInterventionSubmit = async () => {
const handleInterventionSubmit = () => {
if (!interventionNote.trim()) return;
try {
await onExecuteAction("wizard_intervention", {
intervention_type: "note",
content: interventionNote,
step_id: currentStep?.id,
timestamp: new Date().toISOString(),
});
setInterventionNote("");
setIsCommunicationOpen(false);
} catch (_error) {
console.error("Failed to submit intervention:", _error);
}
onActionComplete("wizard_intervention", {
intervention_type: "note",
content: interventionNote,
timestamp: new Date().toISOString(),
});
setInterventionNote("");
setIsCommunicationOpen(false);
};
const toggleRecording = async () => {
const toggleRecording = () => {
const newState = !isRecording;
setIsRecording(newState);
await onExecuteAction("recording_control", {
onActionComplete("recording_control", {
action: newState ? "start_recording" : "stop_recording",
timestamp: new Date().toISOString(),
});
};
const toggleVideo = async () => {
const toggleVideo = () => {
const newState = !isVideoOn;
setIsVideoOn(newState);
await onExecuteAction("video_control", {
onActionComplete("video_control", {
action: newState ? "video_on" : "video_off",
timestamp: new Date().toISOString(),
});
};
const toggleAudio = async () => {
const toggleAudio = () => {
const newState = !isAudioOn;
setIsAudioOn(newState);
await onExecuteAction("audio_control", {
onActionComplete("audio_control", {
action: newState ? "audio_on" : "audio_off",
timestamp: new Date().toISOString(),
});
@@ -217,7 +230,9 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
onClick={toggleRecording}
className="flex items-center space-x-2"
>
<div className={`w-2 h-2 rounded-full ${isRecording ? "bg-white animate-pulse" : "bg-red-500"}`}></div>
<div
className={`h-2 w-2 rounded-full ${isRecording ? "animate-pulse" : ""}`}
></div>
<span>{isRecording ? "Stop Recording" : "Start Recording"}</span>
</Button>
@@ -226,7 +241,11 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
onClick={toggleVideo}
className="flex items-center space-x-2"
>
{isVideoOn ? <Video className="h-4 w-4" /> : <VideoOff className="h-4 w-4" />}
{isVideoOn ? (
<Video className="h-4 w-4" />
) : (
<VideoOff className="h-4 w-4" />
)}
<span>Video</span>
</Button>
@@ -235,7 +254,11 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
onClick={toggleAudio}
className="flex items-center space-x-2"
>
{isAudioOn ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
{isAudioOn ? (
<Volume2 className="h-4 w-4" />
) : (
<VolumeX className="h-4 w-4" />
)}
<span>Audio</span>
</Button>
@@ -265,15 +288,18 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
<Button
key={action.id}
variant={
action.type === "emergency" ? "destructive" :
action.type === "primary" ? "default" : "outline"
action.type === "emergency"
? "destructive"
: action.type === "primary"
? "default"
: "outline"
}
onClick={() => handleQuickAction(action)}
className="flex items-center justify-start space-x-3 h-12"
className="flex h-12 items-center justify-start space-x-3"
>
<action.icon className="h-4 w-4 flex-shrink-0" />
<div className="flex-1 text-left">
<div className="font-medium">{action.label}</div>
<h4 className="font-medium">{action.label}</h4>
<div className="text-xs opacity-75">{action.description}</div>
</div>
</Button>
@@ -293,29 +319,14 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="text-sm text-slate-600">
Current step: <span className="font-medium">{currentStep.name}</span>
<div className="text-muted-foreground text-sm">
Current step:{" "}
<span className="font-medium">{currentStep.name}</span>
</div>
{currentStep.actions && currentStep.actions.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Available Actions:</Label>
<div className="grid gap-2">
{currentStep.actions.map((action: any, index: number) => (
<Button
key={action.id || index}
variant="outline"
size="sm"
onClick={() => onExecuteAction(`step_action_${action.id}`, action)}
className="justify-start text-left"
>
<Play className="h-3 w-3 mr-2" />
{action.name}
</Button>
))}
</div>
</div>
)}
<div className="text-muted-foreground text-xs">
Use the controls below to execute wizard actions for this step.
</div>
</div>
</CardContent>
</Card>
@@ -343,8 +354,8 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
/>
</div>
<div className="flex items-center space-x-2">
<Clock className="h-4 w-4 text-slate-500" />
<span className="text-sm text-slate-500">
<Clock className="h-4 w-4" />
<span className="text-muted-foreground text-sm">
{new Date().toLocaleTimeString()}
</span>
</div>
@@ -370,18 +381,22 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
<Dialog open={showEmergencyDialog} onOpenChange={setShowEmergencyDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center space-x-2 text-red-600">
<DialogTitle className="flex items-center space-x-2">
<AlertTriangle className="h-5 w-5" />
<span>Emergency Action Required</span>
</DialogTitle>
<DialogDescription>
Select the type of emergency action to perform. This will immediately stop or override current robot operations.
Select the type of emergency action to perform. This will
immediately stop or override current robot operations.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="emergency-select">Emergency Action Type</Label>
<Select value={selectedEmergencyAction} onValueChange={setSelectedEmergencyAction}>
<Select
value={selectedEmergencyAction}
onValueChange={setSelectedEmergencyAction}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select emergency action..." />
</SelectTrigger>
@@ -394,11 +409,13 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
</SelectContent>
</Select>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<div className="rounded-lg border p-3">
<div className="flex items-start space-x-2">
<AlertTriangle className="h-4 w-4 text-red-600 mt-0.5 flex-shrink-0" />
<div className="text-sm text-red-800">
<strong>Warning:</strong> Emergency actions will immediately halt all robot operations and may require manual intervention to resume.
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0" />
<div className="text-sm">
<strong>Warning:</strong> Emergency actions will immediately
halt all robot operations and may require manual intervention
to resume.
</div>
</div>
</div>

View File

@@ -0,0 +1,151 @@
"use client";
import { Clock, Activity, User, Bot, AlertCircle } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Badge } from "~/components/ui/badge";
import { useEffect, useState } from "react";
import type { WebSocketMessage } from "~/hooks/useWebSocket";
interface EventsLogSidebarProps {
events: WebSocketMessage[];
maxEvents?: number;
showTimestamps?: boolean;
}
const getEventIcon = (eventType: string) => {
switch (eventType) {
case "trial_status":
case "trial_action_executed":
return Activity;
case "step_changed":
return Clock;
case "wizard_intervention":
case "intervention_logged":
return User;
case "robot_action":
return Bot;
case "error":
return AlertCircle;
default:
return Activity;
}
};
const getEventVariant = (eventType: string) => {
switch (eventType) {
case "error":
return "destructive" as const;
case "wizard_intervention":
case "intervention_logged":
return "secondary" as const;
case "trial_status":
return "default" as const;
default:
return "outline" as const;
}
};
const formatEventData = (event: WebSocketMessage): string => {
switch (event.type) {
case "trial_status":
const trialData = event.data as { trial: { status: string } };
return `Trial status: ${trialData.trial.status}`;
case "step_changed":
const stepData = event.data as {
to_step: number;
step_name?: string;
};
return `Step ${stepData.to_step + 1}${stepData.step_name ? `: ${stepData.step_name}` : ""}`;
case "trial_action_executed":
const actionData = event.data as { action_type: string };
return `Action: ${actionData.action_type}`;
case "wizard_intervention":
case "intervention_logged":
const interventionData = event.data as { content?: string };
return interventionData.content ?? "Wizard intervention";
case "error":
const errorData = event.data as { message?: string };
return errorData.message ?? "System error";
default:
return `Event: ${event.type}`;
}
};
const getEventTimestamp = (event: WebSocketMessage): Date => {
const data = event.data as { timestamp?: number };
return data.timestamp ? new Date(data.timestamp) : new Date();
};
export function EventsLogSidebar({
events,
maxEvents = 10,
showTimestamps = true,
}: EventsLogSidebarProps) {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
const displayEvents = events.slice(-maxEvents).reverse();
if (displayEvents.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Clock className="text-muted-foreground mb-3 h-8 w-8" />
<p className="text-muted-foreground text-sm">No events yet</p>
<p className="text-muted-foreground mt-1 text-xs">
Events will appear here during trial execution
</p>
</div>
);
}
return (
<ScrollArea className="h-64">
<div className="space-y-3">
{displayEvents.map((event, index) => {
const Icon = getEventIcon(event.type);
const timestamp = getEventTimestamp(event);
const eventText = formatEventData(event);
return (
<div key={index} className="flex items-start gap-3">
<div className="mt-0.5 flex-shrink-0">
<div className="bg-muted rounded-full p-1.5">
<Icon className="h-3 w-3" />
</div>
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<Badge
variant={getEventVariant(event.type)}
className="text-xs"
>
{event.type.replace(/_/g, " ")}
</Badge>
{showTimestamps && isClient && (
<span className="text-muted-foreground text-xs">
{formatDistanceToNow(timestamp, { addSuffix: true })}
</span>
)}
</div>
<p className="text-foreground text-sm break-words">
{eventText}
</p>
</div>
</div>
);
})}
</div>
</ScrollArea>
);
}

View File

@@ -0,0 +1,330 @@
"use client";
import { CheckCircle, Clock, PlayCircle, AlertCircle, Eye } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Progress } from "~/components/ui/progress";
import { Alert, AlertDescription } from "~/components/ui/alert";
interface ActionDefinition {
id: string;
stepId: string;
name: string;
description?: string;
type: string;
orderIndex: number;
parameters: Record<string, unknown>;
timeout?: number;
required: boolean;
condition?: string;
}
interface StepDefinition {
id: string;
name: string;
description?: string;
type: string;
orderIndex: number;
condition?: string;
actions: ActionDefinition[];
}
interface ExecutionContext {
trialId: string;
experimentId: string;
participantId: string;
wizardId?: string;
currentStepIndex: number;
startTime: Date;
variables: Record<string, unknown>;
}
interface ExecutionStepDisplayProps {
currentStep: StepDefinition | null;
executionContext: ExecutionContext | null;
totalSteps: number;
onExecuteStep: () => void;
onAdvanceStep: () => void;
onCompleteWizardAction: (
actionId: string,
data?: Record<string, unknown>,
) => void;
isExecuting: boolean;
}
export function ExecutionStepDisplay({
currentStep,
executionContext,
totalSteps,
onExecuteStep,
onAdvanceStep,
onCompleteWizardAction,
isExecuting,
}: ExecutionStepDisplayProps) {
if (!currentStep || !executionContext) {
return (
<Card className="shadow-sm">
<CardContent className="p-6 text-center">
<div className="text-muted-foreground">
<Clock className="mx-auto mb-2 h-8 w-8 opacity-50" />
<p className="text-sm">No active step</p>
<p className="mt-1 text-xs">
Trial may not be started or all steps completed
</p>
</div>
</CardContent>
</Card>
);
}
const progress =
totalSteps > 0
? ((executionContext.currentStepIndex + 1) / totalSteps) * 100
: 0;
const getActionConfig = (
type: string,
): { icon: typeof PlayCircle; label: string } => {
const configs: Record<string, { icon: typeof PlayCircle; label: string }> =
{
wizard_say: {
icon: PlayCircle,
label: "Wizard Speech",
},
wizard_gesture: {
icon: PlayCircle,
label: "Wizard Gesture",
},
wizard_show_object: {
icon: Eye,
label: "Show Object",
},
observe_behavior: {
icon: Eye,
label: "Observe Behavior",
},
wait: { icon: Clock, label: "Wait" },
};
return (
configs[type] ?? {
icon: PlayCircle,
label: "Action",
}
);
};
const getWizardInstructions = (action: ActionDefinition): string => {
switch (action.type) {
case "wizard_say":
return `Say: "${String(action.parameters.text) ?? "Please speak to the participant"}";`;
case "wizard_gesture":
return `Perform gesture: ${String(action.parameters.gesture) ?? "as specified in the protocol"}`;
case "wizard_show_object":
return `Show object: ${String(action.parameters.object) ?? "as specified in the protocol"}`;
case "observe_behavior":
return `Observe and record: ${String(action.parameters.behavior) ?? "participant behavior"}`;
case "wait":
return `Wait for ${String(action.parameters.duration) ?? "1000"}ms`;
default:
return `Execute: ${action.name ?? "Unknown Action"}`;
}
};
const requiresWizardInput = (action: ActionDefinition): boolean => {
return [
"wizard_say",
"wizard_gesture",
"wizard_show_object",
"observe_behavior",
].includes(action.type);
};
return (
<div className="space-y-4">
{/* Step Progress */}
<Card className="shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-semibold">
Step {executionContext.currentStepIndex + 1} of {totalSteps}
</CardTitle>
<Badge variant="outline" className="text-xs">
{Math.round(progress)}% Complete
</Badge>
</div>
<Progress value={progress} className="h-2" />
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2">
<h3 className="font-medium">{currentStep.name}</h3>
{currentStep.description && (
<p className="text-muted-foreground text-sm">
{currentStep.description}
</p>
)}
<div className="flex items-center space-x-2">
<Badge variant="secondary" className="text-xs">
{currentStep.type
.replace(/_/g, " ")
.replace(/\b\w/g, (l) => l.toUpperCase())}
</Badge>
<span className="text-muted-foreground text-xs">
{currentStep.actions.length} action
{currentStep.actions.length !== 1 ? "s" : ""}
</span>
</div>
</div>
</CardContent>
</Card>
{/* Step Actions */}
<Card className="shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-medium">
Step Actions
</CardTitle>
<Button
onClick={onExecuteStep}
disabled={isExecuting}
size="sm"
className="h-8"
>
<PlayCircle className="mr-1 h-3 w-3" />
{isExecuting ? "Executing..." : "Execute Step"}
</Button>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-3">
{currentStep.actions?.map((action, _index) => {
const config = getActionConfig(action.type);
const Icon = config.icon;
const needsWizardInput = requiresWizardInput(action);
return (
<div key={action.id} className="rounded-lg border p-3">
<div className="flex items-start justify-between">
<div className="flex-1 space-y-2">
<div className="flex items-center space-x-2">
<Icon className="h-4 w-4" />
<span className="text-sm font-medium">
{action.name}
</span>
<Badge variant="outline" className="text-xs">
{config.label}
</Badge>
{action.required && (
<Badge variant="destructive" className="text-xs">
Required
</Badge>
)}
</div>
{action.description && (
<p className="text-muted-foreground ml-6 text-xs">
{action.description}
</p>
)}
{needsWizardInput && (
<Alert className="mt-2 ml-6">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
{getWizardInstructions(action)}
</AlertDescription>
</Alert>
)}
{/* Action Parameters */}
{Object.keys(action.parameters).length > 0 && (
<div className="mt-2 ml-6">
<details className="text-xs">
<summary className="text-muted-foreground cursor-pointer">
Parameters (
{Object.keys(action.parameters).length})
</summary>
<div className="mt-1 space-y-1">
{Object.entries(action.parameters).map(
([key, value]) => (
<div
key={key}
className="flex justify-between text-xs"
>
<span className="text-muted-foreground font-mono">
{key}:
</span>
<span className="font-mono">
{typeof value === "string"
? `"${value}"`
: String(value)}
</span>
</div>
),
)}
</div>
</details>
</div>
)}
</div>
{needsWizardInput && (
<Button
onClick={() => onCompleteWizardAction(action.id, {})}
size="sm"
variant="outline"
className="h-7 text-xs"
>
<CheckCircle className="mr-1 h-3 w-3" />
Complete
</Button>
)}
</div>
</div>
);
})}
</div>
{/* Step Controls */}
<div className="mt-4 flex justify-end space-x-2">
<Button
onClick={onAdvanceStep}
variant="outline"
size="sm"
disabled={isExecuting}
>
Next Step
</Button>
</div>
</CardContent>
</Card>
{/* Execution Variables (if any) */}
{Object.keys(executionContext.variables).length > 0 && (
<Card className="shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base font-medium">
Execution Variables
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-1">
{Object.entries(executionContext.variables).map(
([key, value]) => (
<div key={key} className="flex justify-between text-xs">
<span className="font-mono text-slate-600">{key}:</span>
<span className="font-mono text-slate-900">
{typeof value === "string" ? `"${value}"` : String(value)}
</span>
</div>
),
)}
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -1,43 +1,41 @@
"use client";
import {
Briefcase, Clock, GraduationCap, Info, Mail, Shield, User
} from "lucide-react";
import { Briefcase, Clock, GraduationCap, Info, Shield } from "lucide-react";
import { Avatar, AvatarFallback } from "~/components/ui/avatar";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
interface ParticipantInfoProps {
participant: {
id: string;
participantCode: string;
email: string | null;
name: string | null;
demographics: any;
demographics: Record<string, unknown> | null;
};
trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
}
export function ParticipantInfo({ participant }: ParticipantInfoProps) {
const demographics = participant.demographics || {};
export function ParticipantInfo({
participant,
trialStatus: _trialStatus,
}: ParticipantInfoProps) {
const demographics = participant.demographics ?? {};
// Extract common demographic fields
const age = demographics.age;
const gender = demographics.gender;
const occupation = demographics.occupation;
const education = demographics.education;
const language = demographics.primaryLanguage || demographics.language;
const location = demographics.location || demographics.city;
const experience = demographics.robotExperience || demographics.experience;
const age = demographics.age as string | number | undefined;
const gender = demographics.gender as string | undefined;
const occupation = demographics.occupation as string | undefined;
const education = demographics.education as string | undefined;
const language =
(demographics.primaryLanguage as string | undefined) ??
(demographics.language as string | undefined);
const experience =
(demographics.robotExperience as string | undefined) ??
(demographics.experience as string | undefined);
// Get participant initials for avatar
const getInitials = () => {
if (participant.name) {
const nameParts = participant.name.split(" ");
return nameParts.map((part) => part.charAt(0).toUpperCase()).join("");
}
return participant.participantCode.substring(0, 2).toUpperCase();
};
const formatDemographicValue = (key: string, value: any) => {
const formatDemographicValue = (key: string, value: unknown) => {
if (value === null || value === undefined || value === "") return null;
// Handle different data types
@@ -53,81 +51,64 @@ export function ParticipantInfo({ participant }: ParticipantInfoProps) {
return JSON.stringify(value);
}
return String(value);
return typeof value === "string" ? value : JSON.stringify(value);
};
return (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<User className="h-4 w-4 text-slate-600" />
<h3 className="font-medium text-slate-900">Participant</h3>
</div>
{/* Basic Info Card */}
<Card className="shadow-sm">
<CardContent className="p-4">
<div className="flex items-start space-x-3">
<Avatar className="h-10 w-10">
<AvatarFallback className="bg-blue-100 font-medium text-blue-600">
{getInitials()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-slate-900">
{participant.name || "Anonymous"}
</div>
<div className="text-sm text-slate-600">
ID: {participant.participantCode}
</div>
{participant.email && (
<div className="mt-1 flex items-center space-x-1 text-xs text-slate-500">
<Mail className="h-3 w-3" />
<span className="truncate">{participant.email}</span>
</div>
)}
{/* Basic Info */}
<div className="rounded-lg border p-4">
<div className="flex items-start space-x-3">
<Avatar className="h-10 w-10">
<AvatarFallback className="font-medium">
{getInitials()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-slate-900">
Participant {participant.participantCode}
</div>
<div className="text-sm text-slate-600">
ID: {participant.participantCode}
</div>
</div>
</CardContent>
</Card>
</div>
</div>
{/* Quick Demographics */}
{(age || gender || language) && (
<Card className="shadow-sm">
<CardContent className="p-4">
<div className="grid grid-cols-1 gap-2 text-sm">
{age && (
<div className="flex items-center justify-between">
<span className="text-slate-600">Age:</span>
<span className="font-medium">{age}</span>
</div>
)}
{gender && (
<div className="flex items-center justify-between">
<span className="text-slate-600">Gender:</span>
<span className="font-medium capitalize">{gender}</span>
</div>
)}
{language && (
<div className="flex items-center justify-between">
<span className="text-slate-600">Language:</span>
<span className="font-medium">{language}</span>
</div>
)}
</div>
</CardContent>
</Card>
{(age ?? gender ?? language) && (
<div className="rounded-lg border p-4">
<div className="grid grid-cols-1 gap-2 text-sm">
{age && (
<div className="flex items-center justify-between">
<span className="text-slate-600">Age:</span>
<span className="font-medium">{age}</span>
</div>
)}
{gender && (
<div className="flex items-center justify-between">
<span className="text-slate-600">Gender:</span>
<span className="font-medium capitalize">{gender}</span>
</div>
)}
{language && (
<div className="flex items-center justify-between">
<span className="text-slate-600">Language:</span>
<span className="font-medium">{language}</span>
</div>
)}
</div>
</div>
)}
{/* Background Info */}
{(occupation || education || experience) && (
<Card className="shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="flex items-center space-x-1 text-sm font-medium text-slate-700">
<Info className="h-3 w-3" />
<span>Background</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 pt-0">
{(occupation ?? education ?? experience) && (
<div className="rounded-lg border p-4">
<div className="mb-3 flex items-center space-x-1 text-sm font-medium text-slate-700">
<Info className="h-3 w-3" />
<span>Background</span>
</div>
<div className="space-y-2">
{occupation && (
<div className="flex items-start space-x-2 text-sm">
<Briefcase className="mt-0.5 h-3 w-3 flex-shrink-0 text-slate-400" />
@@ -155,19 +136,17 @@ export function ParticipantInfo({ participant }: ParticipantInfoProps) {
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
)}
{/* Additional Demographics */}
{Object.keys(demographics).length > 0 && (
<Card className="shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-700">
Additional Info
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="rounded-lg border p-4">
<div className="mb-3 text-sm font-medium text-slate-700">
Additional Info
</div>
<div>
<div className="space-y-1">
{Object.entries(demographics)
.filter(
@@ -211,30 +190,26 @@ export function ParticipantInfo({ participant }: ParticipantInfoProps) {
);
})}
</div>
</CardContent>
</Card>
</div>
</div>
)}
{/* Consent Status */}
<Card className="border-green-200 bg-green-50 shadow-sm">
<CardContent className="p-3">
<div className="flex items-center space-x-2">
<div className="h-2 w-2 rounded-full bg-green-500"></div>
<span className="text-sm font-medium text-green-800">
Consent Verified
</span>
</div>
<div className="mt-1 text-xs text-green-600">
Participant has provided informed consent
</div>
</CardContent>
</Card>
<div className="rounded-lg border p-3">
<div className="flex items-center space-x-2">
<div className="h-2 w-2 rounded-full bg-green-500"></div>
<span className="text-sm font-medium">Consent Verified</span>
</div>
<div className="text-muted-foreground mt-1 text-xs">
Participant has provided informed consent
</div>
</div>
{/* Session Info */}
<div className="space-y-1 text-xs text-slate-500">
<div className="flex items-center space-x-1">
<Clock className="h-3 w-3" />
<span>Session started: {new Date().toLocaleTimeString()}</span>
<span>Session active</span>
</div>
</div>
</div>

View File

@@ -1,18 +1,25 @@
"use client";
import {
Activity, AlertTriangle, Battery,
BatteryLow, Bot, CheckCircle,
Clock, RefreshCw, Signal,
SignalHigh,
SignalLow,
SignalMedium, WifiOff
Activity,
AlertTriangle,
Battery,
BatteryLow,
Bot,
CheckCircle,
Clock,
RefreshCw,
Signal,
SignalHigh,
SignalLow,
SignalMedium,
WifiOff,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Progress } from "~/components/ui/progress";
interface RobotStatusProps {
@@ -37,10 +44,10 @@ interface RobotStatus {
z?: number;
orientation?: number;
};
sensors?: Record<string, any>;
sensors?: Record<string, string>;
}
export function RobotStatus({ trialId }: RobotStatusProps) {
export function RobotStatus({ trialId: _trialId }: RobotStatusProps) {
const [robotStatus, setRobotStatus] = useState<RobotStatus | null>(null);
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
const [refreshing, setRefreshing] = useState(false);
@@ -62,32 +69,43 @@ export function RobotStatus({ trialId }: RobotStatusProps) {
position: {
x: 1.2,
y: 0.8,
orientation: 45
orientation: 45,
},
sensors: {
lidar: "operational",
camera: "operational",
imu: "operational",
odometry: "operational"
}
odometry: "operational",
},
};
setRobotStatus(mockStatus);
// Simulate periodic updates
const interval = setInterval(() => {
setRobotStatus(prev => {
setRobotStatus((prev) => {
if (!prev) return prev;
return {
...prev,
batteryLevel: Math.max(0, (prev.batteryLevel || 0) - Math.random() * 0.5),
signalStrength: Math.max(0, Math.min(100, (prev.signalStrength || 0) + (Math.random() - 0.5) * 10)),
batteryLevel: Math.max(
0,
(prev.batteryLevel ?? 0) - Math.random() * 0.5,
),
signalStrength: Math.max(
0,
Math.min(
100,
(prev.signalStrength ?? 0) + (Math.random() - 0.5) * 10,
),
),
lastHeartbeat: new Date(),
position: prev.position ? {
...prev.position,
x: prev.position.x + (Math.random() - 0.5) * 0.1,
y: prev.position.y + (Math.random() - 0.5) * 0.1,
} : undefined
position: prev.position
? {
...prev.position,
x: prev.position.x + (Math.random() - 0.5) * 0.1,
y: prev.position.y + (Math.random() - 0.5) * 0.1,
}
: undefined,
};
});
setLastUpdate(new Date());
@@ -103,35 +121,35 @@ export function RobotStatus({ trialId }: RobotStatusProps) {
icon: CheckCircle,
color: "text-green-600",
bgColor: "bg-green-100",
label: "Connected"
label: "Connected",
};
case "connecting":
return {
icon: RefreshCw,
color: "text-blue-600",
bgColor: "bg-blue-100",
label: "Connecting"
label: "Connecting",
};
case "disconnected":
return {
icon: WifiOff,
color: "text-gray-600",
bgColor: "bg-gray-100",
label: "Disconnected"
label: "Disconnected",
};
case "error":
return {
icon: AlertTriangle,
color: "text-red-600",
bgColor: "bg-red-100",
label: "Error"
label: "Error",
};
default:
return {
icon: WifiOff,
color: "text-gray-600",
bgColor: "bg-gray-100",
label: "Unknown"
label: "Unknown",
};
}
};
@@ -159,182 +177,173 @@ export function RobotStatus({ trialId }: RobotStatusProps) {
if (!robotStatus) {
return (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Bot className="h-4 w-4 text-slate-600" />
<h3 className="font-medium text-slate-900">Robot Status</h3>
<div className="rounded-lg border p-4 text-center">
<div className="text-slate-500">
<Bot className="mx-auto mb-2 h-8 w-8 opacity-50" />
<p className="text-sm">No robot connected</p>
</div>
</div>
<Card className="shadow-sm">
<CardContent className="p-4 text-center">
<div className="text-slate-500">
<Bot className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No robot connected</p>
</div>
</CardContent>
</Card>
</div>
);
}
const statusConfig = getConnectionStatusConfig(robotStatus.connectionStatus);
const StatusIcon = statusConfig.icon;
const SignalIcon = getSignalIcon(robotStatus.signalStrength || 0);
const BatteryIcon = getBatteryIcon(robotStatus.batteryLevel || 0);
const SignalIcon = getSignalIcon(robotStatus.signalStrength ?? 0);
const BatteryIcon = getBatteryIcon(robotStatus.batteryLevel ?? 0);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Bot className="h-4 w-4 text-slate-600" />
<h3 className="font-medium text-slate-900">Robot Status</h3>
</div>
<div className="flex items-center justify-end">
<Button
variant="ghost"
size="sm"
onClick={handleRefreshStatus}
disabled={refreshing}
>
<RefreshCw className={`h-3 w-3 ${refreshing ? 'animate-spin' : ''}`} />
<RefreshCw
className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`}
/>
</Button>
</div>
{/* Main Status Card */}
<Card className="shadow-sm">
<CardContent className="p-4">
<div className="space-y-3">
{/* Robot Info */}
<div className="flex items-center justify-between">
<div className="font-medium text-slate-900">{robotStatus.name}</div>
<Badge className={`${statusConfig.bgColor} ${statusConfig.color}`} variant="secondary">
<StatusIcon className="mr-1 h-3 w-3" />
{statusConfig.label}
</Badge>
</div>
{/* Connection Details */}
<div className="text-sm text-slate-600">
Protocol: {robotStatus.communicationProtocol}
</div>
{/* Status Indicators */}
<div className="grid grid-cols-2 gap-3">
{/* Battery */}
{robotStatus.batteryLevel !== undefined && (
<div className="space-y-1">
<div className="flex items-center space-x-1 text-xs text-slate-600">
<BatteryIcon className={`h-3 w-3 ${
robotStatus.batteryLevel <= 20 ? 'text-red-500' : 'text-green-500'
}`} />
<span>Battery</span>
</div>
<div className="flex items-center space-x-2">
<Progress
value={robotStatus.batteryLevel}
className="flex-1 h-1.5"
/>
<span className="text-xs font-medium w-8">
{Math.round(robotStatus.batteryLevel)}%
</span>
</div>
</div>
)}
{/* Signal Strength */}
{robotStatus.signalStrength !== undefined && (
<div className="space-y-1">
<div className="flex items-center space-x-1 text-xs text-slate-600">
<SignalIcon className="h-3 w-3" />
<span>Signal</span>
</div>
<div className="flex items-center space-x-2">
<Progress
value={robotStatus.signalStrength}
className="flex-1 h-1.5"
/>
<span className="text-xs font-medium w-8">
{Math.round(robotStatus.signalStrength)}%
</span>
</div>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Current Mode */}
<Card className="shadow-sm">
<CardContent className="p-3">
<div className="rounded-lg border p-4">
<div className="space-y-3">
{/* Robot Info */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Activity className="h-3 w-3 text-slate-600" />
<span className="text-sm text-slate-600">Mode:</span>
</div>
<Badge variant="outline" className="text-xs">
{robotStatus.currentMode.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
<div className="font-medium text-slate-900">{robotStatus.name}</div>
<Badge
className={`${statusConfig.bgColor} ${statusConfig.color}`}
variant="secondary"
>
<StatusIcon className="mr-1 h-3 w-3" />
{statusConfig.label}
</Badge>
</div>
{robotStatus.isMoving && (
<div className="flex items-center space-x-1 mt-2 text-xs text-blue-600">
<div className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse"></div>
<span>Robot is moving</span>
</div>
)}
</CardContent>
</Card>
{/* Connection Details */}
<div className="text-sm text-slate-600">
Protocol: {robotStatus.communicationProtocol}
</div>
{/* Status Indicators */}
<div className="grid grid-cols-2 gap-3">
{/* Battery */}
{robotStatus.batteryLevel !== undefined && (
<div className="space-y-1">
<div className="flex items-center space-x-1 text-xs text-slate-600">
<BatteryIcon className="h-3 w-3" />
<span>Battery</span>
</div>
<div className="flex items-center space-x-2">
<Progress
value={robotStatus.batteryLevel}
className="h-1.5 flex-1"
/>
<span className="w-8 text-xs font-medium">
{Math.round(robotStatus.batteryLevel)}%
</span>
</div>
</div>
)}
{/* Signal Strength */}
{robotStatus.signalStrength !== undefined && (
<div className="space-y-1">
<div className="flex items-center space-x-1 text-xs text-slate-600">
<SignalIcon className="h-3 w-3" />
<span>Signal</span>
</div>
<div className="flex items-center space-x-2">
<Progress
value={robotStatus.signalStrength}
className="h-1.5 flex-1"
/>
<span className="w-8 text-xs font-medium">
{Math.round(robotStatus.signalStrength)}%
</span>
</div>
</div>
)}
</div>
</div>
</div>
{/* Current Mode */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Activity className="h-3 w-3 text-slate-600" />
<span className="text-sm text-slate-600">Mode:</span>
</div>
<Badge variant="outline" className="text-xs">
{robotStatus.currentMode
.replace(/_/g, " ")
.replace(/\b\w/g, (l) => l.toUpperCase())}
</Badge>
</div>
{robotStatus.isMoving && (
<div className="mt-2 flex items-center space-x-1 text-xs">
<div className="h-1.5 w-1.5 animate-pulse rounded-full"></div>
<span>Robot is moving</span>
</div>
)}
</div>
{/* Position Info */}
{robotStatus.position && (
<Card className="shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-700">Position</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="rounded-lg border p-4">
<div className="mb-3 text-sm font-medium text-slate-700">
Position
</div>
<div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex justify-between">
<span className="text-slate-600">X:</span>
<span className="font-mono">{robotStatus.position.x.toFixed(2)}m</span>
<span className="font-mono">
{robotStatus.position.x.toFixed(2)}m
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Y:</span>
<span className="font-mono">{robotStatus.position.y.toFixed(2)}m</span>
<span className="font-mono">
{robotStatus.position.y.toFixed(2)}m
</span>
</div>
{robotStatus.position.orientation !== undefined && (
<div className="flex justify-between col-span-2">
<div className="col-span-2 flex justify-between">
<span className="text-slate-600">Orientation:</span>
<span className="font-mono">{Math.round(robotStatus.position.orientation)}°</span>
<span className="font-mono">
{Math.round(robotStatus.position.orientation)}°
</span>
</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
)}
{/* Sensors Status */}
{robotStatus.sensors && (
<Card className="shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-700">Sensors</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="rounded-lg border p-4">
<div className="mb-3 text-sm font-medium text-slate-700">Sensors</div>
<div>
<div className="space-y-1">
{Object.entries(robotStatus.sensors).map(([sensor, status]) => (
<div key={sensor} className="flex items-center justify-between text-xs">
<div
key={sensor}
className="flex items-center justify-between text-xs"
>
<span className="text-slate-600 capitalize">{sensor}:</span>
<Badge
variant="outline"
className={`text-xs ${
status === 'operational'
? 'text-green-600 border-green-200'
: 'text-red-600 border-red-200'
}`}
>
<Badge variant="outline" className="text-xs">
{status}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
)}
{/* Error Alert */}
@@ -348,7 +357,7 @@ export function RobotStatus({ trialId }: RobotStatusProps) {
)}
{/* Last Update */}
<div className="text-xs text-slate-500 flex items-center space-x-1">
<div className="flex items-center space-x-1 text-xs text-slate-500">
<Clock className="h-3 w-3" />
<span>Last update: {lastUpdate.toLocaleTimeString()}</span>
</div>

View File

@@ -1,9 +1,23 @@
"use client";
import {
Activity, ArrowRight, Bot, CheckCircle, GitBranch, MessageSquare, Play, Settings, Timer,
User, Users
Activity,
ArrowRight,
Bot,
CheckCircle,
GitBranch,
MessageSquare,
Play,
Settings,
Timer,
User,
Users,
} from "lucide-react";
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
import { useState } from "react";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Badge } from "~/components/ui/badge";
@@ -16,7 +30,11 @@ interface StepDisplayProps {
step: {
id: string;
name: string;
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional_branch";
type:
| "wizard_action"
| "robot_action"
| "parallel_steps"
| "conditional_branch";
description?: string;
parameters?: any;
duration?: number;
@@ -63,10 +81,12 @@ export function StepDisplay({
stepIndex,
totalSteps,
isActive,
onExecuteAction
onExecuteAction,
}: StepDisplayProps) {
const [isExecuting, setIsExecuting] = useState(false);
const [completedActions, setCompletedActions] = useState<Set<string>>(new Set());
const [completedActions, setCompletedActions] = useState<Set<string>>(
new Set(),
);
const stepConfig = stepTypeConfig[step.type];
const StepIcon = stepConfig.icon;
@@ -75,7 +95,7 @@ export function StepDisplay({
setIsExecuting(true);
try {
await onExecuteAction(actionId, actionData);
setCompletedActions(prev => new Set([...prev, actionId]));
setCompletedActions((prev) => new Set([...prev, actionId]));
} catch (_error) {
console.error("Failed to execute action:", _error);
} finally {
@@ -97,17 +117,19 @@ export function StepDisplay({
{step.actions && step.actions.length > 0 && (
<div className="space-y-3">
<h4 className="font-medium text-slate-900">Available Actions:</h4>
<h4 className="font-medium text-slate-900">
Available Actions:
</h4>
<div className="grid gap-2">
{step.actions.map((action: any, index: number) => {
const isCompleted = completedActions.has(action.id);
return (
<div
key={action.id || index}
className={`flex items-center justify-between p-3 rounded-lg border ${
className={`flex items-center justify-between rounded-lg border p-3 ${
isCompleted
? "bg-green-50 border-green-200"
: "bg-slate-50 border-slate-200"
? "border-green-200 bg-green-50"
: "border-slate-200 bg-slate-50"
}`}
>
<div className="flex items-center space-x-3">
@@ -117,16 +139,20 @@ export function StepDisplay({
<Play className="h-4 w-4 text-slate-400" />
)}
<div>
<p className="font-medium text-sm">{action.name}</p>
<p className="text-sm font-medium">{action.name}</p>
{action.description && (
<p className="text-xs text-slate-600">{action.description}</p>
<p className="text-xs text-slate-600">
{action.description}
</p>
)}
</div>
</div>
{isActive && !isCompleted && (
<Button
size="sm"
onClick={() => handleActionExecution(action.id, action)}
onClick={() =>
handleActionExecution(action.id, action)
}
disabled={isExecuting}
>
Execute
@@ -153,8 +179,10 @@ export function StepDisplay({
{step.parameters && (
<div className="space-y-2">
<h4 className="font-medium text-slate-900">Robot Parameters:</h4>
<div className="bg-slate-50 rounded-lg p-3 text-sm font-mono">
<h4 className="font-medium text-slate-900">
Robot Parameters:
</h4>
<div className="rounded-lg bg-slate-50 p-3 font-mono text-sm">
<pre>{JSON.stringify(step.parameters, null, 2)}</pre>
</div>
</div>
@@ -181,22 +209,26 @@ export function StepDisplay({
{step.substeps && step.substeps.length > 0 && (
<div className="space-y-3">
<h4 className="font-medium text-slate-900">Parallel Actions:</h4>
<h4 className="font-medium text-slate-900">
Parallel Actions:
</h4>
<div className="grid gap-3">
{step.substeps.map((substep: any, index: number) => (
<div
key={substep.id || index}
className="flex items-center space-x-3 p-3 bg-slate-50 rounded-lg border"
className="flex items-center space-x-3 rounded-lg border bg-slate-50 p-3"
>
<div className="flex-shrink-0">
<div className="w-6 h-6 rounded-full bg-purple-100 flex items-center justify-center text-xs font-medium text-purple-600">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-purple-100 text-xs font-medium text-purple-600">
{index + 1}
</div>
</div>
<div className="flex-1">
<p className="font-medium text-sm">{substep.name}</p>
<p className="text-sm font-medium">{substep.name}</p>
{substep.description && (
<p className="text-xs text-slate-600">{substep.description}</p>
<p className="text-xs text-slate-600">
{substep.description}
</p>
)}
</div>
<div className="flex-shrink-0">
@@ -225,7 +257,7 @@ export function StepDisplay({
{step.conditions && (
<div className="space-y-2">
<h4 className="font-medium text-slate-900">Conditions:</h4>
<div className="bg-slate-50 rounded-lg p-3 text-sm">
<div className="rounded-lg bg-slate-50 p-3 text-sm">
<pre>{JSON.stringify(step.conditions, null, 2)}</pre>
</div>
</div>
@@ -233,19 +265,23 @@ export function StepDisplay({
{step.branches && step.branches.length > 0 && (
<div className="space-y-3">
<h4 className="font-medium text-slate-900">Possible Branches:</h4>
<h4 className="font-medium text-slate-900">
Possible Branches:
</h4>
<div className="grid gap-2">
{step.branches.map((branch: any, index: number) => (
<div
key={branch.id || index}
className="flex items-center justify-between p-3 bg-slate-50 rounded-lg border"
className="flex items-center justify-between rounded-lg border bg-slate-50 p-3"
>
<div className="flex items-center space-x-3">
<ArrowRight className="h-4 w-4 text-orange-500" />
<div>
<p className="font-medium text-sm">{branch.name}</p>
<p className="text-sm font-medium">{branch.name}</p>
{branch.condition && (
<p className="text-xs text-slate-600">If: {branch.condition}</p>
<p className="text-xs text-slate-600">
If: {branch.condition}
</p>
)}
</div>
</div>
@@ -253,7 +289,9 @@ export function StepDisplay({
<Button
size="sm"
variant="outline"
onClick={() => handleActionExecution(`branch_${branch.id}`, branch)}
onClick={() =>
handleActionExecution(`branch_${branch.id}`, branch)
}
disabled={isExecuting}
>
Select
@@ -269,8 +307,8 @@ export function StepDisplay({
default:
return (
<div className="text-center py-8 text-slate-500">
<Settings className="h-8 w-8 mx-auto mb-2" />
<div className="py-8 text-center text-slate-500">
<Settings className="mx-auto mb-2 h-8 w-8" />
<p>Unknown step type: {step.type}</p>
</div>
);
@@ -278,32 +316,46 @@ export function StepDisplay({
};
return (
<Card className={`transition-all duration-200 ${
isActive ? "ring-2 ring-blue-500 shadow-lg" : "border-slate-200"
}`}>
<Card
className={`transition-all duration-200 ${
isActive ? "shadow-lg ring-2 ring-blue-500" : "border-slate-200"
}`}
>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3">
<div className={`flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center ${
stepConfig.color === "blue" ? "bg-blue-100" :
stepConfig.color === "green" ? "bg-green-100" :
stepConfig.color === "purple" ? "bg-purple-100" :
stepConfig.color === "orange" ? "bg-orange-100" :
"bg-slate-100"
}`}>
<StepIcon className={`h-5 w-5 ${
stepConfig.color === "blue" ? "text-blue-600" :
stepConfig.color === "green" ? "text-green-600" :
stepConfig.color === "purple" ? "text-purple-600" :
stepConfig.color === "orange" ? "text-orange-600" :
"text-slate-600"
}`} />
<div
className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg ${
stepConfig.color === "blue"
? "bg-blue-100"
: stepConfig.color === "green"
? "bg-green-100"
: stepConfig.color === "purple"
? "bg-purple-100"
: stepConfig.color === "orange"
? "bg-orange-100"
: "bg-slate-100"
}`}
>
<StepIcon
className={`h-5 w-5 ${
stepConfig.color === "blue"
? "text-blue-600"
: stepConfig.color === "green"
? "text-green-600"
: stepConfig.color === "purple"
? "text-purple-600"
: stepConfig.color === "orange"
? "text-orange-600"
: "text-slate-600"
}`}
/>
</div>
<div className="flex-1 min-w-0">
<div className="min-w-0 flex-1">
<CardTitle className="text-lg font-semibold text-slate-900">
{step.name}
</CardTitle>
<div className="flex items-center space-x-2 mt-1">
<div className="mt-1 flex items-center space-x-2">
<Badge variant="outline" className="text-xs">
{stepConfig.label}
</Badge>
@@ -311,7 +363,7 @@ export function StepDisplay({
Step {stepIndex + 1} of {totalSteps}
</span>
</div>
<p className="text-sm text-slate-600 mt-1">
<p className="mt-1 text-sm text-slate-600">
{stepConfig.description}
</p>
</div>
@@ -341,9 +393,14 @@ export function StepDisplay({
<Separator className="my-4" />
<div className="flex items-center justify-between text-xs text-slate-500">
<span>Step Progress</span>
<span>{stepIndex + 1}/{totalSteps}</span>
<span>
{stepIndex + 1}/{totalSteps}
</span>
</div>
<Progress value={((stepIndex + 1) / totalSteps) * 100} className="h-1 mt-2" />
<Progress
value={((stepIndex + 1) / totalSteps) * 100}
className="mt-2 h-1"
/>
</CardContent>
</Card>
);

View File

@@ -1,8 +1,15 @@
"use client";
import {
Activity, Bot, CheckCircle,
Circle, Clock, GitBranch, Play, Target, Users
Activity,
Bot,
CheckCircle,
Circle,
Clock,
GitBranch,
Play,
Target,
Users,
} from "lucide-react";
import { Badge } from "~/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
@@ -13,10 +20,14 @@ interface TrialProgressProps {
steps: Array<{
id: string;
name: string;
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional_branch";
type:
| "wizard_action"
| "robot_action"
| "parallel_steps"
| "conditional_branch";
description?: string;
duration?: number;
parameters?: any;
parameters?: Record<string, unknown>;
}>;
currentStepIndex: number;
trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
@@ -29,7 +40,7 @@ const stepTypeConfig = {
color: "blue",
bgColor: "bg-blue-100",
textColor: "text-blue-600",
borderColor: "border-blue-300"
borderColor: "border-blue-300",
},
robot_action: {
label: "Robot",
@@ -37,7 +48,7 @@ const stepTypeConfig = {
color: "green",
bgColor: "bg-green-100",
textColor: "text-green-600",
borderColor: "border-green-300"
borderColor: "border-green-300",
},
parallel_steps: {
label: "Parallel",
@@ -45,7 +56,7 @@ const stepTypeConfig = {
color: "purple",
bgColor: "bg-purple-100",
textColor: "text-purple-600",
borderColor: "border-purple-300"
borderColor: "border-purple-300",
},
conditional_branch: {
label: "Branch",
@@ -53,17 +64,21 @@ const stepTypeConfig = {
color: "orange",
bgColor: "bg-orange-100",
textColor: "text-orange-600",
borderColor: "border-orange-300"
}
borderColor: "border-orange-300",
},
};
export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialProgressProps) {
export function TrialProgress({
steps,
currentStepIndex,
trialStatus,
}: TrialProgressProps) {
if (!steps || steps.length === 0) {
return (
<Card>
<CardContent className="p-6 text-center">
<div className="text-slate-500">
<Target className="h-8 w-8 mx-auto mb-2 opacity-50" />
<Target className="mx-auto mb-2 h-8 w-8 opacity-50" />
<p className="text-sm">No experiment steps defined</p>
</div>
</CardContent>
@@ -71,19 +86,28 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
);
}
const progress = trialStatus === "completed" ? 100 :
trialStatus === "aborted" ? 0 :
((currentStepIndex + 1) / steps.length) * 100;
const progress =
trialStatus === "completed"
? 100
: trialStatus === "aborted"
? 0
: ((currentStepIndex + 1) / steps.length) * 100;
const completedSteps = trialStatus === "completed" ? steps.length :
trialStatus === "aborted" || trialStatus === "failed" ? 0 :
currentStepIndex;
const completedSteps =
trialStatus === "completed"
? steps.length
: trialStatus === "aborted" || trialStatus === "failed"
? 0
: currentStepIndex;
const getStepStatus = (index: number) => {
if (trialStatus === "aborted" || trialStatus === "failed") return "aborted";
if (trialStatus === "completed" || index < currentStepIndex) return "completed";
if (index === currentStepIndex && trialStatus === "in_progress") return "active";
if (index === currentStepIndex && trialStatus === "scheduled") return "pending";
if (trialStatus === "completed" || index < currentStepIndex)
return "completed";
if (index === currentStepIndex && trialStatus === "in_progress")
return "active";
if (index === currentStepIndex && trialStatus === "scheduled")
return "pending";
return "upcoming";
};
@@ -95,7 +119,7 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
iconColor: "text-green-600",
bgColor: "bg-green-100",
borderColor: "border-green-300",
textColor: "text-green-800"
textColor: "text-green-800",
};
case "active":
return {
@@ -103,7 +127,7 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
iconColor: "text-blue-600",
bgColor: "bg-blue-100",
borderColor: "border-blue-300",
textColor: "text-blue-800"
textColor: "text-blue-800",
};
case "pending":
return {
@@ -111,7 +135,7 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
iconColor: "text-amber-600",
bgColor: "bg-amber-100",
borderColor: "border-amber-300",
textColor: "text-amber-800"
textColor: "text-amber-800",
};
case "aborted":
return {
@@ -119,7 +143,7 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
iconColor: "text-red-600",
bgColor: "bg-red-100",
borderColor: "border-red-300",
textColor: "text-red-800"
textColor: "text-red-800",
};
default: // upcoming
return {
@@ -127,12 +151,15 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
iconColor: "text-slate-400",
bgColor: "bg-slate-100",
borderColor: "border-slate-300",
textColor: "text-slate-600"
textColor: "text-slate-600",
};
}
};
const totalDuration = steps.reduce((sum, step) => sum + (step.duration || 0), 0);
const totalDuration = steps.reduce(
(sum, step) => sum + (step.duration ?? 0),
0,
);
return (
<Card>
@@ -165,19 +192,25 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
<Progress
value={progress}
className={`h-2 ${
trialStatus === "completed" ? "bg-green-100" :
trialStatus === "aborted" || trialStatus === "failed" ? "bg-red-100" :
"bg-blue-100"
trialStatus === "completed"
? "bg-green-100"
: trialStatus === "aborted" || trialStatus === "failed"
? "bg-red-100"
: "bg-blue-100"
}`}
/>
<div className="flex justify-between text-xs text-slate-500">
<span>Start</span>
<span>
{trialStatus === "completed" ? "Completed" :
trialStatus === "aborted" ? "Aborted" :
trialStatus === "failed" ? "Failed" :
trialStatus === "in_progress" ? "In Progress" :
"Not Started"}
{trialStatus === "completed"
? "Completed"
: trialStatus === "aborted"
? "Aborted"
: trialStatus === "failed"
? "Failed"
: trialStatus === "in_progress"
? "In Progress"
: "Not Started"}
</span>
</div>
</div>
@@ -186,7 +219,9 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
{/* Steps Timeline */}
<div className="space-y-4">
<h4 className="font-medium text-slate-900 text-sm">Experiment Steps</h4>
<h4 className="text-sm font-medium text-slate-900">
Experiment Steps
</h4>
<div className="space-y-3">
{steps.map((step, index) => {
@@ -201,9 +236,10 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
{/* Connection Line */}
{index < steps.length - 1 && (
<div
className={`absolute left-6 top-12 w-0.5 h-6 ${
className={`absolute top-12 left-6 h-6 w-0.5 ${
getStepStatus(index + 1) === "completed" ||
(getStepStatus(index + 1) === "active" && status === "completed")
(getStepStatus(index + 1) === "active" &&
status === "completed")
? "bg-green-300"
: "bg-slate-300"
}`}
@@ -211,57 +247,76 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
)}
{/* Step Card */}
<div className={`flex items-start space-x-3 p-3 rounded-lg border transition-all ${
status === "active"
? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200`
: status === "completed"
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
: status === "aborted"
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
: "bg-slate-50 border-slate-200"
}`}>
<div
className={`flex items-start space-x-3 rounded-lg border p-3 transition-all ${
status === "active"
? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200`
: status === "completed"
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
: status === "aborted"
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
: "border-slate-200 bg-slate-50"
}`}
>
{/* Step Number & Status */}
<div className="flex-shrink-0 space-y-1">
<div className={`w-12 h-8 rounded-lg flex items-center justify-center ${
status === "active" ? statusConfig.bgColor :
status === "completed" ? "bg-green-100" :
status === "aborted" ? "bg-red-100" :
"bg-slate-100"
}`}>
<span className={`text-sm font-medium ${
status === "active" ? statusConfig.textColor :
status === "completed" ? "text-green-700" :
status === "aborted" ? "text-red-700" :
"text-slate-600"
}`}>
<div
className={`flex h-8 w-12 items-center justify-center rounded-lg ${
status === "active"
? statusConfig.bgColor
: status === "completed"
? "bg-green-100"
: status === "aborted"
? "bg-red-100"
: "bg-slate-100"
}`}
>
<span
className={`text-sm font-medium ${
status === "active"
? statusConfig.textColor
: status === "completed"
? "text-green-700"
: status === "aborted"
? "text-red-700"
: "text-slate-600"
}`}
>
{index + 1}
</span>
</div>
<div className="flex justify-center">
<StatusIcon className={`h-4 w-4 ${statusConfig.iconColor}`} />
<StatusIcon
className={`h-4 w-4 ${statusConfig.iconColor}`}
/>
</div>
</div>
{/* Step Content */}
<div className="flex-1 min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<h5 className={`font-medium truncate ${
status === "active" ? "text-slate-900" :
status === "completed" ? "text-green-900" :
status === "aborted" ? "text-red-900" :
"text-slate-700"
}`}>
<h5
className={`truncate font-medium ${
status === "active"
? "text-slate-900"
: status === "completed"
? "text-green-900"
: status === "aborted"
? "text-red-900"
: "text-slate-700"
}`}
>
{step.name}
</h5>
{step.description && (
<p className="text-sm text-slate-600 mt-1 line-clamp-2">
<p className="mt-1 line-clamp-2 text-sm text-slate-600">
{step.description}
</p>
)}
</div>
<div className="flex-shrink-0 ml-3 space-y-1">
<div className="ml-3 flex-shrink-0 space-y-1">
<Badge
variant="outline"
className={`text-xs ${stepConfig.textColor} ${stepConfig.borderColor}`}
@@ -280,19 +335,19 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
{/* Step Status Message */}
{status === "active" && trialStatus === "in_progress" && (
<div className="flex items-center space-x-1 mt-2 text-sm text-blue-600">
<div className="mt-2 flex items-center space-x-1 text-sm text-blue-600">
<Activity className="h-3 w-3 animate-pulse" />
<span>Currently executing...</span>
</div>
)}
{status === "active" && trialStatus === "scheduled" && (
<div className="flex items-center space-x-1 mt-2 text-sm text-amber-600">
<div className="mt-2 flex items-center space-x-1 text-sm text-amber-600">
<Clock className="h-3 w-3" />
<span>Ready to start</span>
</div>
)}
{status === "completed" && (
<div className="flex items-center space-x-1 mt-2 text-sm text-green-600">
<div className="mt-2 flex items-center space-x-1 text-sm text-green-600">
<CheckCircle className="h-3 w-3" />
<span>Completed</span>
</div>
@@ -309,7 +364,9 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
<Separator />
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-green-600">{completedSteps}</div>
<div className="text-2xl font-bold text-green-600">
{completedSteps}
</div>
<div className="text-xs text-slate-600">Completed</div>
</div>
<div>
@@ -320,7 +377,9 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
</div>
<div>
<div className="text-2xl font-bold text-slate-600">
{steps.length - completedSteps - (trialStatus === "in_progress" ? 1 : 0)}
{steps.length -
completedSteps -
(trialStatus === "in_progress" ? 1 : 0)}
</div>
<div className="text-xs text-slate-600">Remaining</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -34,7 +34,7 @@ function CommandDialog({
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
showCloseButton: _showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;

View File

@@ -63,7 +63,7 @@ export function EntityForm<T extends FieldValues = FieldValues>({
entityName,
entityNamePlural,
backUrl,
listUrl,
listUrl: _listUrl,
title,
description,
icon: Icon,
@@ -195,7 +195,7 @@ export function EntityForm<T extends FieldValues = FieldValues>({
</span>
</div>
) : (
submitText || defaultSubmitText
(submitText ?? defaultSubmitText)
)}
</Button>
</div>

View File

@@ -1,9 +1,15 @@
"use client";
import {
AlertCircle, CheckCircle, File, FileAudio, FileImage,
FileVideo, Loader2, Upload,
X
AlertCircle,
CheckCircle,
File,
FileAudio,
FileImage,
FileVideo,
Loader2,
Upload,
X,
} from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { Alert, AlertDescription } from "~/components/ui/alert";
@@ -62,20 +68,23 @@ export function FileUpload({
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const validateFile = (file: File): string | null => {
if (file.size > maxSize) {
return `File size exceeds ${Math.round(maxSize / 1024 / 1024)}MB limit`;
}
if (allowedTypes.length > 0) {
const extension = file.name.split('.').pop()?.toLowerCase() || '';
if (!allowedTypes.includes(extension)) {
return `File type .${extension} is not allowed`;
const validateFile = useCallback(
(file: File): string | null => {
if (file.size > maxSize) {
return `File size exceeds ${Math.round(maxSize / 1024 / 1024)}MB limit`;
}
}
return null;
};
if (allowedTypes && allowedTypes.length > 0) {
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
if (!allowedTypes.includes(extension)) {
return `File type .${extension} is not allowed`;
}
}
return null;
},
[maxSize, allowedTypes],
);
const createFilePreview = (file: File): FileWithPreview => {
const fileWithPreview = file as FileWithPreview;
@@ -83,66 +92,69 @@ export function FileUpload({
fileWithPreview.uploaded = false;
// Create preview for images
if (file.type.startsWith('image/')) {
if (file.type.startsWith("image/")) {
fileWithPreview.preview = URL.createObjectURL(file);
}
return fileWithPreview;
};
const handleFiles = useCallback((newFiles: FileList | File[]) => {
const fileArray = Array.from(newFiles);
const handleFiles = useCallback(
(newFiles: FileList | File[]) => {
const fileArray = Array.from(newFiles);
// Check max files limit
if (!multiple && fileArray.length > 1) {
onUploadError?.("Only one file is allowed");
return;
}
if (files.length + fileArray.length > maxFiles) {
onUploadError?.(`Maximum ${maxFiles} files allowed`);
return;
}
const validFiles: FileWithPreview[] = [];
const errors: string[] = [];
fileArray.forEach((file) => {
const error = validateFile(file);
if (error) {
errors.push(`${file.name}: ${error}`);
} else {
validFiles.push(createFilePreview(file));
// Check max files limit
if (!multiple && fileArray.length > 1) {
onUploadError?.("Only one file is allowed");
return;
}
});
if (errors.length > 0) {
onUploadError?.(errors.join(', '));
return;
}
if (files.length + fileArray.length > maxFiles) {
onUploadError?.(`Maximum ${maxFiles} files allowed`);
return;
}
setFiles((prev) => [...prev, ...validFiles]);
}, [files.length, maxFiles, multiple, maxSize, allowedTypes, onUploadError]);
const validFiles: FileWithPreview[] = [];
const errors: string[] = [];
fileArray.forEach((file) => {
const error = validateFile(file);
if (error) {
errors.push(`${file.name}: ${error}`);
} else {
validFiles.push(createFilePreview(file));
}
});
if (errors.length > 0) {
onUploadError?.(errors.join(", "));
return;
}
setFiles((prev) => [...prev, ...validFiles]);
},
[files.length, maxFiles, multiple, onUploadError, validateFile],
);
const uploadFile = async (file: FileWithPreview): Promise<UploadedFile> => {
const formData = new FormData();
formData.append('file', file);
formData.append('category', category);
formData.append("file", file);
formData.append("category", category);
if (trialId) {
formData.append('trialId', trialId);
formData.append("trialId", trialId);
}
const response = await fetch('/api/upload', {
method: 'POST',
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Upload failed');
const error = (await response.json()) as { error?: string };
throw new Error(error.error ?? "Upload failed");
}
const result = await response.json();
const result = (await response.json()) as { data: UploadedFile };
return result.data;
};
@@ -160,17 +172,17 @@ export function FileUpload({
try {
// Update progress
setFiles((prev) =>
prev.map((f, index) =>
index === i ? { ...f, progress: 0 } : f
)
prev.map((f, index) => (index === i ? { ...f, progress: 0 } : f)),
);
// Simulate progress (in real implementation, use XMLHttpRequest for progress)
const progressInterval = setInterval(() => {
setFiles((prev) =>
prev.map((f, index) =>
index === i ? { ...f, progress: Math.min((f.progress || 0) + 10, 90) } : f
)
index === i
? { ...f, progress: Math.min((f.progress ?? 0) + 10, 90) }
: f,
),
);
}, 100);
@@ -188,19 +200,20 @@ export function FileUpload({
uploaded: true,
uploadedData: uploadedFile,
}
: f
)
: f,
),
);
uploadedFiles.push(uploadedFile);
} catch (_error) {
const errorMessage = _error instanceof Error ? _error.message : 'Upload failed';
const errorMessage =
_error instanceof Error ? _error.message : "Upload failed";
errors.push(`${file?.name}: ${errorMessage}`);
setFiles((prev) =>
prev.map((f, index) =>
index === i ? { ...f, error: errorMessage, progress: 0 } : f
)
index === i ? { ...f, error: errorMessage, progress: 0 } : f,
),
);
}
}
@@ -208,7 +221,7 @@ export function FileUpload({
setIsUploading(false);
if (errors.length > 0) {
onUploadError?.(errors.join(', '));
onUploadError?.(errors.join(", "));
}
if (uploadedFiles.length > 0) {
@@ -240,15 +253,18 @@ export function FileUpload({
handleFiles(droppedFiles);
}
},
[handleFiles, disabled]
[handleFiles, disabled],
);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (!disabled) {
setIsDragging(true);
}
}, [disabled]);
const handleDragOver = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (!disabled) {
setIsDragging(true);
}
},
[disabled],
);
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
@@ -262,24 +278,24 @@ export function FileUpload({
handleFiles(selectedFiles);
}
// Reset input value to allow selecting the same file again
e.target.value = '';
e.target.value = "";
},
[handleFiles]
[handleFiles],
);
const getFileIcon = (file: File) => {
if (file.type.startsWith('image/')) return FileImage;
if (file.type.startsWith('video/')) return FileVideo;
if (file.type.startsWith('audio/')) return FileAudio;
if (file.type.startsWith("image/")) return FileImage;
if (file.type.startsWith("video/")) return FileVideo;
if (file.type.startsWith("audio/")) return FileAudio;
return File;
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
return (
@@ -287,11 +303,11 @@ export function FileUpload({
{/* Upload Area */}
<Card
className={cn(
"border-2 border-dashed transition-colors cursor-pointer",
"cursor-pointer border-2 border-dashed transition-colors",
isDragging
? "border-blue-500 bg-blue-50"
: "border-slate-300 hover:border-slate-400",
disabled && "opacity-50 cursor-not-allowed"
disabled && "cursor-not-allowed opacity-50",
)}
onDrop={handleDrop}
onDragOver={handleDragOver}
@@ -299,10 +315,12 @@ export function FileUpload({
onClick={() => !disabled && fileInputRef.current?.click()}
>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Upload className={cn(
"h-12 w-12 mb-4",
isDragging ? "text-blue-500" : "text-slate-400"
)} />
<Upload
className={cn(
"mb-4 h-12 w-12",
isDragging ? "text-blue-500" : "text-slate-400",
)}
/>
<div className="space-y-2">
<p className="text-lg font-medium">
{isDragging ? "Drop files here" : "Upload files"}
@@ -312,7 +330,7 @@ export function FileUpload({
</p>
<div className="flex flex-wrap justify-center gap-2 text-xs text-slate-500">
{allowedTypes.length > 0 && (
<span>Allowed: {allowedTypes.join(', ')}</span>
<span>Allowed: {allowedTypes.join(", ")}</span>
)}
<span>Max size: {Math.round(maxSize / 1024 / 1024)}MB</span>
{multiple && <span>Max files: {maxFiles}</span>}
@@ -340,7 +358,7 @@ export function FileUpload({
<Button
size="sm"
onClick={handleUpload}
disabled={isUploading || files.every(f => f.uploaded)}
disabled={isUploading || files.every((f) => f.uploaded)}
>
{isUploading ? (
<>
@@ -369,6 +387,7 @@ export function FileUpload({
<Card key={index} className="p-3">
<div className="flex items-center space-x-3">
{file.preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={file.preview}
alt={file.name}
@@ -380,8 +399,8 @@ export function FileUpload({
</div>
)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{file.name}</p>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{file.name}</p>
<p className="text-sm text-slate-600">
{formatFileSize(file.size)}
</p>

View File

@@ -14,6 +14,7 @@ interface PageHeaderProps {
variant?: "default" | "secondary" | "destructive" | "outline";
className?: string;
}>;
breadcrumbs?: ReactNode;
actions?: ReactNode;
className?: string;
}
@@ -24,33 +25,44 @@ export function PageHeader({
icon: Icon,
iconClassName,
badges,
breadcrumbs,
actions,
className,
}: PageHeaderProps) {
return (
<div className={cn("flex items-start justify-between", className)}>
<div className="flex items-start space-x-4">
<div
className={cn(
"flex min-w-0 items-start justify-between gap-2 md:gap-4",
className,
)}
>
<div className="flex min-w-0 items-start gap-3 md:gap-4">
{/* Icon */}
{Icon && (
<div
className={cn(
"bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg",
"bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg md:h-12 md:w-12",
iconClassName,
)}
>
<Icon className="text-primary h-6 w-6" />
<Icon className="text-primary h-5 w-5 md:h-6 md:w-6" />
</div>
)}
{/* Title and description */}
<div className="min-w-0 flex-1">
<div className="flex items-center space-x-3">
<h1 className="text-foreground text-3xl font-bold tracking-tight">
{breadcrumbs && (
<div className="text-muted-foreground/80 mb-1 truncate text-xs md:text-sm">
{breadcrumbs}
</div>
)}
<div className="flex min-w-0 items-center gap-2 md:gap-3">
<h1 className="text-foreground truncate text-2xl font-bold tracking-tight md:text-3xl">
{title}
</h1>
{/* Badges */}
{badges && badges.length > 0 && (
<div className="flex space-x-2">
<div className="hidden flex-shrink-0 items-center gap-2 sm:flex">
{badges.map((badge, index) => (
<Badge
key={index}
@@ -64,7 +76,7 @@ export function PageHeader({
)}
</div>
{description && (
<p className="text-muted-foreground mt-2 text-base">
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-sm md:mt-2 md:text-base">
{description}
</p>
)}
@@ -72,7 +84,9 @@ export function PageHeader({
</div>
{/* Actions */}
{actions && <div className="flex-shrink-0">{actions}</div>}
{actions && (
<div className="flex flex-shrink-0 items-center gap-2">{actions}</div>
)}
</div>
);
}
@@ -82,7 +96,13 @@ interface ActionButtonProps {
children: ReactNode;
href?: string;
onClick?: () => void;
variant?: "default" | "secondary" | "outline" | "destructive" | "ghost" | "link";
variant?:
| "default"
| "secondary"
| "outline"
| "destructive"
| "ghost"
| "link";
size?: "default" | "sm" | "lg" | "icon";
disabled?: boolean;
className?: string;

View File

@@ -81,8 +81,8 @@ export function PageLayout({
className,
title,
description,
userName,
userRole,
userName: _userName,
userRole: _userRole,
breadcrumb,
createButton,
quickActions,
@@ -201,7 +201,7 @@ export function PageLayout({
variant={
action.variant === "primary"
? "default"
: action.variant || "default"
: (action.variant ?? "default")
}
className="h-auto flex-col gap-2 p-4"
>

View File

@@ -1,9 +1,9 @@
"use client"
"use client";
import * as ProgressPrimitive from "@radix-ui/react-progress"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress";
import * as React from "react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Progress({
className,
@@ -15,17 +15,17 @@ function Progress({
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
);
}
export { Progress }
export { Progress };

View File

@@ -1,7 +1,7 @@
"use client"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "~/lib/utils"

View File

@@ -1,19 +0,0 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean>(false)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return isMobile
}

View File

@@ -3,9 +3,97 @@
import { useSession } from "next-auth/react";
import { useCallback, useEffect, useRef, useState } from "react";
export interface WebSocketMessage {
export type TrialStatus =
| "scheduled"
| "in_progress"
| "completed"
| "aborted"
| "failed";
export interface TrialSnapshot {
id: string;
status: TrialStatus;
startedAt?: string | Date | null;
completedAt?: string | Date | null;
}
interface ConnectionEstablishedMessage {
type: "connection_established";
data: {
trialId: string;
userId: string | null;
role: string;
connectedAt: number;
};
}
interface HeartbeatResponseMessage {
type: "heartbeat_response";
data: {
timestamp: number;
};
}
interface TrialStatusMessage {
type: "trial_status";
data: {
trial: TrialSnapshot;
current_step_index: number;
timestamp: number;
};
}
interface TrialActionExecutedMessage {
type: "trial_action_executed";
data: {
action_type: string;
timestamp: number;
} & Record<string, unknown>;
}
interface InterventionLoggedMessage {
type: "intervention_logged";
data: {
timestamp: number;
} & Record<string, unknown>;
}
interface StepChangedMessage {
type: "step_changed";
data: {
from_step?: number;
to_step: number;
step_name?: string;
timestamp: number;
} & Record<string, unknown>;
}
interface ErrorMessage {
type: "error";
data: {
message?: string;
};
}
type KnownInboundMessage =
| ConnectionEstablishedMessage
| HeartbeatResponseMessage
| TrialStatusMessage
| TrialActionExecutedMessage
| InterventionLoggedMessage
| StepChangedMessage
| ErrorMessage;
export type WebSocketMessage =
| KnownInboundMessage
| {
type: string;
data: unknown;
};
export interface OutgoingMessage {
type: string;
data: any;
data: Record<string, unknown>;
}
export interface UseWebSocketOptions {
@@ -23,7 +111,7 @@ export interface UseWebSocketReturn {
isConnected: boolean;
isConnecting: boolean;
connectionError: string | null;
sendMessage: (message: WebSocketMessage) => void;
sendMessage: (message: OutgoingMessage) => void;
disconnect: () => void;
reconnect: () => void;
lastMessage: WebSocketMessage | null;
@@ -40,25 +128,30 @@ export function useWebSocket({
heartbeatInterval = 30000,
}: UseWebSocketOptions): UseWebSocketReturn {
const { data: session } = useSession();
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [isConnected, setIsConnected] = useState<boolean>(false);
const [isConnecting, setIsConnecting] = useState<boolean>(false);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [hasAttemptedConnection, setHasAttemptedConnection] =
useState<boolean>(false);
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const heartbeatTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const attemptCountRef = useRef(0);
const mountedRef = useRef(true);
const attemptCountRef = useRef<number>(0);
const mountedRef = useRef<boolean>(true);
const connectionStableTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Generate auth token (simplified - in production use proper JWT)
const getAuthToken = useCallback(() => {
const getAuthToken = useCallback((): string | null => {
if (!session?.user) return null;
// In production, this would be a proper JWT token
return btoa(JSON.stringify({ userId: session.user.id, timestamp: Date.now() }));
return btoa(
JSON.stringify({ userId: session.user.id, timestamp: Date.now() }),
);
}, [session]);
const sendMessage = useCallback((message: WebSocketMessage) => {
const sendMessage = useCallback((message: OutgoingMessage): void => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
} else {
@@ -66,11 +159,11 @@ export function useWebSocket({
}
}, []);
const sendHeartbeat = useCallback(() => {
const sendHeartbeat = useCallback((): void => {
sendMessage({ type: "heartbeat", data: {} });
}, [sendMessage]);
const scheduleHeartbeat = useCallback(() => {
const scheduleHeartbeat = useCallback((): void => {
if (heartbeatTimeoutRef.current) {
clearTimeout(heartbeatTimeoutRef.current);
}
@@ -82,99 +175,167 @@ export function useWebSocket({
}, heartbeatInterval);
}, [isConnected, sendHeartbeat, heartbeatInterval]);
const handleMessage = useCallback((event: MessageEvent) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
setLastMessage(message);
const handleMessage = useCallback(
(event: MessageEvent<string>): void => {
try {
const message = JSON.parse(event.data) as WebSocketMessage;
setLastMessage(message);
// Handle system messages
switch (message.type) {
case "connection_established":
console.log("WebSocket connection established:", message.data);
setIsConnected(true);
setIsConnecting(false);
setConnectionError(null);
attemptCountRef.current = 0;
scheduleHeartbeat();
onConnect?.();
break;
// Handle system messages
switch (message.type) {
case "connection_established": {
console.log(
"WebSocket connection established:",
(message as ConnectionEstablishedMessage).data,
);
setIsConnected(true);
setIsConnecting(false);
setConnectionError(null);
attemptCountRef.current = 0;
scheduleHeartbeat();
onConnect?.();
break;
}
case "heartbeat_response":
// Heartbeat acknowledged, connection is alive
break;
case "heartbeat_response":
// Heartbeat acknowledged, connection is alive
break;
case "error":
console.error("WebSocket server error:", message.data);
setConnectionError(message.data.message || "Server error");
onError?.(new Event("server_error"));
break;
case "error": {
console.error("WebSocket server error:", message);
const msg =
(message as ErrorMessage).data?.message ?? "Server error";
setConnectionError(msg);
onError?.(new Event("server_error"));
break;
}
default:
// Pass to user-defined message handler
onMessage?.(message);
break;
}
} catch (error) {
console.error("Error parsing WebSocket message:", error);
setConnectionError("Failed to parse message");
}
}, [onMessage, onConnect, onError, scheduleHeartbeat]);
const handleClose = useCallback((event: CloseEvent) => {
console.log("WebSocket connection closed:", event.code, event.reason);
setIsConnected(false);
setIsConnecting(false);
if (heartbeatTimeoutRef.current) {
clearTimeout(heartbeatTimeoutRef.current);
}
onDisconnect?.();
// Attempt reconnection if not manually closed and component is still mounted
if (event.code !== 1000 && mountedRef.current && attemptCountRef.current < reconnectAttempts) {
attemptCountRef.current++;
const delay = reconnectInterval * Math.pow(1.5, attemptCountRef.current - 1); // Exponential backoff
console.log(`Attempting reconnection ${attemptCountRef.current}/${reconnectAttempts} in ${delay}ms`);
setConnectionError(`Connection lost. Reconnecting... (${attemptCountRef.current}/${reconnectAttempts})`);
reconnectTimeoutRef.current = setTimeout(() => {
if (mountedRef.current) {
connect();
default:
// Pass to user-defined message handler
onMessage?.(message);
break;
}
}, delay);
} else if (attemptCountRef.current >= reconnectAttempts) {
setConnectionError("Failed to reconnect after maximum attempts");
}
}, [onDisconnect, reconnectAttempts, reconnectInterval]);
} catch (error) {
console.error("Error parsing WebSocket message:", error);
setConnectionError("Failed to parse message");
}
},
[onMessage, onConnect, onError, scheduleHeartbeat],
);
const handleError = useCallback((event: Event) => {
console.error("WebSocket error:", event);
setConnectionError("Connection error");
setIsConnecting(false);
onError?.(event);
}, [onError]);
const handleClose = useCallback(
(event: CloseEvent): void => {
console.log("WebSocket connection closed:", event.code, event.reason);
setIsConnected(false);
setIsConnecting(false);
const connect = useCallback(() => {
if (heartbeatTimeoutRef.current) {
clearTimeout(heartbeatTimeoutRef.current);
}
onDisconnect?.();
// Attempt reconnection if not manually closed and component is still mounted
// In development, don't aggressively reconnect to prevent UI flashing
if (
event.code !== 1000 &&
mountedRef.current &&
attemptCountRef.current < reconnectAttempts &&
process.env.NODE_ENV !== "development"
) {
attemptCountRef.current++;
const delay =
reconnectInterval * Math.pow(1.5, attemptCountRef.current - 1); // Exponential backoff
console.log(
`Attempting reconnection ${attemptCountRef.current}/${reconnectAttempts} in ${delay}ms`,
);
setConnectionError(
`Connection lost. Reconnecting... (${attemptCountRef.current}/${reconnectAttempts})`,
);
reconnectTimeoutRef.current = setTimeout(() => {
if (mountedRef.current) {
attemptCountRef.current = 0;
setIsConnecting(true);
setConnectionError(null);
}
}, delay);
} else if (attemptCountRef.current >= reconnectAttempts) {
setConnectionError("Failed to reconnect after maximum attempts");
} else if (
process.env.NODE_ENV === "development" &&
event.code !== 1000
) {
// In development, set a stable error message without reconnection attempts
setConnectionError("WebSocket unavailable - using polling mode");
}
},
[onDisconnect, reconnectAttempts, reconnectInterval],
);
const handleError = useCallback(
(event: Event): void => {
// In development, WebSocket failures are expected with Edge Runtime
if (process.env.NODE_ENV === "development") {
// Only set error state after the first failed attempt to prevent flashing
if (!hasAttemptedConnection) {
setHasAttemptedConnection(true);
// Debounce the error state to prevent UI flashing
if (connectionStableTimeoutRef.current) {
clearTimeout(connectionStableTimeoutRef.current);
}
connectionStableTimeoutRef.current = setTimeout(() => {
setConnectionError("WebSocket unavailable - using polling mode");
setIsConnecting(false);
}, 1000);
}
} else {
console.error("WebSocket error:", event);
setConnectionError("Connection error");
setIsConnecting(false);
}
onError?.(event);
},
[onError, hasAttemptedConnection],
);
const connectInternal = useCallback((): void => {
if (!session?.user || !trialId) {
setConnectionError("Missing authentication or trial ID");
if (!hasAttemptedConnection) {
setConnectionError("Missing authentication or trial ID");
setHasAttemptedConnection(true);
}
return;
}
if (wsRef.current &&
(wsRef.current.readyState === WebSocket.CONNECTING ||
wsRef.current.readyState === WebSocket.OPEN)) {
if (
wsRef.current &&
(wsRef.current.readyState === WebSocket.CONNECTING ||
wsRef.current.readyState === WebSocket.OPEN)
) {
return; // Already connecting or connected
}
const token = getAuthToken();
if (!token) {
setConnectionError("Failed to generate auth token");
if (!hasAttemptedConnection) {
setConnectionError("Failed to generate auth token");
setHasAttemptedConnection(true);
}
return;
}
setIsConnecting(true);
// Only show connecting state for the first attempt or if we've been stable
if (!hasAttemptedConnection || isConnected) {
setIsConnecting(true);
}
// Clear any pending error updates
if (connectionStableTimeoutRef.current) {
clearTimeout(connectionStableTimeoutRef.current);
}
setConnectionError(null);
try {
@@ -191,15 +352,26 @@ export function useWebSocket({
console.log("WebSocket connection opened");
// Connection establishment is handled in handleMessage
};
} catch (error) {
console.error("Failed to create WebSocket connection:", error);
setConnectionError("Failed to create connection");
if (!hasAttemptedConnection) {
setConnectionError("Failed to create connection");
setHasAttemptedConnection(true);
}
setIsConnecting(false);
}
}, [session, trialId, getAuthToken, handleMessage, handleClose, handleError]);
}, [
session,
trialId,
getAuthToken,
handleMessage,
handleClose,
handleError,
hasAttemptedConnection,
isConnected,
]);
const disconnect = useCallback(() => {
const disconnect = useCallback((): void => {
mountedRef.current = false;
if (reconnectTimeoutRef.current) {
@@ -210,6 +382,10 @@ export function useWebSocket({
clearTimeout(heartbeatTimeoutRef.current);
}
if (connectionStableTimeoutRef.current) {
clearTimeout(connectionStableTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close(1000, "Manual disconnect");
wsRef.current = null;
@@ -218,32 +394,53 @@ export function useWebSocket({
setIsConnected(false);
setIsConnecting(false);
setConnectionError(null);
setHasAttemptedConnection(false);
attemptCountRef.current = 0;
}, []);
const reconnect = useCallback(() => {
const reconnect = useCallback((): void => {
disconnect();
mountedRef.current = true;
attemptCountRef.current = 0;
setTimeout(connect, 100); // Small delay to ensure cleanup
}, [disconnect, connect]);
setHasAttemptedConnection(false);
setTimeout(() => {
if (mountedRef.current) {
void connectInternal();
}
}, 100); // Small delay to ensure cleanup
}, [disconnect, connectInternal]);
// Effect to establish initial connection
useEffect(() => {
if (session?.user && trialId) {
connect();
if (session?.user?.id && trialId) {
// In development, only attempt connection once to prevent flashing
if (process.env.NODE_ENV === "development" && hasAttemptedConnection) {
return;
}
// Trigger reconnection if timeout was set
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
void connectInternal();
} else {
void connectInternal();
}
}
return () => {
mountedRef.current = false;
disconnect();
};
}, [session?.user?.id, trialId]); // Reconnect if user or trial changes
}, [session?.user?.id, trialId, hasAttemptedConnection]);
// Cleanup on unmount
useEffect(() => {
return () => {
mountedRef.current = false;
if (connectionStableTimeoutRef.current) {
clearTimeout(connectionStableTimeoutRef.current);
}
disconnect();
};
}, [disconnect]);
@@ -262,27 +459,30 @@ export function useWebSocket({
// Hook for trial-specific WebSocket events
export function useTrialWebSocket(trialId: string) {
const [trialEvents, setTrialEvents] = useState<WebSocketMessage[]>([]);
const [currentTrialStatus, setCurrentTrialStatus] = useState<any>(null);
const [wizardActions, setWizardActions] = useState<any[]>([]);
const [currentTrialStatus, setCurrentTrialStatus] =
useState<TrialSnapshot | null>(null);
const [wizardActions, setWizardActions] = useState<WebSocketMessage[]>([]);
const handleMessage = useCallback((message: WebSocketMessage) => {
const handleMessage = useCallback((message: WebSocketMessage): void => {
// Add to events log
setTrialEvents(prev => [...prev, message].slice(-100)); // Keep last 100 events
setTrialEvents((prev) => [...prev, message].slice(-100)); // Keep last 100 events
switch (message.type) {
case "trial_status":
setCurrentTrialStatus(message.data.trial);
case "trial_status": {
const data = (message as TrialStatusMessage).data;
setCurrentTrialStatus(data.trial);
break;
}
case "trial_action_executed":
case "intervention_logged":
case "step_changed":
setWizardActions(prev => [...prev, message].slice(-50)); // Keep last 50 actions
setWizardActions((prev) => [...prev, message].slice(-50)); // Keep last 50 actions
break;
case "step_changed":
// Handle step transitions
console.log("Step changed:", message.data);
// Handle step transitions (optional logging)
console.log("Step changed:", (message as StepChangedMessage).data);
break;
default:
@@ -295,42 +495,68 @@ export function useTrialWebSocket(trialId: string) {
trialId,
onMessage: handleMessage,
onConnect: () => {
console.log(`Connected to trial ${trialId} WebSocket`);
// Request current trial status on connect
webSocket.sendMessage({ type: "request_trial_status", data: {} });
if (process.env.NODE_ENV === "development") {
console.log(`Connected to trial ${trialId} WebSocket`);
}
},
onDisconnect: () => {
console.log(`Disconnected from trial ${trialId} WebSocket`);
if (process.env.NODE_ENV === "development") {
console.log(`Disconnected from trial ${trialId} WebSocket`);
}
},
onError: (error) => {
console.error(`Trial ${trialId} WebSocket error:`, error);
onError: () => {
// Suppress noisy WebSocket errors in development
if (process.env.NODE_ENV !== "development") {
console.error(`Trial ${trialId} WebSocket connection failed`);
}
},
});
// Request trial status after connection is established
useEffect(() => {
if (webSocket.isConnected) {
webSocket.sendMessage({ type: "request_trial_status", data: {} });
}
}, [webSocket.isConnected, webSocket]);
// Trial-specific actions
const executeTrialAction = useCallback((actionType: string, actionData: any) => {
webSocket.sendMessage({
type: "trial_action",
data: {
actionType,
...actionData,
},
});
}, [webSocket]);
const executeTrialAction = useCallback(
(actionType: string, actionData: Record<string, unknown>): void => {
webSocket.sendMessage({
type: "trial_action",
data: {
actionType,
...actionData,
},
});
},
[webSocket],
);
const logWizardIntervention = useCallback((interventionData: any) => {
webSocket.sendMessage({
type: "wizard_intervention",
data: interventionData,
});
}, [webSocket]);
const logWizardIntervention = useCallback(
(interventionData: Record<string, unknown>): void => {
webSocket.sendMessage({
type: "wizard_intervention",
data: interventionData,
});
},
[webSocket],
);
const transitionStep = useCallback((stepData: any) => {
webSocket.sendMessage({
type: "step_transition",
data: stepData,
});
}, [webSocket]);
const transitionStep = useCallback(
(stepData: {
from_step?: number;
to_step: number;
step_name?: string;
[k: string]: unknown;
}): void => {
webSocket.sendMessage({
type: "step_transition",
data: stepData,
});
},
[webSocket],
);
return {
...webSocket,

View File

@@ -1,19 +1,25 @@
import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import {
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env } from "~/env";
// Configure MinIO S3 client
const s3Client = new S3Client({
endpoint: env.MINIO_ENDPOINT || "http://localhost:9000",
region: env.MINIO_REGION || "us-east-1",
endpoint: env.MINIO_ENDPOINT ?? "http://localhost:9000",
region: env.MINIO_REGION ?? "us-east-1",
credentials: {
accessKeyId: env.MINIO_ACCESS_KEY || "minioadmin",
secretAccessKey: env.MINIO_SECRET_KEY || "minioadmin",
accessKeyId: env.MINIO_ACCESS_KEY ?? "minioadmin",
secretAccessKey: env.MINIO_SECRET_KEY ?? "minioadmin",
},
forcePathStyle: true, // Required for MinIO
});
const BUCKET_NAME = env.MINIO_BUCKET_NAME || "hristudio";
const BUCKET_NAME = env.MINIO_BUCKET_NAME ?? "hristudio";
const PRESIGNED_URL_EXPIRY = 3600; // 1 hour in seconds
export interface UploadParams {
@@ -46,7 +52,7 @@ export async function uploadFile(params: UploadParams): Promise<UploadResult> {
Bucket: BUCKET_NAME,
Key: params.key,
Body: params.body,
ContentType: params.contentType || "application/octet-stream",
ContentType: params.contentType ?? "application/octet-stream",
Metadata: params.metadata,
});
@@ -55,13 +61,17 @@ export async function uploadFile(params: UploadParams): Promise<UploadResult> {
return {
key: params.key,
url: `${env.MINIO_ENDPOINT}/${BUCKET_NAME}/${params.key}`,
size: Buffer.isBuffer(params.body) ? params.body.length : params.body.toString().length,
contentType: params.contentType || "application/octet-stream",
etag: result.ETag || "",
size: Buffer.isBuffer(params.body)
? params.body.length
: params.body.toString().length,
contentType: params.contentType ?? "application/octet-stream",
etag: result.ETag ?? "",
};
} catch (error) {
console.error("Error uploading file to MinIO:", error);
throw new Error(`Failed to upload file: ${error instanceof Error ? error.message : "Unknown error"}`);
throw new Error(
`Failed to upload file: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
@@ -71,10 +81,14 @@ export async function uploadFile(params: UploadParams): Promise<UploadResult> {
export async function getPresignedUrl(
key: string,
operation: "getObject" | "putObject" = "getObject",
options: PresignedUrlOptions = {}
options: PresignedUrlOptions = {},
): Promise<string> {
try {
const { expiresIn = PRESIGNED_URL_EXPIRY, responseContentType, responseContentDisposition } = options;
const {
expiresIn = PRESIGNED_URL_EXPIRY,
responseContentType,
responseContentDisposition,
} = options;
let command;
if (operation === "getObject") {
@@ -96,7 +110,9 @@ export async function getPresignedUrl(
return url;
} catch (error) {
console.error("Error generating presigned URL:", error);
throw new Error(`Failed to generate presigned URL: ${error instanceof Error ? error.message : "Unknown error"}`);
throw new Error(
`Failed to generate presigned URL: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
@@ -113,7 +129,9 @@ export async function deleteFile(key: string): Promise<void> {
await s3Client.send(command);
} catch (error) {
console.error("Error deleting file from MinIO:", error);
throw new Error(`Failed to delete file: ${error instanceof Error ? error.message : "Unknown error"}`);
throw new Error(
`Failed to delete file: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
@@ -134,7 +152,9 @@ export async function fileExists(key: string): Promise<boolean> {
return false;
}
console.error("Error checking file existence:", error);
throw new Error(`Failed to check file existence: ${error instanceof Error ? error.message : "Unknown error"}`);
throw new Error(
`Failed to check file existence: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
@@ -157,23 +177,30 @@ export async function getFileMetadata(key: string): Promise<{
const result = await s3Client.send(command);
return {
size: result.ContentLength || 0,
lastModified: result.LastModified || new Date(),
contentType: result.ContentType || "application/octet-stream",
etag: result.ETag || "",
metadata: result.Metadata || {},
size: result.ContentLength ?? 0,
lastModified: result.LastModified ?? new Date(),
contentType: result.ContentType ?? "application/octet-stream",
etag: result.ETag ?? "",
metadata: result.Metadata ?? {},
};
} catch (error) {
console.error("Error getting file metadata:", error);
throw new Error(`Failed to get file metadata: ${error instanceof Error ? error.message : "Unknown error"}`);
throw new Error(
`Failed to get file metadata: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
/**
* Generate a download URL for a file
*/
export async function getDownloadUrl(key: string, filename?: string): Promise<string> {
const contentDisposition = filename ? `attachment; filename="${filename}"` : undefined;
export async function getDownloadUrl(
key: string,
filename?: string,
): Promise<string> {
const contentDisposition = filename
? `attachment; filename="${filename}"`
: undefined;
return getPresignedUrl(key, "getObject", {
responseContentDisposition: contentDisposition,
@@ -183,7 +210,10 @@ export async function getDownloadUrl(key: string, filename?: string): Promise<st
/**
* Generate an upload URL for direct client uploads
*/
export async function getUploadUrl(key: string, contentType?: string): Promise<string> {
export async function getUploadUrl(
key: string,
contentType?: string,
): Promise<string> {
return getPresignedUrl(key, "putObject", {
responseContentType: contentType,
});
@@ -196,7 +226,7 @@ export function generateFileKey(
prefix: string,
filename: string,
userId?: string,
trialId?: string
trialId?: string,
): string {
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 8);
@@ -274,7 +304,7 @@ export function getMimeType(filename: string): string {
gz: "application/gzip",
};
return mimeTypes[extension] || "application/octet-stream";
return mimeTypes[extension] ?? "application/octet-stream";
}
/**
@@ -284,10 +314,10 @@ export function validateFile(
filename: string,
size: number,
allowedTypes?: string[],
maxSize?: number
maxSize?: number,
): { valid: boolean; error?: string } {
// Check file size (default 100MB limit)
const maxFileSize = maxSize || 100 * 1024 * 1024;
const maxFileSize = maxSize ?? 100 * 1024 * 1024;
if (size > maxFileSize) {
return {
valid: false,
@@ -313,4 +343,3 @@ export function validateFile(
export { s3Client };
// Export bucket name for reference
export { BUCKET_NAME };

View File

@@ -13,7 +13,7 @@ import {
} from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import type { db } from "~/server/db";
import { db } from "~/server/db";
import {
experiments,
participants,
@@ -25,6 +25,7 @@ import {
mediaCaptures,
users,
} from "~/server/db/schema";
import { TrialExecutionEngine } from "~/server/services/trial-execution";
// Helper function to check if user has access to trial
async function checkTrialAccess(
@@ -77,6 +78,9 @@ async function checkTrialAccess(
return trial[0];
}
// Global execution engine instance
const executionEngine = new TrialExecutionEngine(db);
export const trialsRouter = createTRPCRouter({
list: protectedProcedure
.input(
@@ -412,25 +416,31 @@ export const trialsRouter = createTRPCRouter({
});
}
// Start trial
const [trial] = await db
.update(trials)
.set({
status: "in_progress",
startedAt: new Date(),
})
// Use execution engine to start trial
const result = await executionEngine.startTrial(input.id, userId);
if (!result.success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: result.error ?? "Failed to start trial",
});
}
// Return updated trial data
const trial = await db
.select()
.from(trials)
.where(eq(trials.id, input.id))
.returning();
.limit(1);
// Log trial start event
await db.insert(trialEvents).values({
trialId: input.id,
eventType: "trial_started",
timestamp: new Date(),
data: { userId },
});
if (!trial[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Trial not found after start",
});
}
return trial;
return trial[0];
}),
complete: protectedProcedure
@@ -488,24 +498,31 @@ export const trialsRouter = createTRPCRouter({
"wizard",
]);
const [trial] = await db
.update(trials)
.set({
status: "aborted",
completedAt: new Date(),
})
// Use execution engine to abort trial
const result = await executionEngine.abortTrial(input.id, input.reason);
if (!result.success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: result.error ?? "Failed to complete trial",
});
}
// Return updated trial data
const trial = await db
.select()
.from(trials)
.where(eq(trials.id, input.id))
.returning();
.limit(1);
// Log trial abort event
await db.insert(trialEvents).values({
trialId: input.id,
eventType: "trial_aborted",
timestamp: new Date(),
data: { userId, reason: input.reason },
});
if (!trial[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Trial not found after abort",
});
}
return trial;
return trial[0];
}),
logEvent: protectedProcedure
@@ -789,4 +806,84 @@ export const trialsRouter = createTRPCRouter({
},
};
}),
// Trial Execution Procedures
executeCurrentStep: protectedProcedure
.input(z.object({ trialId: z.string() }))
.mutation(async ({ ctx, input }) => {
await checkTrialAccess(ctx.db, ctx.session.user.id, input.trialId);
const result = await executionEngine.executeCurrentStep(input.trialId);
if (!result.success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: result.error ?? "Failed to reset trial",
});
}
return result;
}),
advanceToNextStep: protectedProcedure
.input(z.object({ trialId: z.string() }))
.mutation(async ({ ctx, input }) => {
await checkTrialAccess(ctx.db, ctx.session.user.id, input.trialId);
const result = await executionEngine.advanceToNextStep(input.trialId);
if (!result.success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: result.error ?? "Failed to advance to next step",
});
}
return result;
}),
getExecutionStatus: protectedProcedure
.input(z.object({ trialId: z.string() }))
.query(async ({ ctx, input }) => {
await checkTrialAccess(ctx.db, ctx.session.user.id, input.trialId);
const status = executionEngine.getTrialStatus(input.trialId);
const currentStep = executionEngine.getCurrentStep(input.trialId);
return {
status,
currentStep,
};
}),
getCurrentStep: protectedProcedure
.input(z.object({ trialId: z.string() }))
.query(async ({ ctx, input }) => {
await checkTrialAccess(ctx.db, ctx.session.user.id, input.trialId);
return executionEngine.getCurrentStep(input.trialId);
}),
completeWizardAction: protectedProcedure
.input(
z.object({
trialId: z.string(),
actionId: z.string(),
data: z.record(z.string(), z.unknown()).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
await checkTrialAccess(ctx.db, ctx.session.user.id, input.trialId);
// Log wizard action completion
await ctx.db.insert(trialEvents).values({
trialId: input.trialId,
eventType: "wizard_action_completed",
actionId: input.actionId,
data: input.data,
timestamp: new Date(),
createdBy: ctx.session.user.id,
});
return { success: true };
}),
});

View File

@@ -477,11 +477,16 @@ export const usersRouter = createTRPCRouter({
role.role === "wizard" ||
role.role === "researcher" ||
role.role === "administrator",
)?.role || "wizard",
)?.role ?? "wizard",
});
}
});
return Array.from(wizardUsers.values());
return Array.from(wizardUsers.values()) as Array<{
id: string;
name: string;
email: string;
role: "wizard" | "researcher" | "administrator";
}>;
}),
});

View File

@@ -1,6 +1,6 @@
import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
import { type DefaultSession } from "next-auth";
import { type DefaultSession, type NextAuthConfig } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { z } from "zod";
@@ -38,9 +38,10 @@ declare module "next-auth" {
*
* @see https://next-auth.js.org/configuration/options
*/
export const authConfig = {
export const authConfig: NextAuthConfig = {
session: {
strategy: "jwt",
strategy: "jwt" as const,
maxAge: 30 * 24 * 60 * 60, // 30 days
},
pages: {
@@ -87,17 +88,17 @@ export const authConfig = {
}),
],
callbacks: {
jwt: ({ token, user }: { token: any; user: any }) => {
jwt: async ({ token, user }) => {
if (user) {
token.id = user.id;
}
return token;
},
session: async ({ session, token }: { session: any; token: any }) => {
if (token.id) {
session: async ({ session, token }) => {
if (token.id && typeof token.id === 'string') {
// Fetch user roles from database
const userWithRoles = await db.query.users.findFirst({
where: eq(users.id, token.id as string),
where: eq(users.id, token.id),
with: {
systemRoles: {
with: {
@@ -117,7 +118,7 @@ export const authConfig = {
...session,
user: {
...session.user,
id: token.id as string,
id: token.id,
roles:
userWithRoles?.systemRoles?.map((sr) => ({
role: sr.role,
@@ -130,4 +131,4 @@ export const authConfig = {
return session;
},
},
} as any;
};

View File

@@ -0,0 +1,763 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/no-base-to-string */
import { type db } from "~/server/db";
import { trials, steps, actions, trialEvents } from "~/server/db/schema";
import { eq, asc } from "drizzle-orm";
export type TrialStatus =
| "scheduled"
| "in_progress"
| "completed"
| "aborted"
| "failed";
export interface ExecutionContext {
trialId: string;
experimentId: string;
participantId: string;
wizardId?: string;
currentStepIndex: number;
startTime: Date;
variables: Record<string, unknown>;
}
export interface StepDefinition {
id: string;
name: string;
description?: string;
type: string;
orderIndex: number;
condition?: string;
actions: ActionDefinition[];
}
export interface ActionDefinition {
id: string;
stepId: string;
name: string;
description?: string;
type: string;
orderIndex: number;
parameters: Record<string, unknown>;
timeout?: number;
required: boolean;
condition?: string;
}
export interface ExecutionResult {
success: boolean;
error?: string;
data?: Record<string, unknown>;
duration?: number;
nextStepIndex?: number;
}
export interface ActionExecutionResult {
success: boolean;
error?: string;
data?: Record<string, unknown>;
duration: number;
completed: boolean;
}
export class TrialExecutionEngine {
private db: typeof db;
private activeTrials = new Map<string, ExecutionContext>();
private stepDefinitions = new Map<string, StepDefinition[]>();
constructor(database: typeof db) {
this.db = database;
}
/**
* Initialize a trial for execution
*/
async initializeTrial(trialId: string): Promise<ExecutionContext> {
// Get trial details
const [trial] = await this.db
.select()
.from(trials)
.where(eq(trials.id, trialId));
if (!trial) {
throw new Error(`Trial ${trialId} not found`);
}
if (trial.status === "completed" || trial.status === "aborted") {
throw new Error(`Trial ${trialId} is already ${trial.status}`);
}
// Load experiment steps and actions
const experimentSteps = await this.loadExperimentProtocol(
trial.experimentId,
);
this.stepDefinitions.set(trialId, experimentSteps);
// Create execution context
const context: ExecutionContext = {
trialId,
experimentId: trial.experimentId,
participantId: trial.participantId || "",
wizardId: trial.wizardId || undefined,
currentStepIndex: 0,
startTime: new Date(),
variables: {},
};
this.activeTrials.set(trialId, context);
return context;
}
/**
* Load experiment protocol (steps and actions) from database
*/
private async loadExperimentProtocol(
experimentId: string,
): Promise<StepDefinition[]> {
// Get all steps for the experiment
const stepRecords = await this.db
.select()
.from(steps)
.where(eq(steps.experimentId, experimentId))
.orderBy(asc(steps.orderIndex));
const stepDefinitions: StepDefinition[] = [];
for (const step of stepRecords) {
// Get all actions for this step
const actionRecords = await this.db
.select()
.from(actions)
.where(eq(actions.stepId, step.id))
.orderBy(asc(actions.orderIndex));
const actionDefinitions: ActionDefinition[] = actionRecords.map(
(action: any) => ({
id: action.id,
stepId: action.stepId,
name: action.name,
description: action.description || undefined,
type: action.type,
orderIndex: action.orderIndex,
parameters: (action.parameters as Record<string, unknown>) || {},
timeout: action.timeout || undefined,
required: action.required || true,
condition: action.condition || undefined,
}),
);
stepDefinitions.push({
id: step.id,
name: step.name,
description: step.description || undefined,
type: step.type,
orderIndex: step.orderIndex,
condition: (step.conditions as string) || undefined,
actions: actionDefinitions,
});
}
return stepDefinitions;
}
/**
* Start trial execution
*/
async startTrial(
trialId: string,
wizardId?: string,
): Promise<ExecutionResult> {
try {
let context = this.activeTrials.get(trialId);
if (!context) {
context = await this.initializeTrial(trialId);
}
if (wizardId) {
context.wizardId = wizardId;
}
// Update trial status in database
await this.db
.update(trials)
.set({
status: "in_progress",
startedAt: context.startTime,
wizardId: context.wizardId,
})
.where(eq(trials.id, trialId));
// Log trial start event
await this.logTrialEvent(trialId, "trial_started", {
wizardId: context.wizardId,
startTime: context.startTime.toISOString(),
});
return {
success: true,
data: {
trialId,
status: "in_progress",
currentStepIndex: context.currentStepIndex,
},
};
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: "Unknown error starting trial",
};
}
}
/**
* Execute the current step
*/
async executeCurrentStep(trialId: string): Promise<ExecutionResult> {
const context = this.activeTrials.get(trialId);
if (!context) {
return { success: false, error: "Trial not initialized" };
}
const steps = this.stepDefinitions.get(trialId);
if (!steps || context.currentStepIndex >= steps.length) {
return await this.completeTrial(trialId);
}
const step = steps[context.currentStepIndex];
if (!step) {
return { success: false, error: "Invalid step index" };
}
try {
// Check step condition
if (step.condition && !this.evaluateCondition(step.condition, context)) {
// Skip this step
return await this.advanceToNextStep(trialId);
}
// Log step start
await this.logTrialEvent(trialId, "step_started", {
stepId: step.id,
stepName: step.name,
stepIndex: context.currentStepIndex,
});
// Execute all actions in the step
const actionResults = await this.executeStepActions(trialId, step);
const failedActions = actionResults.filter(
(result) => !result.success && result.required,
);
if (failedActions.length > 0) {
throw new Error(
`Step failed: ${failedActions.map((f) => f.error).join(", ")}`,
);
}
// Log step completion
await this.logTrialEvent(trialId, "step_completed", {
stepId: step.id,
stepName: step.name,
stepIndex: context.currentStepIndex,
actionResults: actionResults.map((r) => ({
success: r.success,
duration: r.duration,
})),
});
return {
success: true,
data: {
stepId: step.id,
stepName: step.name,
actionResults,
},
};
} catch (error) {
await this.logTrialEvent(trialId, "step_failed", {
stepId: step.id,
stepName: step.name,
stepIndex: context.currentStepIndex,
error: error instanceof Error ? error.message : "Unknown error",
});
return {
success: false,
error:
error instanceof Error
? error.message
: "Unknown error executing step",
};
}
}
/**
* Execute all actions within a step
*/
private async executeStepActions(
trialId: string,
step: StepDefinition,
): Promise<Array<ActionExecutionResult & { required: boolean }>> {
const context = this.activeTrials.get(trialId)!;
const results: Array<ActionExecutionResult & { required: boolean }> = [];
for (const action of step.actions) {
// Check action condition
if (
action.condition &&
!this.evaluateCondition(action.condition, context)
) {
results.push({
success: true,
completed: false,
duration: 0,
data: { skipped: true, reason: "condition not met" },
required: action.required,
});
continue;
}
const startTime = Date.now();
try {
const result = await this.executeAction(trialId, action);
const duration = Date.now() - startTime;
await this.logTrialEvent(trialId, "action_executed", {
actionId: action.id,
actionName: action.name,
actionType: action.type,
stepId: step.id,
duration,
success: result.success,
data: result.data,
});
results.push({
...result,
duration,
required: action.required,
});
} catch (error) {
const duration = Date.now() - startTime;
await this.logTrialEvent(trialId, "action_failed", {
actionId: action.id,
actionName: action.name,
actionType: action.type,
stepId: step.id,
duration,
error: error instanceof Error ? error.message : "Unknown error",
});
results.push({
success: false,
completed: false,
duration,
error: error instanceof Error ? error.message : "Unknown error",
required: action.required,
});
}
}
return results;
}
/**
* Execute a single action
*/
private async executeAction(
trialId: string,
action: ActionDefinition,
): Promise<ActionExecutionResult> {
// This is where we'd dispatch to different action executors based on action.type
// For now, we'll implement basic action types and mock robot actions
switch (action.type) {
case "wait":
return await this.executeWaitAction(action);
case "wizard_say":
return await this.executeWizardAction(trialId, action);
case "wizard_gesture":
return await this.executeWizardAction(trialId, action);
case "observe_behavior":
return await this.executeObservationAction(trialId, action);
default:
// Check if it's a robot action (contains plugin prefix)
if (action.type.includes(".")) {
return await this.executeRobotAction(trialId, action);
}
// Unknown action type - log and continue
return {
success: true,
completed: true,
duration: 0,
data: {
message: `Action type '${action.type}' not implemented yet`,
parameters: action.parameters,
},
};
}
}
/**
* Execute wait action
*/
private async executeWaitAction(
action: ActionDefinition,
): Promise<ActionExecutionResult> {
const duration = (action.parameters.duration as number) || 1000;
return new Promise((resolve) => {
setTimeout(() => {
resolve({
success: true,
completed: true,
duration,
data: { waitDuration: duration },
});
}, duration);
});
}
/**
* Execute wizard action (requires human input)
*/
private async executeWizardAction(
trialId: string,
action: ActionDefinition,
): Promise<ActionExecutionResult> {
// For wizard actions, we return immediately but mark as requiring wizard input
// The wizard interface will handle the actual execution
return {
success: true,
completed: false, // Requires wizard confirmation
duration: 0,
data: {
requiresWizardInput: true,
actionType: action.type,
parameters: action.parameters,
instructions: this.getWizardInstructions(action),
},
};
}
/**
* Execute observation action
*/
private async executeObservationAction(
trialId: string,
action: ActionDefinition,
): Promise<ActionExecutionResult> {
// Observation actions typically require wizard input to record observations
return {
success: true,
completed: false,
duration: 0,
data: {
requiresWizardInput: true,
actionType: action.type,
parameters: action.parameters,
observationType: action.parameters.type || "behavior",
},
};
}
/**
* Execute robot action through plugin system
*/
private async executeRobotAction(
trialId: string,
action: ActionDefinition,
): Promise<ActionExecutionResult> {
try {
// Parse plugin.action format
const [pluginId, actionType] = action.type.split(".");
// TODO: Integrate with actual robot plugin system
// For now, simulate robot action execution
const simulationDelay = Math.random() * 2000 + 500; // 500ms - 2.5s
return new Promise((resolve) => {
setTimeout(() => {
// Simulate success/failure
const success = Math.random() > 0.1; // 90% success rate
resolve({
success,
completed: true,
duration: simulationDelay,
data: {
pluginId,
actionType,
parameters: action.parameters,
robotResponse: success
? "Action completed successfully"
: "Robot action failed",
},
error: success ? undefined : "Simulated robot failure",
});
}, simulationDelay);
});
} catch (error) {
return {
success: false,
completed: false,
duration: 0,
error:
error instanceof Error
? error.message
: "Robot action execution failed",
};
}
}
/**
* Advance to the next step
*/
async advanceToNextStep(trialId: string): Promise<ExecutionResult> {
const context = this.activeTrials.get(trialId);
if (!context) {
return { success: false, error: "Trial not initialized" };
}
const steps = this.stepDefinitions.get(trialId);
if (!steps) {
return { success: false, error: "No steps loaded for trial" };
}
const previousStepIndex = context.currentStepIndex;
context.currentStepIndex++;
await this.logTrialEvent(trialId, "step_transition", {
fromStepIndex: previousStepIndex,
toStepIndex: context.currentStepIndex,
});
// Check if we've completed all steps
if (context.currentStepIndex >= steps.length) {
return await this.completeTrial(trialId);
}
return {
success: true,
nextStepIndex: context.currentStepIndex,
data: { previousStepIndex, currentStepIndex: context.currentStepIndex },
};
}
/**
* Complete the trial
*/
async completeTrial(trialId: string): Promise<ExecutionResult> {
const context = this.activeTrials.get(trialId);
if (!context) {
return { success: false, error: "Trial not initialized" };
}
const endTime = new Date();
const duration = endTime.getTime() - context.startTime.getTime();
try {
// Update trial in database
await this.db
.update(trials)
.set({
status: "completed",
completedAt: endTime,
duration: Math.round(duration / 1000), // Convert to seconds
})
.where(eq(trials.id, trialId));
// Log completion
await this.logTrialEvent(trialId, "trial_completed", {
endTime: endTime.toISOString(),
duration,
totalSteps: this.stepDefinitions.get(trialId)?.length || 0,
});
// Clean up
this.activeTrials.delete(trialId);
this.stepDefinitions.delete(trialId);
return {
success: true,
data: {
trialId,
status: "completed",
duration,
endTime: endTime.toISOString(),
},
};
} catch (error) {
return {
success: false,
error:
error instanceof Error ? error.message : "Failed to complete trial",
};
}
}
/**
* Abort the trial
*/
async abortTrial(trialId: string, reason?: string): Promise<ExecutionResult> {
const context = this.activeTrials.get(trialId);
if (!context) {
return { success: false, error: "Trial not initialized" };
}
const endTime = new Date();
const duration = endTime.getTime() - context.startTime.getTime();
try {
await this.db
.update(trials)
.set({
status: "aborted",
completedAt: endTime,
duration: Math.round(duration / 1000),
})
.where(eq(trials.id, trialId));
await this.logTrialEvent(trialId, "trial_aborted", {
reason: reason || "Manual abort",
endTime: endTime.toISOString(),
duration,
stepIndex: context.currentStepIndex,
});
// Clean up
this.activeTrials.delete(trialId);
this.stepDefinitions.delete(trialId);
return {
success: true,
data: {
trialId,
status: "aborted",
reason,
duration,
},
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Failed to abort trial",
};
}
}
/**
* Get current execution status
*/
getTrialStatus(trialId: string): ExecutionContext | null {
return this.activeTrials.get(trialId) || null;
}
/**
* Get current step definition
*/
getCurrentStep(trialId: string): StepDefinition | null {
const context = this.activeTrials.get(trialId);
const steps = this.stepDefinitions.get(trialId);
if (!context || !steps || context.currentStepIndex >= steps.length) {
return null;
}
return steps[context.currentStepIndex] || null;
}
/**
* Log trial event to database
*/
private async logTrialEvent(
trialId: string,
eventType: string,
data: Record<string, unknown> = {},
): Promise<void> {
try {
await this.db.insert(trialEvents).values({
trialId,
eventType,
data: data as any, // TODO: Fix typing
timestamp: new Date(),
createdBy: this.activeTrials.get(trialId)?.wizardId,
});
} catch (error) {
console.error("Failed to log trial event:", error);
// Don't throw - logging failures shouldn't stop execution
}
}
/**
* Evaluate condition (simple implementation)
*/
private evaluateCondition(
condition: string,
context: ExecutionContext,
): boolean {
try {
// Simple condition evaluation - in production, use a safer evaluator
// For now, support basic variable checks
if (condition.includes("variables.")) {
// Replace variables in condition with actual values
let evaluableCondition = condition;
Object.entries(context.variables).forEach(([key, value]) => {
evaluableCondition = evaluableCondition.replace(
`variables.${key}`,
JSON.stringify(value),
);
});
// Basic evaluation - in production, use a proper expression evaluator
// eslint-disable-next-line @typescript-eslint/no-implied-eval
return new Function("return " + evaluableCondition)();
}
return true; // Default to true if condition can't be evaluated
} catch (error) {
console.warn("Failed to evaluate condition:", condition, error);
return true; // Fail open
}
}
/**
* Get wizard instructions for an action
*/
private getWizardInstructions(action: ActionDefinition): string {
switch (action.type) {
case "wizard_say":
return `Say: "${action.parameters.text || "Please speak to the participant"}"`;
case "wizard_gesture":
return `Perform gesture: ${action.parameters.gesture || "as specified in the protocol"}`;
case "observe_behavior":
return `Observe and record: ${action.parameters.behavior || "participant behavior"}`;
default:
return `Execute: ${action.name}`;
}
}
}

View File

@@ -179,4 +179,17 @@
@apply bg-background text-foreground;
letter-spacing: var(--tracking-normal);
}
/* Tabs (shadcn/radix) global theming */
[data-slot="tabs-list"] {
@apply bg-muted text-muted-foreground inline-flex h-9 items-center justify-center rounded-md p-1;
}
[data-slot="tabs-trigger"] {
@apply ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center rounded-sm border border-transparent px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50;
}
[data-slot="tabs-trigger"][data-state="active"] {
@apply bg-background text-foreground shadow;
}
}

48
src/types/edge-websocket.d.ts vendored Normal file
View File

@@ -0,0 +1,48 @@
/**
* Edge WebSocket TypeScript declarations for Next.js Edge runtime.
*
* Purpose:
* - Provide typings for the non-standard `WebSocketPair` constructor available in Edge runtimes.
* - Augment the DOM `WebSocket` interface with the `accept()` method (server-side socket).
* - Augment `ResponseInit` to allow `{ webSocket: WebSocket }` when returning a 101 Switching Protocols response.
*
* This file is safe to include in strict mode projects.
*/
declare global {
/**
* Edge runtime-specific constructor that yields a pair of WebSockets:
* index 0 is the client end, index 1 is the server end.
*
* Usage:
* const pair = new WebSocketPair();
* const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
*/
// Edge WebSocketPair declaration
var WebSocketPair: {
new (): { 0: WebSocket; 1: WebSocket };
prototype: object;
};
/**
* The server-side WebSocket in Edge runtimes exposes `accept()` to finalize the upgrade.
* This augments the standard DOM WebSocket interface.
*/
interface WebSocket {
/**
* Accept the server-side WebSocket before sending/receiving messages.
* No-op on client-side sockets.
*/
accept(): void;
}
/**
* Next.js Edge runtime allows `webSocket` in ResponseInit when returning a 101 response.
* This augments the standard DOM ResponseInit interface.
*/
interface ResponseInit {
webSocket?: WebSocket;
}
}
export {};