+
Already have an account?{" "}
- Sign in here
+ Sign in
{/* Footer */}
-
+
- © 2024 HRIStudio. A platform for Human-Robot Interaction research.
+ © {new Date().getFullYear()} HRIStudio. All rights reserved.
diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx
old mode 100644
new mode 100755
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
old mode 100644
new mode 100755
index ab3a234..a27f097
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -2,17 +2,23 @@
import * as React from "react";
import Link from "next/link";
-import {
- Building,
- FlaskConical,
- TestTube,
- Users,
- Calendar,
- Clock,
- AlertCircle,
- CheckCircle2,
-} from "lucide-react";
+import { format } from "date-fns";
import { formatDistanceToNow } from "date-fns";
+import {
+ Activity,
+ ArrowRight,
+ Bot,
+ Calendar,
+ CheckCircle2,
+ Clock,
+ LayoutDashboard,
+ MoreHorizontal,
+ Play,
+ PlayCircle,
+ Plus,
+ Settings,
+ Users,
+} from "lucide-react";
import { Button } from "~/components/ui/button";
import {
@@ -22,7 +28,14 @@ import {
CardHeader,
CardTitle,
} from "~/components/ui/card";
-import { Badge } from "~/components/ui/badge";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "~/components/ui/dropdown-menu";
import { Progress } from "~/components/ui/progress";
import {
Select,
@@ -31,375 +44,270 @@ import {
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
+import { Badge } from "~/components/ui/badge";
+import { ScrollArea } from "~/components/ui/scroll-area";
import { api } from "~/trpc/react";
-// Dashboard Overview Cards
-function OverviewCards({ studyFilter }: { studyFilter: string | null }) {
- const { data: stats, isLoading } = api.dashboard.getStats.useQuery({
- studyId: studyFilter ?? undefined,
- });
-
- const cards = [
- {
- title: "Active Studies",
- value: stats?.totalStudies ?? 0,
- description: "Research studies you have access to",
- icon: Building,
- color: "text-blue-600",
- bg: "bg-blue-50",
- },
- {
- title: "Experiments",
- value: stats?.totalExperiments ?? 0,
- description: "Experiment protocols designed",
- icon: FlaskConical,
- color: "text-green-600",
- bg: "bg-green-50",
- },
- {
- title: "Participants",
- value: stats?.totalParticipants ?? 0,
- description: "Enrolled participants",
- icon: Users,
- color: "text-purple-600",
- bg: "bg-purple-50",
- },
- {
- title: "Trials",
- value: stats?.totalTrials ?? 0,
- description: "Total trials conducted",
- icon: TestTube,
- color: "text-orange-600",
- bg: "bg-orange-50",
- },
- ];
-
- if (isLoading) {
- return (
-
- {Array.from({ length: 4 }).map((_, i) => (
-
-
-
-
-
-
-
-
-
-
- ))}
-
- );
- }
-
- return (
-
- {cards.map((card) => (
-
-
- {card.title}
-
-
-
-
-
- {card.value}
- {card.description}
-
-
- ))}
-
- );
-}
-
-// Recent Activity Component
-function RecentActivity({ studyFilter }: { studyFilter: string | null }) {
- const { data: activities = [], isLoading } =
- api.dashboard.getRecentActivity.useQuery({
- limit: 8,
- studyId: studyFilter ?? undefined,
- });
-
- const getStatusIcon = (status: string) => {
- switch (status) {
- case "success":
- return
;
- case "pending":
- return
;
- case "error":
- return
;
- default:
- return
;
- }
- };
-
- return (
-
-
- Recent Activity
-
- Latest updates from your research platform
-
-
-
- {isLoading ? (
-
- {Array.from({ length: 4 }).map((_, i) => (
-
- ))}
-
- ) : activities.length === 0 ? (
-
-
-
- No recent activity
-
-
- ) : (
-
- {activities.map((activity) => (
-
- {getStatusIcon(activity.status)}
-
-
- {activity.title}
-
-
- {activity.description}
-
-
-
- {formatDistanceToNow(activity.time, { addSuffix: true })}
-
-
- ))}
-
- )}
-
-
- );
-}
-
-// Quick Actions Component
-function QuickActions() {
- const actions = [
- {
- title: "Create Study",
- description: "Start a new research study",
- href: "/studies/new",
- icon: Building,
- color: "bg-blue-500 hover:bg-blue-600",
- },
- {
- title: "Browse Studies",
- description: "View and manage your studies",
- href: "/studies",
- icon: Building,
- color: "bg-green-500 hover:bg-green-600",
- },
- {
- title: "Create Experiment",
- description: "Design new experiment protocol",
- href: "/experiments/new",
- icon: FlaskConical,
- color: "bg-purple-500 hover:bg-purple-600",
- },
- {
- title: "Browse Experiments",
- description: "View experiment templates",
- href: "/experiments",
- icon: FlaskConical,
- color: "bg-orange-500 hover:bg-orange-600",
- },
- ];
-
- return (
-
- {actions.map((action) => (
-
-
-
-
-
- {action.title}
-
-
-
- {action.description}
-
-
-
- ))}
-
- );
-}
-
-// Study Progress Component
-function StudyProgress({ studyFilter }: { studyFilter: string | null }) {
- const { data: studies = [], isLoading } =
- api.dashboard.getStudyProgress.useQuery({
- limit: 5,
- studyId: studyFilter ?? undefined,
- });
-
- return (
-
-
- Study Progress
-
- Current status of active research studies
-
-
-
- {isLoading ? (
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
- ) : studies.length === 0 ? (
-
-
-
- No active studies found
-
-
- Create a study to get started
-
-
- ) : (
-
- {studies.map((study) => (
-
-
-
-
- {study.name}
-
-
- {study.participants}/{study.totalParticipants} completed
- trials
-
-
-
- {study.status}
-
-
-
-
- {study.progress}% complete
-
-
- ))}
-
- )}
-
-
- );
-}
-
export default function DashboardPage() {
const [studyFilter, setStudyFilter] = React.useState
(null);
- // Get user studies for filter dropdown
+ // --- Data Fetching ---
const { data: userStudiesData } = api.studies.list.useQuery({
memberOnly: true,
limit: 100,
});
-
const userStudies = userStudiesData?.studies ?? [];
+ const { data: stats } = api.dashboard.getStats.useQuery({
+ studyId: studyFilter ?? undefined,
+ });
+
+ const { data: scheduledTrials } = api.trials.list.useQuery({
+ studyId: studyFilter ?? undefined,
+ status: "scheduled",
+ limit: 5,
+ });
+
+ const { data: recentActivity } = api.dashboard.getRecentActivity.useQuery({
+ limit: 10,
+ studyId: studyFilter ?? undefined,
+ });
+
+ const { data: studyProgress } = api.dashboard.getStudyProgress.useQuery({
+ limit: 5,
+ studyId: studyFilter ?? undefined,
+ });
+
return (
-
- {/* Header */}
-
+
+ {/* Header Section */}
+
-
- Dashboard
- {studyFilter && (
-
- {userStudies.find((s) => s.id === studyFilter)?.name}
-
- )}
-
+
Dashboard
- {studyFilter
- ? "Study-specific dashboard view"
- : "Welcome to your HRI Studio research platform"}
+ Overview of your research activities and upcoming tasks.
-
-
-
- Filter by study:
-
-
- setStudyFilter(value === "all" ? null : value)
- }
- >
-
-
-
-
- All Studies
- {userStudies.map((study) => (
-
- {study.name}
-
- ))}
-
-
-
-
-
- {new Date().toLocaleDateString()}
-
+
+
+
+ setStudyFilter(value === "all" ? null : value)
+ }
+ >
+
+
+
+
+ All Studies
+ {userStudies.map((study) => (
+
+ {study.name}
+
+ ))}
+
+
+
+
+
+ New Study
+
+
- {/* Overview Cards */}
-
+ {/* Stats Cards */}
+
+
+
-
+ />
+
+
+
+
+
+
+ {/* Main Column: Scheduled Trials & Study Progress */}
-
-
-
- {/* Quick Actions */}
-
-
Quick Actions
-
+ {/* Scheduled Trials */}
+
+
+
+
+ Upcoming Sessions
+
+ You have {scheduledTrials?.length ?? 0} scheduled trials coming up.
+
+
+
+ View All
+
+
+
+
+ {!scheduledTrials?.length ? (
+
+
+
No scheduled trials found.
+
+ Schedule a Trial
+
+
+ ) : (
+
+ {scheduledTrials.map((trial) => (
+
+
+
+
+
+
+
+ {trial.participant.participantCode}
+ • {trial.experiment.name}
+
+
+
+ {trial.scheduledAt ? format(trial.scheduledAt, "MMM d, h:mm a") : "Unscheduled"}
+
+
+
+
+
+ Start
+
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Study Progress */}
+
+
+ Study Progress
+
+ Completion tracking for active studies
+
+
+
+ {studyProgress?.map((study) => (
+
+
+
{study.name}
+
{study.participants} / {study.totalParticipants} Participants
+
+
+
+ ))}
+ {!studyProgress?.length && (
+ No active studies to track.
+ )}
+
+
+
+
+
+ {/* Side Column: Recent Activity & Quick Actions */}
+
+ {/* Quick Actions */}
+
+
+
+
+ New Experim.
+
+
+
+
+
+ Run Trial
+
+
+
+
+ {/* Recent Activity */}
+
+
+ Recent Activity
+
+
+
+
+ {recentActivity?.map((activity) => (
+
+
+
{activity.title}
+
{activity.description}
+
+ {formatDistanceToNow(activity.time, { addSuffix: true })}
+
+
+ ))}
+ {!recentActivity?.length && (
+
No recent activity.
+ )}
+
+
+
+
+
);
}
+
+function StatsCard({
+ title,
+ value,
+ icon: Icon,
+ description,
+ trend,
+}: {
+ title: string;
+ value: string | number;
+ icon: React.ElementType;
+ description: string;
+ trend?: string;
+}) {
+ return (
+
+
+ {title}
+
+
+
+ {value}
+
+ {description}
+ {trend && {trend} }
+
+
+
+ );
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
old mode 100644
new mode 100755
index fdd54d7..e0dba04
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,7 +1,7 @@
import "~/styles/globals.css";
import { type Metadata } from "next";
-import { Geist } from "next/font/google";
+import { Inter } from "next/font/google";
import { SessionProvider } from "next-auth/react";
import { TRPCReactProvider } from "~/trpc/react";
@@ -13,16 +13,16 @@ export const metadata: Metadata = {
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
-const geist = Geist({
+const inter = Inter({
subsets: ["latin"],
- variable: "--font-geist-sans",
+ variable: "--font-sans",
});
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
-
+
{children}
diff --git a/src/app/page.tsx b/src/app/page.tsx
old mode 100644
new mode 100755
index ae52270..dd8a3a9
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -5,561 +5,290 @@ import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Logo } from "~/components/ui/logo";
import { auth } from "~/server/auth";
+import {
+ ArrowRight,
+ Beaker,
+ Bot,
+ Database,
+ LayoutTemplate,
+ Lock,
+ Network,
+ PlayCircle,
+ Settings2,
+ Share2,
+} from "lucide-react";
export default async function Home() {
const session = await auth();
- // Redirect authenticated users to their dashboard
if (session?.user) {
redirect("/dashboard");
}
return (
-
- {/* Header */}
-
-
-
-
+
+ {/* Navbar */}
+
+
+
+
+
+ Features
+
+
+ Architecture
+
+
+
+ Sign In
+
+
+ Get Started
+
+
+
+
-
-
- Sign In
+
+ {/* Hero Section */}
+
+ {/* Background Gradients */}
+
+
+
+
+ ✨ The Modern Standard for HRI Research
+
+
+
+ Reproducible WoZ Studies
+
+ Made Simple
+
+
+
+
+ HRIStudio is the open-source platform that bridges the gap between
+ ease of use and scientific rigor. Design, execute, and analyze
+ human-robot interaction experiments with zero friction.
+
+
+
+
+
+ Start Researching
+
+
-
- Get Started
+
+
+ View on GitHub
+
-
-
-
- {/* Hero Section */}
-
-
-
- 🤖 Human-Robot Interaction Research Platform
-
-
- Standardize Your
-
- {" "}
- Wizard of Oz{" "}
-
- Studies
-
-
- A comprehensive web-based platform that enhances the scientific
- rigor of Human-Robot Interaction experiments while remaining
- accessible to researchers with varying levels of technical
- expertise.
-
-
-
- Start Your Research
-
-
- Learn More
-
-
-
-
-
- {/* Problem Section */}
-
-
-
-
-
- The Challenge of WoZ Studies
-
-
- While Wizard of Oz is a powerful paradigm for HRI research, it
- faces significant challenges
-
-
-
-
-
-
-
- Reproducibility Issues
-
-
-
-
- • Wizard behavior variability across trials
- • Inconsistent experimental conditions
- • Lack of standardized terminology
- • Insufficient documentation
-
-
-
-
-
-
-
- Technical Barriers
-
-
-
-
- • Platform-specific robot control systems
- • Extensive custom coding requirements
- • Limited to domain experts
- • Fragmented data collection
-
-
-
-
-
-
-
-
- {/* Features Section */}
-
-
-
-
-
- Six Key Design Principles
-
-
- Our platform addresses these challenges through comprehensive
- design principles
-
-
-
-
-
-
-
- Integrated Environment
-
-
-
- All functionalities unified in a single web-based platform
- with intuitive interfaces
-
-
-
-
-
-
-
- Visual Experiment Design
-
-
-
- Minimal-to-no coding required with drag-and-drop visual
- programming capabilities
-
-
-
-
-
-
-
- Real-time Control
-
-
-
- Fine-grained, real-time control of scripted experimental
- runs with multiple robot platforms
-
-
-
-
-
-
-
- Data Management
-
-
-
- Comprehensive data collection and logging with structured
- storage and retrieval
-
-
-
-
-
-
-
- Platform Agnostic
-
-
-
- Support for wide range of robot hardware through RESTful
- APIs, ROS, and custom plugins
-
-
-
-
-
-
-
- Collaboration Support
-
-
-
- Role-based access control and data sharing for effective
- research team collaboration
-
-
-
-
-
-
-
-
- {/* Architecture Section */}
-
-
-
-
-
- Three-Layer Architecture
-
-
- Modular web application with clear separation of concerns
-
-
-
-
-
-
-
-
- User Interface Layer
-
-
-
-
-
-
- Experiment Designer
-
-
- Visual programming for experimental protocols
-
-
-
-
- Wizard Interface
-
-
- Real-time control during trial execution
-
-
-
-
- Playback & Analysis
-
-
- Data exploration and visualization
-
-
-
-
-
-
-
-
-
-
- Data Management Layer
-
-
-
-
- Secure database functionality with role-based access control
- (Researcher, Wizard, Observer) for organizing experiment
- definitions, metadata, and media assets.
-
-
- PostgreSQL
- MinIO Storage
- Role-based Access
- Cloud/On-premise
-
-
-
-
-
-
-
-
- Robot Integration Layer
-
-
-
-
- Robot-agnostic communication layer supporting multiple
- integration methods for diverse hardware platforms.
-
-
- RESTful APIs
- ROS Integration
- Custom Plugins
- Docker Deployment
-
-
-
-
-
-
-
-
- {/* Workflow Section */}
-
-
-
-
-
- Hierarchical Experiment Structure
-
-
- Standardized terminology and organization for reproducible
- research
-
-
-
-
- {/* Hierarchy visualization */}
-
-
-
-
-
- 1
-
-
-
Study
-
- Top-level container comprising one or more experiments
-
-
-
-
-
-
-
-
-
-
- 2
-
-
-
Experiment
-
- Parameterized template specifying experimental
- protocol
-
-
-
-
-
-
-
-
-
-
- 3
-
-
-
Trial
-
- Executable instance with specific participant and
- conditions
-
-
-
-
-
-
-
-
-
-
- 4
-
-
-
Step
-
- Distinct phase containing wizard or robot instructions
-
-
-
-
-
-
-
-
-
-
- 5
-
-
-
Action
-
- Specific atomic task (speech, movement, input
- gathering, etc.)
-
-
-
-
-
+ {/* Mockup / Visual Interest */}
+
+
+
+ {/* Placeholder for actual app screenshot */}
+
+
+
+
Interactive Experiment Designer
+
-
-
+
- {/* CTA Section */}
-
-
-
-
- Ready to Revolutionize Your HRI Research?
-
-
- Join researchers worldwide who are using our platform to conduct
- more rigorous, reproducible Wizard of Oz studies.
-
-
-
- Get Started Free
-
-
- Sign In
-
+ {/* Features Bento Grid */}
+
+
+
Everything You Need
+
Built for the specific needs of HRI researchers and wizards.
+
+
+
+ {/* Visual Designer - Large Item */}
+
+
+
+
+ Visual Experiment Designer
+
+
+
+
+ Construct complex branching narratives without writing a single line of code.
+ Our node-based editor handles logic, timing, and robot actions automatically.
+
+
+
+
Start
+
+
Robot: Greet
+
+
Wait: 5s
+
+
+
+
+
+ {/* Robot Agnostic */}
+
+
+
+
+ Robot Agnostic
+
+
+
+
+ Switch between robots instantly. Whether it's a NAO, Pepper, or a custom ROS2 bot,
+ your experiment logic remains strictly separated from hardware implementation.
+
+
+
+
+ {/* Role Based */}
+
+
+
+
+ Role-Based Access
+
+
+
+
+ Granular permissions for Principal Investigators, Wizards, and Observers.
+
+
+
+
+ {/* Data Logging */}
+
+
+
+
+ Full Traceability
+
+
+
+
+ Every wizard action, automated response, and sensor reading is time-stamped and logged.
+
+
+
+
+
+
+ {/* Architecture Section */}
+
+
+
+
+
Enterprise-Grade Architecture
+
+ Designed for reliability and scale. HRIStudio uses a modern stack to ensure your data is safe and your experiments run smoothly.
+
+
+
+
+
+
+
+
+
3-Layer Design
+
Clear separation between UI, Data, and Hardware layers for maximum stability.
+
+
+
+
+
+
+
+
Collaborative by Default
+
Real-time state synchronization allows multiple researchers to monitor a single trial.
+
+
+
+
+
+
+
+
ROS2 Integration
+
Native support for ROS2 nodes, topics, and actions right out of the box.
+
+
+
+
+
+
+ {/* Abstract representation of architecture */}
+
+
+
+ APP LAYER
+
+
+ Next.js Dashboard + Experiment Designer
+
+
+
+
+ DATA LAYER
+
+
+ PostgreSQL + MinIO + TRPC API
+
+
+
+
+ HARDWARE LAYER
+
+
+ ROS2 Bridge + Robot Plugins
+
+
+
+ {/* Decorative blobs */}
+
+
-
-
+
- {/* Footer */}
-
-
-
-
-
-
-
- Advancing Human-Robot Interaction research through standardized
- Wizard of Oz methodologies
+ {/* CTA Section */}
+
+ Ready to upgrade your lab?
+
+ Join the community of researchers building the future of HRI with reproducible, open-source tools.
+
+
+
+ Get Started for Free
+
+
+
+
+
+
-
+
);
}
diff --git a/src/app/unauthorized/page.tsx b/src/app/unauthorized/page.tsx
old mode 100644
new mode 100755
diff --git a/src/components/admin/AdminContent.tsx b/src/components/admin/AdminContent.tsx
old mode 100644
new mode 100755
diff --git a/src/components/admin/admin-user-table.tsx b/src/components/admin/admin-user-table.tsx
old mode 100644
new mode 100755
diff --git a/src/components/admin/repositories-columns.tsx b/src/components/admin/repositories-columns.tsx
old mode 100644
new mode 100755
diff --git a/src/components/admin/repositories-data-table.tsx b/src/components/admin/repositories-data-table.tsx
old mode 100644
new mode 100755
diff --git a/src/components/admin/role-management.tsx b/src/components/admin/role-management.tsx
old mode 100644
new mode 100755
diff --git a/src/components/admin/system-stats.tsx b/src/components/admin/system-stats.tsx
old mode 100644
new mode 100755
diff --git a/src/components/dashboard/DashboardContent.tsx b/src/components/dashboard/DashboardContent.tsx
old mode 100644
new mode 100755
diff --git a/src/components/dashboard/app-sidebar.tsx b/src/components/dashboard/app-sidebar.tsx
old mode 100644
new mode 100755
index e4bf901..712295a
--- a/src/components/dashboard/app-sidebar.tsx
+++ b/src/components/dashboard/app-sidebar.tsx
@@ -143,9 +143,9 @@ export function AppSidebar({
// Build study work items with proper URLs when study is selected
const studyWorkItemsWithUrls = selectedStudyId
? studyWorkItems.map((item) => ({
- ...item,
- url: `/studies/${selectedStudyId}${item.url}`,
- }))
+ ...item,
+ url: `/studies/${selectedStudyId}${item.url}`,
+ }))
: [];
const handleSignOut = async () => {
@@ -233,6 +233,22 @@ export function AppSidebar({
// Show debug info in development
const showDebug = process.env.NODE_ENV === "development";
+ const [mounted, setMounted] = React.useState(false);
+
+ React.useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ if (!mounted) {
+ return (
+
+
+
+
+
+ );
+ }
+
return (
diff --git a/src/components/dashboard/study-guard.tsx b/src/components/dashboard/study-guard.tsx
old mode 100644
new mode 100755
diff --git a/src/components/experiments/ExperimentForm.tsx b/src/components/experiments/ExperimentForm.tsx
old mode 100644
new mode 100755
index d25fe69..fcf2d6e
--- a/src/components/experiments/ExperimentForm.tsx
+++ b/src/components/experiments/ExperimentForm.tsx
@@ -87,33 +87,33 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
{ label: "Studies", href: "/studies" },
...(selectedStudyId
? [
- {
- label: experiment?.study?.name ?? "Study",
- href: `/studies/${selectedStudyId}`,
- },
- { label: "Experiments", href: "/experiments" },
- ...(mode === "edit" && experiment
- ? [
- {
- label: experiment.name,
- href: `/experiments/${experiment.id}`,
- },
- { label: "Edit" },
- ]
- : [{ label: "New Experiment" }]),
- ]
+ {
+ label: experiment?.study?.name ?? "Study",
+ href: `/studies/${selectedStudyId}`,
+ },
+ { label: "Experiments", href: "/experiments" },
+ ...(mode === "edit" && experiment
+ ? [
+ {
+ label: experiment.name,
+ href: `/studies/${selectedStudyId}/experiments/${experiment.id}`,
+ },
+ { label: "Edit" },
+ ]
+ : [{ label: "New Experiment" }]),
+ ]
: [
- { label: "Experiments", href: "/experiments" },
- ...(mode === "edit" && experiment
- ? [
- {
- label: experiment.name,
- href: `/experiments/${experiment.id}`,
- },
- { label: "Edit" },
- ]
- : [{ label: "New Experiment" }]),
- ]),
+ { label: "Experiments", href: "/experiments" },
+ ...(mode === "edit" && experiment
+ ? [
+ {
+ label: experiment.name,
+ href: `/studies/${experiment.studyId}/experiments/${experiment.id}`,
+ },
+ { label: "Edit" },
+ ]
+ : [{ label: "New Experiment" }]),
+ ]),
];
useBreadcrumbsEffect(breadcrumbs);
@@ -153,14 +153,14 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
...data,
estimatedDuration: data.estimatedDuration ?? undefined,
});
- router.push(`/experiments/${newExperiment.id}/designer`);
+ router.push(`/studies/${data.studyId}/experiments/${newExperiment.id}/designer`);
} else {
const updatedExperiment = await updateExperimentMutation.mutateAsync({
id: experimentId!,
...data,
estimatedDuration: data.estimatedDuration ?? undefined,
});
- router.push(`/experiments/${updatedExperiment.id}`);
+ router.push(`/studies/${experiment?.studyId ?? data.studyId}/experiments/${updatedExperiment.id}`);
}
} catch (error) {
setError(
diff --git a/src/components/experiments/ExperimentsGrid.tsx b/src/components/experiments/ExperimentsGrid.tsx
old mode 100644
new mode 100755
index 5c75de8..90154fc
--- a/src/components/experiments/ExperimentsGrid.tsx
+++ b/src/components/experiments/ExperimentsGrid.tsx
@@ -78,7 +78,7 @@ function ExperimentCard({ experiment }: ExperimentCardProps) {
{experiment.name}
@@ -158,10 +158,10 @@ function ExperimentCard({ experiment }: ExperimentCardProps) {
{/* Actions */}
- View Details
+ View Details
-
+
Design
diff --git a/src/components/experiments/ExperimentsTable.tsx b/src/components/experiments/ExperimentsTable.tsx
old mode 100644
new mode 100755
index 9bc1e35..60c02af
--- a/src/components/experiments/ExperimentsTable.tsx
+++ b/src/components/experiments/ExperimentsTable.tsx
@@ -1,7 +1,7 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
-import { ArrowUpDown, MoreHorizontal } from "lucide-react";
+import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, LayoutTemplate, PlayCircle, Archive } from "lucide-react";
import * as React from "react";
import { formatDistanceToNow } from "date-fns";
@@ -103,7 +103,7 @@ export const columns: ColumnDef[] = [
{String(name)}
@@ -259,20 +259,26 @@ export const columns: ColumnDef
[] = [
navigator.clipboard.writeText(experiment.id)}
>
- Copy experiment ID
+
+ Copy ID
- View details
-
-
-
- Edit experiment
+
+
+ Details
-
- Open designer
+
+
+ Edit
+
+
+
+
+
+ Designer
@@ -280,12 +286,14 @@ export const columns: ColumnDef[] = [
- Create trial
+
+ Start Trial
- Archive experiment
+
+ Archive
diff --git a/src/components/experiments/designer/ActionRegistry.ts b/src/components/experiments/designer/ActionRegistry.ts
old mode 100644
new mode 100755
index 1a50458..d1277e4
--- a/src/components/experiments/designer/ActionRegistry.ts
+++ b/src/components/experiments/designer/ActionRegistry.ts
@@ -78,6 +78,7 @@ export class ActionRegistry {
parameters?: CoreBlockParam[];
timeoutMs?: number;
retryable?: boolean;
+ nestable?: boolean;
}
try {
@@ -139,6 +140,7 @@ export class ActionRegistry {
parameterSchemaRaw: {
parameters: block.parameters ?? [],
},
+ nestable: block.nestable,
};
this.actions.set(actionDef.id, actionDef);
@@ -180,31 +182,33 @@ export class ActionRegistry {
private loadFallbackActions(): void {
const fallbackActions: ActionDefinition[] = [
{
- id: "wizard_speak",
- type: "wizard_speak",
+ id: "wizard_say",
+ type: "wizard_say",
name: "Wizard Says",
description: "Wizard speaks to participant",
category: "wizard",
icon: "MessageSquare",
- color: "#3b82f6",
+ color: "#a855f7",
parameters: [
{
- id: "text",
- name: "Text to say",
+ id: "message",
+ name: "Message",
type: "text",
placeholder: "Hello, participant!",
required: true,
},
- ],
- source: { kind: "core", baseActionId: "wizard_speak" },
- execution: { transport: "internal", timeoutMs: 30000 },
- parameterSchemaRaw: {
- type: "object",
- properties: {
- text: { type: "string" },
+ {
+ id: "tone",
+ name: "Tone",
+ type: "select",
+ options: ["neutral", "friendly", "encouraging"],
+ value: "neutral",
},
- required: ["text"],
- },
+ ],
+ source: { kind: "core", baseActionId: "wizard_say" },
+ execution: { transport: "internal", timeoutMs: 30000 },
+ parameterSchemaRaw: {},
+ nestable: false,
},
{
id: "wait",
@@ -366,34 +370,34 @@ export class ActionRegistry {
const execution = action.ros2
? {
- transport: "ros2" as const,
- timeoutMs: action.timeout,
- retryable: action.retryable,
- ros2: {
- topic: action.ros2.topic,
- messageType: action.ros2.messageType,
- service: action.ros2.service,
- action: action.ros2.action,
- qos: action.ros2.qos,
- payloadMapping: action.ros2.payloadMapping,
- },
- }
+ transport: "ros2" as const,
+ timeoutMs: action.timeout,
+ retryable: action.retryable,
+ ros2: {
+ topic: action.ros2.topic,
+ messageType: action.ros2.messageType,
+ service: action.ros2.service,
+ action: action.ros2.action,
+ qos: action.ros2.qos,
+ payloadMapping: action.ros2.payloadMapping,
+ },
+ }
: action.rest
? {
- transport: "rest" as const,
- timeoutMs: action.timeout,
- retryable: action.retryable,
- rest: {
- method: action.rest.method,
- path: action.rest.path,
- headers: action.rest.headers,
- },
- }
+ transport: "rest" as const,
+ timeoutMs: action.timeout,
+ retryable: action.retryable,
+ rest: {
+ method: action.rest.method,
+ path: action.rest.path,
+ headers: action.rest.headers,
+ },
+ }
: {
- transport: "internal" as const,
- timeoutMs: action.timeout,
- retryable: action.retryable,
- };
+ transport: "internal" as const,
+ timeoutMs: action.timeout,
+ retryable: action.retryable,
+ };
const actionDef: ActionDefinition = {
id: `${plugin.robotId ?? plugin.id}.${action.id}`,
diff --git a/src/components/experiments/designer/DependencyInspector.tsx b/src/components/experiments/designer/DependencyInspector.tsx
old mode 100644
new mode 100755
index 8354818..3487c34
--- a/src/components/experiments/designer/DependencyInspector.tsx
+++ b/src/components/experiments/designer/DependencyInspector.tsx
@@ -57,6 +57,15 @@ export interface DependencyInspectorProps {
* Available action definitions from registry
*/
actionDefinitions: ActionDefinition[];
+ /**
+ * Study plugins with name and metadata
+ */
+ studyPlugins?: Array<{
+ id: string;
+ robotId: string;
+ name: string;
+ version: string;
+ }>;
/**
* Called when user wants to reconcile a drifted action
*/
@@ -80,6 +89,12 @@ function extractPluginDependencies(
steps: ExperimentStep[],
actionDefinitions: ActionDefinition[],
driftedActions: Set,
+ studyPlugins?: Array<{
+ id: string;
+ robotId: string;
+ name: string;
+ version: string;
+ }>,
): PluginDependency[] {
const dependencyMap = new Map();
@@ -134,9 +149,12 @@ function extractPluginDependencies(
dep.installedVersion = dep.version;
}
- // Set plugin name from first available definition
+ // Set plugin name from studyPlugins if available
if (availableActions[0]) {
- dep.name = availableActions[0].source.pluginId; // Could be enhanced with actual plugin name
+ const pluginMeta = studyPlugins?.find(
+ (p) => p.robotId === dep.pluginId,
+ );
+ dep.name = pluginMeta?.name ?? dep.pluginId;
}
}
});
@@ -247,7 +265,9 @@ function PluginDependencyItem({
-
{dependency.pluginId}
+
+ {dependency.name ?? dependency.pluginId}
+
- extractPluginDependencies(steps, actionDefinitions, actionSignatureDrift),
- [steps, actionDefinitions, actionSignatureDrift],
+ extractPluginDependencies(
+ steps,
+ actionDefinitions,
+ actionSignatureDrift,
+ studyPlugins,
+ ),
+ [steps, actionDefinitions, actionSignatureDrift, studyPlugins],
);
const drifts = useMemo(
diff --git a/src/components/experiments/designer/DesignerRoot.tsx b/src/components/experiments/designer/DesignerRoot.tsx
old mode 100644
new mode 100755
index 3b80e7e..73e0aa4
--- a/src/components/experiments/designer/DesignerRoot.tsx
+++ b/src/components/experiments/designer/DesignerRoot.tsx
@@ -1,9 +1,16 @@
"use client";
-import React, { useCallback, useEffect, useRef, useState } from "react";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import { toast } from "sonner";
-import { Play } from "lucide-react";
+import { Play, RefreshCw } from "lucide-react";
+import { cn } from "~/lib/utils";
import { PageHeader } from "~/components/ui/page-header";
import { Button } from "~/components/ui/button";
@@ -19,8 +26,10 @@ import {
MouseSensor,
TouchSensor,
KeyboardSensor,
+ closestCorners,
type DragEndEvent,
type DragStartEvent,
+ type DragOverEvent,
} from "@dnd-kit/core";
import { BottomStatusBar } from "./layout/BottomStatusBar";
import { ActionLibraryPanel } from "./panels/ActionLibraryPanel";
@@ -150,20 +159,28 @@ export function DesignerRoot({
} = api.experiments.get.useQuery({ id: experimentId });
const updateExperiment = api.experiments.update.useMutation({
- onSuccess: async () => {
- toast.success("Experiment saved");
- await refetchExperiment();
- },
onError: (err) => {
toast.error(`Save failed: ${err.message}`);
},
});
- const { data: studyPlugins } = api.robots.plugins.getStudyPlugins.useQuery(
+ const { data: studyPluginsRaw } = api.robots.plugins.getStudyPlugins.useQuery(
{ studyId: experiment?.studyId ?? "" },
{ enabled: !!experiment?.studyId },
);
+ // Map studyPlugins to format expected by DependencyInspector
+ const studyPlugins = useMemo(
+ () =>
+ studyPluginsRaw?.map((sp) => ({
+ id: sp.plugin.id,
+ robotId: sp.plugin.robotId ?? "",
+ name: sp.plugin.name,
+ version: sp.plugin.version,
+ })),
+ [studyPluginsRaw],
+ );
+
/* ------------------------------ Store Access ----------------------------- */
const steps = useDesignerStore((s) => s.steps);
const setSteps = useDesignerStore((s) => s.setSteps);
@@ -230,6 +247,7 @@ export function DesignerRoot({
const [isSaving, setIsSaving] = useState(false);
const [isValidating, setIsValidating] = useState(false);
const [isExporting, setIsExporting] = useState(false);
+ const [isReady, setIsReady] = useState(false); // Track when everything is loaded
const [lastSavedAt, setLastSavedAt] = useState(undefined);
const [inspectorTab, setInspectorTab] = useState<
@@ -250,6 +268,13 @@ export function DesignerRoot({
useEffect(() => {
if (initialized) return;
if (loadingExperiment && !initialDesign) return;
+
+ console.log('[DesignerRoot] 🚀 INITIALIZING', {
+ hasExperiment: !!experiment,
+ hasInitialDesign: !!initialDesign,
+ loadingExperiment,
+ });
+
const adapted =
initialDesign ??
(experiment
@@ -274,8 +299,9 @@ export function DesignerRoot({
setValidatedHash(ih);
}
setInitialized(true);
- // Kick initial hash
- void recomputeHash();
+ // NOTE: We don't call recomputeHash() here because the automatic
+ // hash recomputation useEffect will trigger when setSteps() updates the steps array
+ console.log('[DesignerRoot] 🚀 Initialization complete, steps set');
}, [
initialized,
loadingExperiment,
@@ -299,26 +325,69 @@ export function DesignerRoot({
// Load plugin actions when study plugins 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]);
+ if (!studyPluginsRaw) return;
+ // @ts-expect-error - studyPluginsRaw type from tRPC is compatible but TypeScript can't infer it
+ actionRegistry.loadPluginActions(experiment.studyId, studyPluginsRaw);
+ }, [experiment?.studyId, studyPluginsRaw]);
+
+ /* ------------------------- Ready State Management ------------------------ */
+ // Mark as ready once initialized and plugins are loaded
+ useEffect(() => {
+ if (!initialized || isReady) return;
+
+ // Check if plugins are loaded by verifying the action registry has plugin actions
+ const debugInfo = actionRegistry.getDebugInfo();
+ const hasPlugins = debugInfo.pluginActionsLoaded;
+
+ if (hasPlugins) {
+ // Small delay to ensure all components have rendered
+ const timer = setTimeout(() => {
+ setIsReady(true);
+ console.log('[DesignerRoot] ✅ Designer ready (plugins loaded), fading in');
+ }, 150);
+ return () => clearTimeout(timer);
+ }
+ }, [initialized, isReady, studyPluginsRaw]);
+
+ /* ----------------------- Automatic Hash Recomputation -------------------- */
+ // Automatically recompute hash when steps change (debounced to avoid excessive computation)
+ useEffect(() => {
+ if (!initialized) return;
+
+ console.log('[DesignerRoot] Steps changed, scheduling hash recomputation', {
+ stepsCount: steps.length,
+ actionsCount: steps.reduce((sum, s) => sum + s.actions.length, 0),
+ });
+
+ const timeoutId = setTimeout(async () => {
+ console.log('[DesignerRoot] Executing debounced hash recomputation');
+ const result = await recomputeHash();
+ if (result) {
+ console.log('[DesignerRoot] Hash recomputed:', {
+ newHash: result.designHash.slice(0, 16),
+ fullHash: result.designHash,
+ });
+ }
+ }, 300); // Debounce 300ms
+
+ return () => clearTimeout(timeoutId);
+ }, [steps, initialized, recomputeHash]);
+
/* ----------------------------- Derived State ----------------------------- */
const hasUnsavedChanges =
!!currentDesignHash && lastPersistedHash !== currentDesignHash;
+ // Debug logging to track hash updates and save button state
+ useEffect(() => {
+ console.log('[DesignerRoot] Hash State:', {
+ currentDesignHash: currentDesignHash?.slice(0, 10),
+ lastPersistedHash: lastPersistedHash?.slice(0, 10),
+ hasUnsavedChanges,
+ stepsCount: steps.length,
+ });
+ }, [currentDesignHash, lastPersistedHash, hasUnsavedChanges, steps.length]);
+
/* ------------------------------- Step Ops -------------------------------- */
const createNewStep = useCallback(() => {
const newStep: ExperimentStep = {
@@ -386,8 +455,7 @@ export function DesignerRoot({
}
} catch (err) {
toast.error(
- `Validation error: ${
- err instanceof Error ? err.message : "Unknown error"
+ `Validation error: ${err instanceof Error ? err.message : "Unknown error"
}`,
);
} finally {
@@ -404,6 +472,14 @@ export function DesignerRoot({
/* --------------------------------- Save ---------------------------------- */
const persist = useCallback(async () => {
if (!initialized) return;
+
+ console.log('[DesignerRoot] 💾 SAVE initiated', {
+ stepsCount: steps.length,
+ actionsCount: steps.reduce((sum, s) => sum + s.actions.length, 0),
+ currentHash: currentDesignHash?.slice(0, 16),
+ lastPersistedHash: lastPersistedHash?.slice(0, 16),
+ });
+
setIsSaving(true);
try {
const visualDesign = {
@@ -411,15 +487,43 @@ export function DesignerRoot({
version: designMeta.version,
lastSaved: new Date().toISOString(),
};
- updateExperiment.mutate({
+
+ console.log('[DesignerRoot] 💾 Sending to server...', {
+ experimentId,
+ stepsCount: steps.length,
+ version: designMeta.version,
+ });
+
+ // Wait for mutation to complete
+ await updateExperiment.mutateAsync({
id: experimentId,
visualDesign,
createSteps: true,
compileExecution: autoCompile,
});
- // Optimistic hash recompute
- await recomputeHash();
+
+ console.log('[DesignerRoot] 💾 Server save successful');
+
+ // NOTE: We do NOT refetch here because it would reset the local steps state
+ // to the server state, which would cause the hash to match the persisted hash,
+ // preventing the save button from re-enabling on subsequent changes.
+ // The local state is already the source of truth after a successful save.
+
+ // Recompute hash and update persisted hash
+ const hashResult = await recomputeHash();
+ if (hashResult?.designHash) {
+ console.log('[DesignerRoot] 💾 Updated persisted hash:', {
+ newPersistedHash: hashResult.designHash.slice(0, 16),
+ fullHash: hashResult.designHash,
+ });
+ setPersistedHash(hashResult.designHash);
+ }
+
setLastSavedAt(new Date());
+ toast.success("Experiment saved");
+
+ console.log('[DesignerRoot] 💾 SAVE complete');
+
onPersist?.({
id: experimentId,
name: designMeta.name,
@@ -428,16 +532,22 @@ export function DesignerRoot({
version: designMeta.version,
lastSaved: new Date(),
});
+ } catch (error) {
+ console.error('[DesignerRoot] 💾 SAVE failed:', error);
+ // Error already handled by mutation onError
} finally {
setIsSaving(false);
}
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [
initialized,
steps,
designMeta,
experimentId,
- updateExperiment,
recomputeHash,
+ currentDesignHash,
+ setPersistedHash,
+ refetchExperiment,
onPersist,
autoCompile,
]);
@@ -479,8 +589,7 @@ export function DesignerRoot({
toast.success("Exported design bundle");
} catch (err) {
toast.error(
- `Export failed: ${
- err instanceof Error ? err.message : "Unknown error"
+ `Export failed: ${err instanceof Error ? err.message : "Unknown error"
}`,
);
} finally {
@@ -489,10 +598,11 @@ export function DesignerRoot({
}, [currentDesignHash, steps, experimentId, designMeta, experiment]);
/* ---------------------------- Incremental Hash --------------------------- */
- useEffect(() => {
- if (!initialized) return;
- void recomputeHash();
- }, [steps.length, initialized, recomputeHash]);
+ // Serialize steps for stable comparison
+ const stepsHash = useMemo(() => JSON.stringify(steps), [steps]);
+
+ // Intentionally removed redundant recomputeHash useEffect that was causing excessive refreshes
+ // The debounced useEffect (lines 352-372) handles this correctly.
useEffect(() => {
if (selectedStepId || selectedActionId) {
@@ -517,18 +627,10 @@ export function DesignerRoot({
) {
e.preventDefault();
void persist();
- } else if (e.key === "v" && !e.metaKey && !e.ctrlKey) {
- e.preventDefault();
- void validateDesign();
- } else if (e.key === "e" && !e.metaKey && !e.ctrlKey) {
- e.preventDefault();
- void handleExport();
- } else if (e.key === "n" && e.shiftKey) {
- e.preventDefault();
- createNewStep();
}
+ // 'v' (validate), 'e' (export), 'Shift+N' (new step) shortcuts removed to prevent accidents
},
- [hasUnsavedChanges, persist, validateDesign, handleExport, createNewStep],
+ [hasUnsavedChanges, persist],
);
useEffect(() => {
@@ -576,43 +678,163 @@ export function DesignerRoot({
[toggleLibraryScrollLock],
);
- const handleDragEnd = useCallback(
- async (event: DragEndEvent) => {
- const { active, over } = event;
- console.debug("[DesignerRoot] dragEnd", {
- active: active?.id,
- over: over?.id ?? null,
- });
- // Clear overlay immediately
- toggleLibraryScrollLock(false);
- setDragOverlayAction(null);
- if (!over) {
- console.debug("[DesignerRoot] dragEnd: no drop target (ignored)");
+ const handleDragOver = useCallback((event: DragOverEvent) => {
+ const { active, over } = event;
+ const store = useDesignerStore.getState();
+
+ // Only handle Library -> Flow projection
+ if (!active.id.toString().startsWith("action-")) {
+ if (store.insertionProjection) {
+ store.setInsertionProjection(null);
+ }
+ return;
+ }
+
+ if (!over) {
+ if (store.insertionProjection) {
+ store.setInsertionProjection(null);
+ }
+ return;
+ }
+
+ const overId = over.id.toString();
+ const activeDef = active.data.current?.action;
+
+ if (!activeDef) return;
+
+ let stepId: string | null = null;
+ let parentId: string | null = null;
+ let index = 0;
+
+ // Detect target based on over id
+ if (overId.startsWith("s-act-")) {
+ const data = over.data.current;
+ if (data && data.stepId) {
+ stepId = data.stepId;
+ parentId = data.parentId ?? null; // Use parentId from the action we are hovering over
+ // Use sortable index (insertion point provided by dnd-kit sortable strategy)
+ index = data.sortable?.index ?? 0;
+ }
+ } else if (overId.startsWith("container-")) {
+ // Dropping into a container (e.g. Loop)
+ const data = over.data.current;
+ if (data && data.stepId) {
+ stepId = data.stepId;
+ parentId = data.parentId ?? overId.slice("container-".length);
+ // If dropping into container, appending is a safe default if specific index logic is missing
+ // But actually we can find length if we want. For now, 0 or append logic?
+ // If container is empty, index 0 is correct.
+ // If not empty, we are hitting the container *background*, so append?
+ // The projection logic will insert at 'index'. If index is past length, it appends.
+ // Let's set a large index to ensure append, or look up length.
+ // Lookup requires finding the action in store. Expensive?
+ // Let's assume index 0 for now (prepend) or implement lookup.
+ // Better: lookup action -> children length.
+ const actionId = parentId;
+ const step = store.steps.find(s => s.id === stepId);
+ // Find action recursive? Store has `findActionById` helper but it is not exported/accessible easily here?
+ // Actually, `store.steps` is available.
+ // We can implement a quick BFS/DFS or just assume 0.
+ // If dragging over the container *background* (empty space), append is usually expected.
+ // Let's try 9999?
+ index = 9999;
+ }
+ } else if (overId.startsWith("s-step-") || overId.startsWith("step-")) {
+ // Container drop (Step)
+ stepId = overId.startsWith("s-step-")
+ ? overId.slice("s-step-".length)
+ : overId.slice("step-".length);
+ const step = store.steps.find((s) => s.id === stepId);
+ index = step ? step.actions.length : 0;
+
+ } else if (overId === "projection-placeholder") {
+ // Hovering over our own projection placeholder -> keep current state
+ return;
+ }
+
+ if (stepId) {
+ const current = store.insertionProjection;
+ // Optimization: avoid redundant updates if projection matches
+ if (
+ current &&
+ current.stepId === stepId &&
+ current.parentId === parentId &&
+ current.index === index
+ ) {
return;
}
- // Expect dragged action (library) onto a step droppable
- const activeId = active.id.toString();
- const overId = over.id.toString();
+ store.setInsertionProjection({
+ stepId,
+ parentId,
+ index,
+ action: {
+ id: "projection-placeholder",
+ type: activeDef.type,
+ name: activeDef.name,
+ category: activeDef.category,
+ description: "Drop here",
+ source: activeDef.source || { kind: "library" },
+ parameters: {},
+ execution: activeDef.execution,
+ } as any,
+ });
+ } else {
+ if (store.insertionProjection) store.setInsertionProjection(null);
+ }
+ }, []);
- if (activeId.startsWith("action-") && active.data.current?.action) {
- // Resolve stepId from possible over ids: step-, s-step-, or s-act-
- let stepId: string | null = null;
+ const handleDragEnd = useCallback(
+ async (event: DragEndEvent) => {
+ const { active, over } = event;
+
+ // Clear overlay immediately
+ toggleLibraryScrollLock(false);
+ setDragOverlayAction(null);
+
+ // Capture and clear projection
+ const store = useDesignerStore.getState();
+ const projection = store.insertionProjection;
+ store.setInsertionProjection(null);
+
+ if (!over) {
+ return;
+ }
+
+ // 1. Determine Target (Step, Parent, Index)
+ let stepId: string | null = null;
+ let parentId: string | null = null;
+ let index: number | undefined = undefined;
+
+ if (projection) {
+ stepId = projection.stepId;
+ parentId = projection.parentId;
+ index = projection.index;
+ } else {
+ // Fallback: resolution from overId (if projection failed or raced)
+ const overId = over.id.toString();
if (overId.startsWith("step-")) {
stepId = overId.slice("step-".length);
} else if (overId.startsWith("s-step-")) {
stepId = overId.slice("s-step-".length);
} else if (overId.startsWith("s-act-")) {
+ // This might fail if s-act-projection, but that should have covered by projection check above
const actionId = overId.slice("s-act-".length);
const parent = steps.find((s) =>
s.actions.some((a) => a.id === actionId),
);
stepId = parent?.id ?? null;
}
- if (!stepId) return;
+ }
+ if (!stepId) return;
+ const targetStep = steps.find((s) => s.id === stepId);
+ if (!targetStep) return;
+
+ // 2. Instantiate Action
+ if (active.id.toString().startsWith("action-") && active.data.current?.action) {
const actionDef = active.data.current.action as {
- id: string;
+ id: string; // type
type: string;
name: string;
category: string;
@@ -622,51 +844,82 @@ export function DesignerRoot({
parameters: Array<{ id: string; name: string }>;
};
- const targetStep = steps.find((s) => s.id === stepId);
- if (!targetStep) return;
+ const fullDef = actionRegistry.getAction(actionDef.type);
+ const defaultParams: Record = {};
+ if (fullDef?.parameters) {
+ for (const param of fullDef.parameters) {
+ // @ts-expect-error - 'default' property access
+ if (param.default !== undefined) {
+ // @ts-expect-error - 'default' property access
+ defaultParams[param.id] = param.default;
+ }
+ }
+ }
const execution: ExperimentAction["execution"] =
actionDef.execution &&
- (actionDef.execution.transport === "internal" ||
- actionDef.execution.transport === "rest" ||
- actionDef.execution.transport === "ros2")
+ (actionDef.execution.transport === "internal" ||
+ actionDef.execution.transport === "rest" ||
+ actionDef.execution.transport === "ros2")
? {
- transport: actionDef.execution.transport,
- retryable: actionDef.execution.retryable ?? false,
- }
- : {
- transport: "internal",
- retryable: false,
- };
+ transport: actionDef.execution.transport,
+ retryable: actionDef.execution.retryable ?? false,
+ }
+ : undefined;
+
const newAction: ExperimentAction = {
- id: `action-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
- type: actionDef.type,
+ id: crypto.randomUUID(),
+ type: actionDef.type, // this is the 'type' key
name: actionDef.name,
- category: actionDef.category as ExperimentAction["category"],
- parameters: {},
- source: actionDef.source as ExperimentAction["source"],
+ category: actionDef.category as any,
+ description: "",
+ parameters: defaultParams,
+ source: actionDef.source ? {
+ kind: actionDef.source.kind as any,
+ pluginId: actionDef.source.pluginId,
+ pluginVersion: actionDef.source.pluginVersion,
+ baseActionId: actionDef.id
+ } : { kind: "core" },
execution,
+ children: [],
};
- upsertAction(stepId, newAction);
- // Select the newly added action and open properties
- selectStep(stepId);
+ // 3. Commit
+ upsertAction(stepId, newAction, parentId, index);
+
+ // Auto-select
selectAction(stepId, newAction.id);
- setInspectorTab("properties");
- await recomputeHash();
- toast.success(`Added ${actionDef.name} to ${targetStep.name}`);
+
+ void recomputeHash();
}
},
- [
- steps,
- upsertAction,
- recomputeHash,
- selectStep,
- selectAction,
- toggleLibraryScrollLock,
- ],
+ [steps, upsertAction, selectAction, recomputeHash, toggleLibraryScrollLock],
);
// validation status badges removed (unused)
+ /* ------------------------------- Panels ---------------------------------- */
+ const leftPanel = useMemo(
+ () => (
+
+ ),
+ [],
+ );
+
+ const centerPanel = useMemo(() => , []);
+
+ const rightPanel = useMemo(
+ () => (
+
+
+
+ ),
+ [inspectorTab, studyPlugins],
+ );
/* ------------------------------- Render ---------------------------------- */
if (loadingExperiment && !initialized) {
@@ -677,80 +930,105 @@ export function DesignerRoot({
);
}
+ const actions = (
+
+ validateDesign()}
+ disabled={isValidating}
+ >
+ Validate
+
+ persist()}
+ disabled={!hasUnsavedChanges || isSaving}
+ >
+ Save
+
+
+ );
+
return (
-
+
- validateDesign()}
- disabled={isValidating}
- >
- Validate
-
- persist()}
- disabled={!hasUnsavedChanges || isSaving}
- >
- Save
-
-
- }
+ actions={actions}
+ className="pb-6"
/>
-
-
toggleLibraryScrollLock(false)}
+
+ {/* Loading Overlay */}
+ {!isReady && (
+
+
+
+
Loading designer...
+
+
+ )}
+
+ {/* Main Content - Fade in when ready */}
+
- }
- center={
}
- right={
-
-
-
- }
- />
-
- {dragOverlayAction ? (
-
- {dragOverlayAction.name}
-
- ) : null}
-
-
-
-
persist()}
- onValidate={() => validateDesign()}
- onExport={() => handleExport()}
- lastSavedAt={lastSavedAt}
- saving={isSaving}
- validating={isValidating}
- exporting={isExporting}
- />
+
+
toggleLibraryScrollLock(false)}
+ >
+
+
+ {dragOverlayAction ? (
+
+
+ {dragOverlayAction.name}
+
+ ) : null}
+
+
+
+ persist()}
+ onValidate={() => validateDesign()}
+ onExport={() => handleExport()}
+ onRecalculateHash={() => recomputeHash()}
+ lastSavedAt={lastSavedAt}
+ saving={isSaving}
+ validating={isValidating}
+ exporting={isExporting}
+ />
+
+
diff --git a/src/components/experiments/designer/PropertiesPanel.tsx b/src/components/experiments/designer/PropertiesPanel.tsx
old mode 100644
new mode 100755
index 20ad8fa..0318cea
--- a/src/components/experiments/designer/PropertiesPanel.tsx
+++ b/src/components/experiments/designer/PropertiesPanel.tsx
@@ -1,6 +1,6 @@
"use client";
-import React from "react";
+import React, { useState, useEffect, useCallback, useRef } from "react";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
@@ -70,7 +70,7 @@ export interface PropertiesPanelProps {
className?: string;
}
-export function PropertiesPanel({
+export function PropertiesPanelBase({
design,
selectedStep,
selectedAction,
@@ -80,6 +80,85 @@ export function PropertiesPanel({
}: PropertiesPanelProps) {
const registry = actionRegistry;
+ // Local state for controlled inputs
+ const [localActionName, setLocalActionName] = useState("");
+ const [localStepName, setLocalStepName] = useState("");
+ const [localStepDescription, setLocalStepDescription] = useState("");
+ const [localParams, setLocalParams] = useState
>({});
+
+ // Debounce timers
+ const actionUpdateTimer = useRef(undefined);
+ const stepUpdateTimer = useRef(undefined);
+ const paramUpdateTimers = useRef(new Map());
+
+ // Sync local state when selection ID changes (not on every object recreation)
+ useEffect(() => {
+ if (selectedAction) {
+ setLocalActionName(selectedAction.name);
+ setLocalParams(selectedAction.parameters);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedAction?.id]);
+
+ useEffect(() => {
+ if (selectedStep) {
+ setLocalStepName(selectedStep.name);
+ setLocalStepDescription(selectedStep.description ?? "");
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedStep?.id]);
+
+ // Cleanup timers on unmount
+ useEffect(() => {
+ const timersMap = paramUpdateTimers.current;
+ return () => {
+ if (actionUpdateTimer.current) clearTimeout(actionUpdateTimer.current);
+ if (stepUpdateTimer.current) clearTimeout(stepUpdateTimer.current);
+ timersMap.forEach((timer) => clearTimeout(timer));
+ };
+ }, []);
+
+ // Debounced update handlers
+ const debouncedActionUpdate = useCallback(
+ (stepId: string, actionId: string, updates: Partial) => {
+ if (actionUpdateTimer.current) clearTimeout(actionUpdateTimer.current);
+ actionUpdateTimer.current = setTimeout(() => {
+ onActionUpdate(stepId, actionId, updates);
+ }, 300);
+ },
+ [onActionUpdate],
+ );
+
+ const debouncedStepUpdate = useCallback(
+ (stepId: string, updates: Partial) => {
+ if (stepUpdateTimer.current) clearTimeout(stepUpdateTimer.current);
+ stepUpdateTimer.current = setTimeout(() => {
+ onStepUpdate(stepId, updates);
+ }, 300);
+ },
+ [onStepUpdate],
+ );
+
+ const debouncedParamUpdate = useCallback(
+ (stepId: string, actionId: string, paramId: string, value: unknown) => {
+ const existing = paramUpdateTimers.current.get(paramId);
+ if (existing) clearTimeout(existing);
+
+ const timer = setTimeout(() => {
+ onActionUpdate(stepId, actionId, {
+ parameters: {
+ ...selectedAction?.parameters,
+ [paramId]: value,
+ },
+ });
+ paramUpdateTimers.current.delete(paramId);
+ }, 300);
+
+ paramUpdateTimers.current.set(paramId, timer);
+ },
+ [onActionUpdate, selectedAction?.parameters],
+ );
+
// Find containing step for selected action (if any)
const containingStep =
selectedAction &&
@@ -119,8 +198,8 @@ export function PropertiesPanel({
const ResolvedIcon: React.ComponentType<{ className?: string }> =
def?.icon && iconComponents[def.icon]
? (iconComponents[def.icon] as React.ComponentType<{
- className?: string;
- }>)
+ className?: string;
+ }>)
: Zap;
return (
@@ -176,12 +255,21 @@ export function PropertiesPanel({
Display Name
- onActionUpdate(containingStep.id, selectedAction.id, {
- name: e.target.value,
- })
- }
+ value={localActionName}
+ onChange={(e) => {
+ const newName = e.target.value;
+ setLocalActionName(newName);
+ debouncedActionUpdate(containingStep.id, selectedAction.id, {
+ name: newName,
+ });
+ }}
+ onBlur={() => {
+ if (localActionName !== selectedAction.name) {
+ onActionUpdate(containingStep.id, selectedAction.id, {
+ name: localActionName,
+ });
+ }
+ }}
className="mt-1 h-7 w-full text-xs"
/>
@@ -194,148 +282,22 @@ export function PropertiesPanel({
Parameters
- {def.parameters.map((param) => {
- const rawValue = selectedAction.parameters[param.id];
- const commonLabel = (
-
- {param.name}
-
- {param.type === "number" &&
- (param.min !== undefined || param.max !== undefined) &&
- typeof rawValue === "number" &&
- `( ${rawValue} )`}
-
-
- );
-
- /* ---- Handlers ---- */
- const updateParamValue = (value: unknown) => {
- onActionUpdate(containingStep.id, selectedAction.id, {
- parameters: {
- ...selectedAction.parameters,
- [param.id]: value,
- },
- });
- };
-
- /* ---- Control Rendering ---- */
- let control: React.ReactNode = null;
-
- if (param.type === "text") {
- control = (
-
updateParamValue(e.target.value)}
- className="mt-1 h-7 w-full text-xs"
- />
- );
- } else if (param.type === "select") {
- control = (
-
updateParamValue(val)}
- >
-
-
-
-
- {param.options?.map((opt) => (
-
- {opt}
-
- ))}
-
-
- );
- } else if (param.type === "boolean") {
- control = (
-
- updateParamValue(val)}
- aria-label={param.name}
- />
-
- {Boolean(rawValue) ? "Enabled" : "Disabled"}
-
-
- );
- } else if (param.type === "number") {
- const numericVal =
- typeof rawValue === "number"
- ? rawValue
- : typeof param.value === "number"
- ? param.value
- : (param.min ?? 0);
-
- if (param.min !== undefined || param.max !== undefined) {
- const min = param.min ?? 0;
- const max =
- param.max ??
- Math.max(
- min + 1,
- Number.isFinite(numericVal) ? numericVal : min + 1,
- );
- // Step heuristic
- const range = max - min;
- const step =
- param.step ??
- (range <= 5
- ? 0.1
- : range <= 50
- ? 0.5
- : Math.max(1, Math.round(range / 100)));
- control = (
-
-
-
- updateParamValue(vals[0])
- }
- />
-
- {step < 1
- ? Number(numericVal).toFixed(2)
- : Number(numericVal).toString()}
-
-
-
- {min}
- {max}
-
-
- );
- } else {
- control = (
-
- updateParamValue(parseFloat(e.target.value) || 0)
- }
- className="mt-1 h-7 w-full text-xs"
- />
- );
- }
- }
-
- return (
-
- {commonLabel}
- {param.description && (
-
- {param.description}
-
- )}
- {control}
-
- );
- })}
+ {def.parameters.map((param) => (
+
{
+ onActionUpdate(containingStep.id, selectedAction.id, {
+ parameters: {
+ ...selectedAction.parameters,
+ [param.id]: val,
+ },
+ });
+ }}
+ onCommit={() => { }}
+ />
+ ))}
) : (
@@ -373,23 +335,41 @@ export function PropertiesPanel({
Name
- onStepUpdate(selectedStep.id, { name: e.target.value })
- }
+ value={localStepName}
+ onChange={(e) => {
+ const newName = e.target.value;
+ setLocalStepName(newName);
+ debouncedStepUpdate(selectedStep.id, { name: newName });
+ }}
+ onBlur={() => {
+ if (localStepName !== selectedStep.name) {
+ onStepUpdate(selectedStep.id, { name: localStepName });
+ }
+ }}
className="mt-1 h-7 w-full text-xs"
/>
Description
- onStepUpdate(selectedStep.id, {
- description: e.target.value,
- })
- }
+ onChange={(e) => {
+ const newDesc = e.target.value;
+ setLocalStepDescription(newDesc);
+ debouncedStepUpdate(selectedStep.id, {
+ description: newDesc,
+ });
+ }}
+ onBlur={() => {
+ if (
+ localStepDescription !== (selectedStep.description ?? "")
+ ) {
+ onStepUpdate(selectedStep.id, {
+ description: localStepDescription,
+ });
+ }
+ }}
className="mt-1 h-7 w-full text-xs"
/>
@@ -405,9 +385,9 @@ export function PropertiesPanel({
Type
- onStepUpdate(selectedStep.id, { type: val as StepType })
- }
+ onValueChange={(val) => {
+ onStepUpdate(selectedStep.id, { type: val as StepType });
+ }}
>
@@ -424,14 +404,14 @@ export function PropertiesPanel({
Trigger
+ onValueChange={(val) => {
onStepUpdate(selectedStep.id, {
trigger: {
...selectedStep.trigger,
type: val as TriggerType,
},
- })
- }
+ });
+ }}
>
@@ -470,3 +450,158 @@ export function PropertiesPanel({
);
}
+
+export const PropertiesPanel = React.memo(PropertiesPanelBase);
+
+/* -------------------------------------------------------------------------- */
+/* Isolated Parameter Editor (Optimized) */
+/* -------------------------------------------------------------------------- */
+
+interface ParameterEditorProps {
+ param: any;
+ value: unknown;
+ onUpdate: (value: unknown) => void;
+ onCommit: () => void;
+}
+
+const ParameterEditor = React.memo(function ParameterEditor({
+ param,
+ value: rawValue,
+ onUpdate,
+ onCommit
+}: ParameterEditorProps) {
+ // Local state for immediate feedback
+ const [localValue, setLocalValue] = useState(rawValue);
+ const debounceRef = useRef();
+
+ // Sync from prop if it changes externally
+ useEffect(() => {
+ setLocalValue(rawValue);
+ }, [rawValue]);
+
+ const handleUpdate = useCallback((newVal: unknown, immediate = false) => {
+ setLocalValue(newVal);
+
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+
+ if (immediate) {
+ onUpdate(newVal);
+ } else {
+ debounceRef.current = setTimeout(() => {
+ onUpdate(newVal);
+ }, 300);
+ }
+ }, [onUpdate]);
+
+ const handleCommit = useCallback(() => {
+ if (localValue !== rawValue) {
+ onUpdate(localValue);
+ }
+ }, [localValue, rawValue, onUpdate]);
+
+ let control: React.ReactNode = null;
+
+ if (param.type === "text") {
+ control = (
+ handleUpdate(e.target.value)}
+ onBlur={handleCommit}
+ className="mt-1 h-7 w-full text-xs"
+ />
+ );
+ } else if (param.type === "select") {
+ control = (
+ handleUpdate(val, true)}
+ >
+
+
+
+
+ {param.options?.map((opt: string) => (
+
+ {opt}
+
+ ))}
+
+
+ );
+ } else if (param.type === "boolean") {
+ control = (
+
+ handleUpdate(val, true)}
+ aria-label={param.name}
+ />
+
+ {Boolean(localValue) ? "Enabled" : "Disabled"}
+
+
+ );
+ } else if (param.type === "number") {
+ const numericVal = typeof localValue === "number" ? localValue : (param.min ?? 0);
+
+ if (param.min !== undefined || param.max !== undefined) {
+ const min = param.min ?? 0;
+ const max = param.max ?? Math.max(min + 1, Number.isFinite(numericVal) ? numericVal : min + 1);
+ const range = max - min;
+ const step = param.step ?? (range <= 5 ? 0.1 : range <= 50 ? 0.5 : Math.max(1, Math.round(range / 100)));
+
+ control = (
+
+
+ setLocalValue(vals[0])} // Update only local visual
+ onPointerUp={() => handleUpdate(localValue)} // Commit on release
+ />
+
+ {step < 1 ? Number(numericVal).toFixed(2) : Number(numericVal).toString()}
+
+
+
+ {min}
+ {max}
+
+
+ );
+ } else {
+ control = (
+ handleUpdate(parseFloat(e.target.value) || 0)}
+ onBlur={handleCommit}
+ className="mt-1 h-7 w-full text-xs"
+ />
+ );
+ }
+ }
+
+ return (
+
+
+ {param.name}
+
+ {param.type === "number" &&
+ (param.min !== undefined || param.max !== undefined) &&
+ typeof rawValue === "number" &&
+ `( ${rawValue} )`}
+
+
+ {param.description && (
+
+ {param.description}
+
+ )}
+ {control}
+
+ );
+});
diff --git a/src/components/experiments/designer/StepPreview.tsx b/src/components/experiments/designer/StepPreview.tsx
old mode 100644
new mode 100755
diff --git a/src/components/experiments/designer/ValidationPanel.tsx b/src/components/experiments/designer/ValidationPanel.tsx
old mode 100644
new mode 100755
diff --git a/src/components/experiments/designer/flow/FlowWorkspace.tsx b/src/components/experiments/designer/flow/FlowWorkspace.tsx
old mode 100644
new mode 100755
index 5e9ae41..047ab89
--- a/src/components/experiments/designer/flow/FlowWorkspace.tsx
+++ b/src/components/experiments/designer/flow/FlowWorkspace.tsx
@@ -12,6 +12,7 @@ import {
useDndMonitor,
type DragEndEvent,
type DragStartEvent,
+ type DragOverEvent,
} from "@dnd-kit/core";
import {
useSortable,
@@ -68,7 +69,7 @@ interface FlowWorkspaceProps {
onActionCreate?: (stepId: string, action: ExperimentAction) => void;
}
-interface VirtualItem {
+export interface VirtualItem {
index: number;
top: number;
height: number;
@@ -77,6 +78,232 @@ interface VirtualItem {
visible: boolean;
}
+interface StepRowProps {
+ item: VirtualItem;
+ selectedStepId: string | null | undefined;
+ selectedActionId: string | null | undefined;
+ renamingStepId: string | null;
+ onSelectStep: (id: string | undefined) => void;
+ onSelectAction: (stepId: string, actionId: string | undefined) => void;
+ onToggleExpanded: (step: ExperimentStep) => void;
+ onRenameStep: (step: ExperimentStep, name: string) => void;
+ onDeleteStep: (step: ExperimentStep) => void;
+ onDeleteAction: (stepId: string, actionId: string) => void;
+ setRenamingStepId: (id: string | null) => void;
+ registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void;
+}
+
+const StepRow = React.memo(function StepRow({
+ item,
+ selectedStepId,
+ selectedActionId,
+ renamingStepId,
+ onSelectStep,
+ onSelectAction,
+ onToggleExpanded,
+ onRenameStep,
+ onDeleteStep,
+ onDeleteAction,
+ setRenamingStepId,
+ registerMeasureRef,
+}: StepRowProps) {
+ const step = item.step;
+ const insertionProjection = useDesignerStore((s) => s.insertionProjection);
+
+ const displayActions = useMemo(() => {
+ if (
+ insertionProjection?.stepId === step.id &&
+ insertionProjection.parentId === null
+ ) {
+ const copy = [...step.actions];
+ // Insert placeholder action
+ // Ensure specific ID doesn't crash keys if collision (collision unlikely for library items)
+ // Actually, standard array key is action.id.
+ copy.splice(insertionProjection.index, 0, insertionProjection.action);
+ return copy;
+ }
+ return step.actions;
+ }, [step.actions, step.id, insertionProjection]);
+
+ const {
+ setNodeRef,
+ transform,
+ transition,
+ attributes,
+ listeners,
+ isDragging,
+ } = useSortable({
+ id: sortableStepId(step.id),
+ data: {
+ type: "step",
+ step: step,
+ },
+ });
+
+ const style: React.CSSProperties = {
+ position: "absolute",
+ top: item.top,
+ left: 0,
+ right: 0,
+ width: "100%",
+ transform: CSS.Transform.toString(transform),
+ transition,
+ zIndex: isDragging ? 25 : undefined,
+ };
+
+ return (
+
+
registerMeasureRef(step.id, el)}
+ className="relative px-3 py-4"
+ data-step-id={step.id}
+ >
+
+
+
{
+ const tag = (e.target as HTMLElement).tagName.toLowerCase();
+ if (tag === "input" || tag === "textarea" || tag === "button")
+ return;
+ onSelectStep(step.id);
+ onSelectAction(step.id, undefined);
+ }}
+ role="button"
+ tabIndex={0}
+ >
+
+
{
+ e.stopPropagation();
+ onToggleExpanded(step);
+ }}
+ className="text-muted-foreground hover:bg-accent/60 hover:text-foreground rounded p-1"
+ aria-label={step.expanded ? "Collapse step" : "Expand step"}
+ >
+ {step.expanded ? (
+
+ ) : (
+
+ )}
+
+
+ {step.order + 1}
+
+ {renamingStepId === step.id ? (
+
e.stopPropagation()}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ onRenameStep(
+ step,
+ (e.target as HTMLInputElement).value.trim() ||
+ step.name,
+ );
+ setRenamingStepId(null);
+ } else if (e.key === "Escape") {
+ setRenamingStepId(null);
+ }
+ }}
+ onBlur={(e) => {
+ onRenameStep(step, e.target.value.trim() || step.name);
+ setRenamingStepId(null);
+ }}
+ />
+ ) : (
+
+ {step.name}
+ {
+ e.stopPropagation();
+ setRenamingStepId(step.id);
+ }}
+ >
+
+
+
+ )}
+
+ {step.actions.length} actions
+
+
+
+
{
+ e.stopPropagation();
+ onDeleteStep(step);
+ }}
+ aria-label="Delete step"
+ >
+
+
+
+
+
+
+
+
+ {/* Action List (Collapsible/Virtual content) */}
+ {step.expanded && (
+
+
sortableActionId(a.id))}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {displayActions.length === 0 ? (
+
+ Drop actions here
+
+ ) : (
+ displayActions.map((action) => (
+
+ ))
+ )}
+
+
+
+ )}
+
+
+
+ );
+});
+
/* -------------------------------------------------------------------------- */
/* Utility */
/* -------------------------------------------------------------------------- */
@@ -111,7 +338,7 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
className={cn(
"pointer-events-none absolute inset-0 rounded-md transition-colors",
isOver &&
- "bg-blue-50/40 ring-2 ring-blue-400/60 ring-offset-0 dark:bg-blue-950/20",
+ "bg-blue-50/40 ring-2 ring-blue-400/60 ring-offset-0 dark:bg-blue-950/20",
)}
/>
);
@@ -122,37 +349,125 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
/* -------------------------------------------------------------------------- */
interface ActionChipProps {
+ stepId: string;
action: ExperimentAction;
- isSelected: boolean;
- onSelect: () => void;
- onDelete: () => void;
+ parentId: string | null;
+ selectedActionId: string | null | undefined;
+ onSelectAction: (stepId: string, actionId: string | undefined) => void;
+ onDeleteAction: (stepId: string, actionId: string) => void;
dragHandle?: boolean;
}
function SortableActionChip({
+ stepId,
action,
- isSelected,
- onSelect,
- onDelete,
+ parentId,
+ selectedActionId,
+ onSelectAction,
+ onDeleteAction,
+ dragHandle,
}: ActionChipProps) {
const def = actionRegistry.getAction(action.type);
+ const isSelected = selectedActionId === action.id;
+
+ const insertionProjection = useDesignerStore((s) => s.insertionProjection);
+ const displayChildren = useMemo(() => {
+ if (
+ insertionProjection?.stepId === stepId &&
+ insertionProjection.parentId === action.id
+ ) {
+ const copy = [...(action.children || [])];
+ copy.splice(insertionProjection.index, 0, insertionProjection.action);
+ return copy;
+ }
+ return action.children;
+ }, [action.children, action.id, stepId, insertionProjection]);
+
+ /* ------------------------------------------------------------------------ */
+ /* Main Sortable Logic */
+ /* ------------------------------------------------------------------------ */
+ const isPlaceholder = action.id === "projection-placeholder";
+
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
- isDragging,
+ isDragging: isSortableDragging,
} = useSortable({
id: sortableActionId(action.id),
+ disabled: isPlaceholder, // Disable sortable for placeholder
+ data: {
+ type: "action",
+ stepId,
+ parentId,
+ id: action.id,
+ },
});
- const style: React.CSSProperties = {
- transform: CSS.Transform.toString(transform),
+ // Use local dragging state or passed prop
+ const isDragging = isSortableDragging || dragHandle;
+
+ const style = {
+ transform: CSS.Translate.toString(transform),
transition,
- zIndex: isDragging ? 30 : undefined,
};
+ /* ------------------------------------------------------------------------ */
+ /* Nested Droppable (for control flow containers) */
+ /* ------------------------------------------------------------------------ */
+ const nestedDroppableId = `container-${action.id}`;
+ const {
+ isOver: isOverNested,
+ setNodeRef: setNestedNodeRef
+ } = useDroppable({
+ id: nestedDroppableId,
+ disabled: !def?.nestable || isPlaceholder, // Disable droppable for placeholder
+ data: {
+ type: "container",
+ stepId,
+ parentId: action.id,
+ action // Pass full action for projection logic
+ }
+ });
+
+ const shouldRenderChildren = def?.nestable;
+
+ if (isPlaceholder) {
+ const { setNodeRef: setPlaceholderRef } = useDroppable({
+ id: "projection-placeholder",
+ data: { type: "placeholder" }
+ });
+
+ // Render simplified placeholder without hooks refs
+ // We still render the content matching the action type for visual fidelity
+ return (
+
+
+
+ {def?.name ?? action.name}
+
+ {def?.description && (
+
+ {def.description}
+
+ )}
+
+ );
+ }
+
return (
{
+ e.stopPropagation();
+ onSelectAction(stepId, action.id);
+ }}
{...attributes}
role="button"
aria-pressed={isSelected}
@@ -182,11 +502,11 @@ function SortableActionChip({
"h-2.5 w-2.5 rounded-full",
def
? {
- wizard: "bg-blue-500",
- robot: "bg-emerald-500",
- control: "bg-amber-500",
- observation: "bg-purple-500",
- }[def.category]
+ wizard: "bg-blue-500",
+ robot: "bg-emerald-500",
+ control: "bg-amber-500",
+ observation: "bg-purple-500",
+ }[def.category]
: "bg-slate-400",
)}
/>
@@ -197,7 +517,7 @@ function SortableActionChip({
type="button"
onClick={(e) => {
e.stopPropagation();
- onDelete();
+ onDeleteAction(stepId, action.id);
}}
className="text-muted-foreground hover:text-foreground rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
aria-label="Delete action"
@@ -221,12 +541,45 @@ function SortableActionChip({
))}
{def.parameters.length > 4 && (
-
- +{def.parameters.length - 4} more
-
+ +{def.parameters.length - 4}
)}
) : null}
+
+ {/* Nested Actions Container */}
+ {shouldRenderChildren && (
+
+
c.id !== "projection-placeholder")
+ .map(c => sortableActionId(c.id))}
+ strategy={verticalListSortingStrategy}
+ >
+ {(displayChildren || action.children || []).map((child) => (
+
+ ))}
+ {(!displayChildren?.length && !action.children?.length) && (
+
+ Drag actions here
+
+ )}
+
+
+ )}
+
);
}
@@ -254,7 +607,7 @@ export function FlowWorkspace({
const removeAction = useDesignerStore((s) => s.removeAction);
const reorderStep = useDesignerStore((s) => s.reorderStep);
- const reorderAction = useDesignerStore((s) => s.reorderAction);
+ const moveAction = useDesignerStore((s) => s.moveAction);
const recomputeHash = useDesignerStore((s) => s.recomputeHash);
/* Local state */
@@ -382,7 +735,10 @@ export function FlowWorkspace({
description: "",
type: "sequential",
order: steps.length,
- trigger: { type: "trial_start", conditions: {} },
+ trigger:
+ steps.length === 0
+ ? { type: "trial_start", conditions: {} }
+ : { type: "previous_step", conditions: {} },
actions: [],
expanded: true,
};
@@ -472,34 +828,77 @@ export function FlowWorkspace({
}
}
}
- // Action reorder (within same parent only)
+ // Action reorder (supports nesting)
if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) {
- const fromActionId = parseSortableAction(activeId);
- const toActionId = parseSortableAction(overId);
- if (fromActionId && toActionId && fromActionId !== toActionId) {
- const fromParent = actionParentMap.get(fromActionId);
- const toParent = actionParentMap.get(toActionId);
- if (fromParent && toParent && fromParent === toParent) {
- const step = steps.find((s) => s.id === fromParent);
- if (step) {
- const fromIdx = step.actions.findIndex(
- (a) => a.id === fromActionId,
- );
- const toIdx = step.actions.findIndex((a) => a.id === toActionId);
- if (fromIdx >= 0 && toIdx >= 0) {
- reorderAction(step.id, fromIdx, toIdx);
- void recomputeHash();
- }
- }
+ const activeData = active.data.current;
+ const overData = over.data.current;
+
+ if (
+ activeData && overData &&
+ activeData.stepId === overData.stepId &&
+ activeData.type === 'action' && overData.type === 'action'
+ ) {
+ const stepId = activeData.stepId as string;
+ const activeActionId = activeData.action.id;
+ const overActionId = overData.action.id;
+
+ if (activeActionId !== overActionId) {
+ const newParentId = overData.parentId as string | null;
+ const newIndex = overData.sortable.index; // index within that parent's list
+
+ moveAction(stepId, activeActionId, newParentId, newIndex);
+ void recomputeHash();
}
}
}
},
- [steps, reorderStep, reorderAction, actionParentMap, recomputeHash],
+ [steps, reorderStep, moveAction, recomputeHash],
+ );
+
+ /* ------------------------------------------------------------------------ */
+ /* Drag Over (Live Sorting) */
+ /* ------------------------------------------------------------------------ */
+ const handleLocalDragOver = useCallback(
+ (event: DragOverEvent) => {
+ const { active, over } = event;
+ if (!over) return;
+
+ const activeId = active.id.toString();
+ const overId = over.id.toString();
+
+ // Only handle action reordering
+ if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) {
+ const activeData = active.data.current;
+ const overData = over.data.current;
+
+ if (
+ activeData &&
+ overData &&
+ activeData.type === 'action' &&
+ overData.type === 'action'
+ ) {
+ const activeActionId = activeData.action.id;
+ const overActionId = overData.action.id;
+ const activeStepId = activeData.stepId;
+ const overStepId = overData.stepId;
+ const activeParentId = activeData.parentId;
+ const overParentId = overData.parentId;
+
+ // If moving between different lists (parents/steps), move immediately to visualize snap
+ if (activeParentId !== overParentId || activeStepId !== overStepId) {
+ // Determine new index
+ // verification of safe move handled by store
+ moveAction(overStepId, activeActionId, overParentId, overData.sortable.index);
+ }
+ }
+ }
+ },
+ [moveAction]
);
useDndMonitor({
onDragStart: handleLocalDragStart,
+ onDragOver: handleLocalDragOver,
onDragEnd: handleLocalDragEnd,
onDragCancel: () => {
// no-op
@@ -509,204 +908,22 @@ export function FlowWorkspace({
/* ------------------------------------------------------------------------ */
/* Step Row (Sortable + Virtualized) */
/* ------------------------------------------------------------------------ */
- function StepRow({ item }: { item: VirtualItem }) {
- const step = item.step;
- const {
- setNodeRef,
- transform,
- transition,
- attributes,
- listeners,
- isDragging,
- } = useSortable({
- id: sortableStepId(step.id),
- });
+ // StepRow moved outside of component to prevent re-mounting on every render (flashing fix)
- const style: React.CSSProperties = {
- position: "absolute",
- top: item.top,
- left: 0,
- right: 0,
- width: "100%",
- transform: CSS.Transform.toString(transform),
- transition,
- zIndex: isDragging ? 25 : undefined,
- };
-
- const setMeasureRef = (el: HTMLDivElement | null) => {
- const prev = measureRefs.current.get(step.id) ?? null;
+ const registerMeasureRef = useCallback(
+ (stepId: string, el: HTMLDivElement | null) => {
+ const prev = measureRefs.current.get(stepId) ?? null;
if (prev && prev !== el) {
roRef.current?.unobserve(prev);
- measureRefs.current.delete(step.id);
+ measureRefs.current.delete(stepId);
}
if (el) {
- measureRefs.current.set(step.id, el);
+ measureRefs.current.set(stepId, el);
roRef.current?.observe(el);
}
- };
-
- return (
-
-
-
-
-
{
- // 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;
- selectStep(step.id);
- selectAction(step.id, undefined);
- }}
- role="button"
- tabIndex={0}
- >
-
-
{
- e.stopPropagation();
- toggleExpanded(step);
- }}
- className="text-muted-foreground hover:bg-accent/60 hover:text-foreground rounded p-1"
- aria-label={step.expanded ? "Collapse step" : "Expand step"}
- >
- {step.expanded ? (
-
- ) : (
-
- )}
-
-
- {step.order + 1}
-
- {renamingStepId === step.id ? (
-
e.stopPropagation()}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- renameStep(
- step,
- (e.target as HTMLInputElement).value.trim() ||
- step.name,
- );
- setRenamingStepId(null);
- void recomputeHash();
- } else if (e.key === "Escape") {
- setRenamingStepId(null);
- }
- }}
- onBlur={(e) => {
- renameStep(step, e.target.value.trim() || step.name);
- setRenamingStepId(null);
- void recomputeHash();
- }}
- />
- ) : (
-
- {step.name}
- {
- e.stopPropagation();
- setRenamingStepId(step.id);
- }}
- >
-
-
-
- )}
-
- {step.actions.length} actions
-
-
-
-
{
- e.stopPropagation();
- deleteStep(step);
- }}
- aria-label="Delete step"
- >
-
-
-
-
-
-
-
-
- {step.expanded && (
-
-
- {step.actions.length > 0 && (
-
sortableActionId(a.id))}
- strategy={verticalListSortingStrategy}
- >
-
- {step.actions.map((action) => (
- {
- selectStep(step.id);
- selectAction(step.id, action.id);
- }}
- onDelete={() => deleteAction(step.id, action.id)}
- />
- ))}
-
-
- )}
-
- {/* Persistent centered bottom drop hint */}
-
-
- Drop actions here
-
-
-
- )}
-
-
-
- );
- }
+ },
+ [],
+ );
/* ------------------------------------------------------------------------ */
/* Render */
@@ -767,7 +984,27 @@ export function FlowWorkspace({
>
{virtualItems.map(
- (vi) => vi.visible && ,
+ (vi) =>
+ vi.visible && (
+ {
+ renameStep(step, name);
+ void recomputeHash();
+ }}
+ onDeleteStep={deleteStep}
+ onDeleteAction={deleteAction}
+ setRenamingStepId={setRenamingStepId}
+ registerMeasureRef={registerMeasureRef}
+ />
+ ),
)}
@@ -777,4 +1014,6 @@ export function FlowWorkspace({
);
}
-export default FlowWorkspace;
+// Wrap in React.memo to prevent unnecessary re-renders causing flashing
+export default React.memo(FlowWorkspace);
+
diff --git a/src/components/experiments/designer/layout/BottomStatusBar.tsx b/src/components/experiments/designer/layout/BottomStatusBar.tsx
old mode 100644
new mode 100755
index 1d517d4..2a998ef
--- a/src/components/experiments/designer/layout/BottomStatusBar.tsx
+++ b/src/components/experiments/designer/layout/BottomStatusBar.tsx
@@ -40,7 +40,7 @@ export interface BottomStatusBarProps {
onValidate?: () => void;
onExport?: () => void;
onOpenCommandPalette?: () => void;
- onToggleVersionStrategy?: () => void;
+ onRecalculateHash?: () => void;
className?: string;
saving?: boolean;
validating?: boolean;
@@ -56,7 +56,7 @@ export function BottomStatusBar({
onValidate,
onExport,
onOpenCommandPalette,
- onToggleVersionStrategy,
+ onRecalculateHash,
className,
saving,
validating,
@@ -198,9 +198,9 @@ export function BottomStatusBar({
if (onOpenCommandPalette) onOpenCommandPalette();
}, [onOpenCommandPalette]);
- const handleToggleVersionStrategy = useCallback(() => {
- if (onToggleVersionStrategy) onToggleVersionStrategy();
- }, [onToggleVersionStrategy]);
+ const handleRecalculateHash = useCallback(() => {
+ if (onRecalculateHash) onRecalculateHash();
+ }, [onRecalculateHash]);
/* ------------------------------------------------------------------------ */
/* Render */
@@ -265,12 +265,21 @@ export function BottomStatusBar({
{autoSaveEnabled ? "auto-save on" : "auto-save off"}
-
- {versionStrategy.replace(/_/g, " ")}
+
+ {currentDesignHash?.slice(0, 16) ?? '—'}
+
+
+
> = ({
+ className: panelCls,
+ panelClassName,
+ contentClassName,
+ children,
+}) => (
+
+ );
+
export function PanelsContainer({
left,
center,
@@ -209,10 +233,10 @@ export function PanelsContainer({
// CSS variables for the grid fractions
const styleVars: React.CSSProperties & Record
= hasCenter
? {
- "--col-left": `${(hasLeft ? l : 0) * 100}%`,
- "--col-center": `${c * 100}%`,
- "--col-right": `${(hasRight ? r : 0) * 100}%`,
- }
+ "--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
@@ -229,28 +253,12 @@ export function PanelsContainer({
const centerDividers =
showDividers && hasCenter
? cn({
- "border-l": hasLeft,
- "border-r": hasRight,
- })
+ "border-l": hasLeft,
+ "border-r": hasRight,
+ })
: undefined;
- const Panel: React.FC> = ({
- className: panelCls,
- children,
- }) => (
-
- );
+
return (
- {hasLeft &&
{left} }
+ {hasLeft && (
+
+ {left}
+
+ )}
- {hasCenter &&
{center} }
+ {hasCenter && (
+
+ {center}
+
+ )}
- {hasRight &&
{right} }
+ {hasRight && (
+
+ {right}
+
+ )}
{/* Resize handles (only render where applicable) */}
{hasCenter && hasLeft && (
diff --git a/src/components/experiments/designer/panels/ActionLibraryPanel.tsx b/src/components/experiments/designer/panels/ActionLibraryPanel.tsx
old mode 100644
new mode 100755
index 51e676b..1a337ff
--- a/src/components/experiments/designer/panels/ActionLibraryPanel.tsx
+++ b/src/components/experiments/designer/panels/ActionLibraryPanel.tsx
@@ -88,8 +88,8 @@ function DraggableAction({
const style: React.CSSProperties = transform
? {
- transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
- }
+ transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
+ }
: {};
const IconComponent = iconMap[action.icon] ?? Sparkles;
@@ -174,7 +174,7 @@ export function ActionLibraryPanel() {
const [search, setSearch] = useState("");
const [selectedCategories, setSelectedCategories] = useState<
Set
- >(new Set(["wizard"]));
+ >(new Set(["wizard", "robot", "control", "observation"]));
const [favorites, setFavorites] = useState({
favorites: new Set(),
});
@@ -293,9 +293,7 @@ export function ActionLibraryPanel() {
setShowOnlyFavorites(false);
}, [categories]);
- useEffect(() => {
- setSelectedCategories(new Set(categories.map((c) => c.key)));
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
const filtered = useMemo(() => {
const activeCats = selectedCategories;
@@ -487,4 +485,6 @@ export function ActionLibraryPanel() {
);
}
-export default ActionLibraryPanel;
+// Wrap in React.memo to prevent unnecessary re-renders causing flashing in categories
+export default React.memo(ActionLibraryPanel);
+
diff --git a/src/components/experiments/designer/panels/InspectorPanel.tsx b/src/components/experiments/designer/panels/InspectorPanel.tsx
old mode 100644
new mode 100755
index fa50df0..850036d
--- a/src/components/experiments/designer/panels/InspectorPanel.tsx
+++ b/src/components/experiments/designer/panels/InspectorPanel.tsx
@@ -48,9 +48,18 @@ export interface InspectorPanelProps {
*/
onTabChange?: (tab: "properties" | "issues" | "dependencies") => void;
/**
- * Whether to auto-switch to properties tab when selection changes.
+ * If true, auto-switch to "properties" when a selection occurs.
*/
autoFocusOnSelection?: boolean;
+ /**
+ * Study plugins with name and metadata
+ */
+ studyPlugins?: Array<{
+ id: string;
+ robotId: string;
+ name: string;
+ version: string;
+ }>;
}
export function InspectorPanel({
@@ -58,6 +67,7 @@ export function InspectorPanel({
activeTab,
onTabChange,
autoFocusOnSelection = true,
+ studyPlugins,
}: InspectorPanelProps) {
/* ------------------------------------------------------------------------ */
/* Store Selectors */
@@ -274,14 +284,17 @@ export function InspectorPanel({
({
+ id: "design",
+ name: "Design",
+ description: "",
+ version: 1,
+ steps,
+ lastSaved: new Date(),
+ }),
+ [steps],
+ )}
selectedStep={selectedStep}
selectedAction={selectedAction}
onActionUpdate={handleActionUpdate}
@@ -339,6 +352,7 @@ export function InspectorPanel({
steps={steps}
actionSignatureDrift={actionSignatureDrift}
actionDefinitions={actionRegistry.getAllActions()}
+ studyPlugins={studyPlugins}
onReconcileAction={(actionId) => {
// Placeholder: future diff modal / signature update
diff --git a/src/components/experiments/designer/state/hashing.ts b/src/components/experiments/designer/state/hashing.ts
old mode 100644
new mode 100755
index f536103..302a698
--- a/src/components/experiments/designer/state/hashing.ts
+++ b/src/components/experiments/designer/state/hashing.ts
@@ -130,7 +130,7 @@ export interface DesignHashOptions {
}
const DEFAULT_OPTIONS: Required = {
- includeParameterValues: false,
+ includeParameterValues: true, // Changed to true so parameter changes trigger hash updates
includeActionNames: true,
includeStepNames: true,
};
@@ -155,8 +155,9 @@ function projectActionForDesign(
pluginVersion: action.source.pluginVersion,
baseActionId: action.source.baseActionId,
},
- execution: projectExecutionDescriptor(action.execution),
+ execution: action.execution ? projectExecutionDescriptor(action.execution) : null,
parameterKeysOrValues: parameterProjection,
+ children: action.children?.map(c => projectActionForDesign(c, options)) ?? [],
};
if (options.includeActionNames) {
@@ -175,16 +176,16 @@ function projectExecutionDescriptor(
timeoutMs: exec.timeoutMs ?? null,
ros2: exec.ros2
? {
- topic: exec.ros2.topic ?? null,
- service: exec.ros2.service ?? null,
- action: exec.ros2.action ?? null,
- }
+ topic: exec.ros2.topic ?? null,
+ service: exec.ros2.service ?? null,
+ action: exec.ros2.action ?? null,
+ }
: null,
rest: exec.rest
? {
- method: exec.rest.method,
- path: exec.rest.path,
- }
+ method: exec.rest.method,
+ path: exec.rest.path,
+ }
: null,
};
}
@@ -244,10 +245,10 @@ export async function computeActionSignature(
baseActionId: def.baseActionId ?? null,
execution: def.execution
? {
- transport: def.execution.transport,
- retryable: def.execution.retryable ?? false,
- timeoutMs: def.execution.timeoutMs ?? null,
- }
+ transport: def.execution.transport,
+ retryable: def.execution.retryable ?? false,
+ timeoutMs: def.execution.timeoutMs ?? null,
+ }
: null,
schema: def.parameterSchemaRaw ? canonicalize(def.parameterSchemaRaw) : null,
};
@@ -301,7 +302,12 @@ export async function computeIncrementalDesignHash(
// First compute per-action hashes
for (const step of steps) {
for (const action of step.actions) {
- const existing = previous?.actionHashes.get(action.id);
+ // Only reuse cached hash if we're NOT including parameter values
+ // (because parameter values can change without changing the action ID)
+ const existing = !options.includeParameterValues
+ ? previous?.actionHashes.get(action.id)
+ : undefined;
+
if (existing) {
// Simple heuristic: if shallow structural keys unchanged, reuse
// (We still project to confirm minimal structure; deeper diff omitted for performance.)
@@ -316,7 +322,12 @@ export async function computeIncrementalDesignHash(
// Then compute step hashes (including ordered list of action hashes)
for (const step of steps) {
- const existing = previous?.stepHashes.get(step.id);
+ // Only reuse cached hash if we're NOT including parameter values
+ // (because parameter values in actions can change without changing the step ID)
+ const existing = !options.includeParameterValues
+ ? previous?.stepHashes.get(step.id)
+ : undefined;
+
if (existing) {
stepHashes.set(step.id, existing);
continue;
diff --git a/src/components/experiments/designer/state/store.ts b/src/components/experiments/designer/state/store.ts
old mode 100644
new mode 100755
index 1695559..6e3f136
--- a/src/components/experiments/designer/state/store.ts
+++ b/src/components/experiments/designer/state/store.ts
@@ -79,6 +79,23 @@ export interface DesignerState {
busyHashing: boolean;
busyValidating: boolean;
+ /* ---------------------- DnD Projection (Transient) ----------------------- */
+ insertionProjection: {
+ stepId: string;
+ parentId: string | null;
+ index: number;
+ action: ExperimentAction;
+ } | null;
+
+ setInsertionProjection: (
+ projection: {
+ stepId: string;
+ parentId: string | null;
+ index: number;
+ action: ExperimentAction;
+ } | null
+ ) => void;
+
/* ------------------------------ Mutators --------------------------------- */
// Selection
@@ -92,9 +109,10 @@ export interface DesignerState {
reorderStep: (from: number, to: number) => void;
// Actions
- upsertAction: (stepId: string, action: ExperimentAction) => void;
+ upsertAction: (stepId: string, action: ExperimentAction, parentId?: string | null, index?: number) => void;
removeAction: (stepId: string, actionId: string) => void;
reorderAction: (stepId: string, from: number, to: number) => void;
+ moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) => void;
// Dirty
markDirty: (id: string) => void;
@@ -159,17 +177,73 @@ function reindexActions(actions: ExperimentAction[]): ExperimentAction[] {
return actions.map((a) => ({ ...a }));
}
-function updateActionList(
- existing: ExperimentAction[],
+function findActionById(
+ list: ExperimentAction[],
+ id: string,
+): ExperimentAction | null {
+ for (const action of list) {
+ if (action.id === id) return action;
+ if (action.children) {
+ const found = findActionById(action.children, id);
+ if (found) return found;
+ }
+ }
+ return null;
+}
+
+function updateActionInTree(
+ list: ExperimentAction[],
action: ExperimentAction,
): ExperimentAction[] {
- const idx = existing.findIndex((a) => a.id === action.id);
- if (idx >= 0) {
- const copy = [...existing];
- copy[idx] = { ...action };
+ return list.map((a) => {
+ if (a.id === action.id) return { ...action };
+ if (a.children) {
+ return { ...a, children: updateActionInTree(a.children, action) };
+ }
+ return a;
+ });
+}
+
+// Immutable removal
+function removeActionFromTree(
+ list: ExperimentAction[],
+ id: string,
+): ExperimentAction[] {
+ return list
+ .filter((a) => a.id !== id)
+ .map((a) => ({
+ ...a,
+ children: a.children ? removeActionFromTree(a.children, id) : undefined,
+ }));
+}
+
+// Immutable insertion
+function insertActionIntoTree(
+ list: ExperimentAction[],
+ action: ExperimentAction,
+ parentId: string | null,
+ index: number,
+): ExperimentAction[] {
+ if (!parentId) {
+ // Insert at root level
+ const copy = [...list];
+ copy.splice(index, 0, action);
return copy;
}
- return [...existing, { ...action }];
+ return list.map((a) => {
+ if (a.id === parentId) {
+ const children = a.children ? [...a.children] : [];
+ children.splice(index, 0, action);
+ return { ...a, children };
+ }
+ if (a.children) {
+ return {
+ ...a,
+ children: insertActionIntoTree(a.children, action, parentId, index),
+ };
+ }
+ return a;
+ });
}
/* -------------------------------------------------------------------------- */
@@ -187,6 +261,7 @@ export const useDesignerStore = create((set, get) => ({
autoSaveEnabled: true,
busyHashing: false,
busyValidating: false,
+ insertionProjection: null,
/* ------------------------------ Selection -------------------------------- */
selectStep: (id) =>
@@ -263,16 +338,31 @@ export const useDesignerStore = create((set, get) => ({
}),
/* ------------------------------- Actions --------------------------------- */
- upsertAction: (stepId: string, action: ExperimentAction) =>
+ upsertAction: (stepId: string, action: ExperimentAction, parentId: string | null = null, index?: number) =>
set((state: DesignerState) => {
- const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
- s.id === stepId
- ? {
- ...s,
- actions: reindexActions(updateActionList(s.actions, action)),
- }
- : s,
- );
+ const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
+ if (s.id !== stepId) return s;
+
+ // Check if exists (update)
+ const exists = findActionById(s.actions, action.id);
+ if (exists) {
+ // If updating, we don't (currently) support moving via upsert.
+ // Use moveAction for moving.
+ return {
+ ...s,
+ actions: updateActionInTree(s.actions, action)
+ };
+ }
+
+ // Add new
+ // If index is provided, use it. Otherwise append.
+ const insertIndex = index ?? s.actions.length;
+
+ return {
+ ...s,
+ actions: insertActionIntoTree(s.actions, action, parentId, insertIndex)
+ };
+ });
return {
steps: stepsDraft,
dirtyEntities: new Set([
@@ -288,11 +378,9 @@ export const useDesignerStore = create((set, get) => ({
const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
s.id === stepId
? {
- ...s,
- actions: reindexActions(
- s.actions.filter((a) => a.id !== actionId),
- ),
- }
+ ...s,
+ actions: removeActionFromTree(s.actions, actionId),
+ }
: s,
);
const dirty = new Set(state.dirtyEntities);
@@ -308,31 +396,29 @@ export const useDesignerStore = create((set, get) => ({
};
}),
- reorderAction: (stepId: string, from: number, to: number) =>
+ moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) =>
set((state: DesignerState) => {
- const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
+ const stepsDraft = state.steps.map((s) => {
if (s.id !== stepId) return s;
- if (
- from < 0 ||
- to < 0 ||
- from >= s.actions.length ||
- to >= s.actions.length ||
- from === to
- ) {
- return s;
- }
- const actionsDraft = [...s.actions];
- const [moved] = actionsDraft.splice(from, 1);
- if (!moved) return s;
- actionsDraft.splice(to, 0, moved);
- return { ...s, actions: reindexActions(actionsDraft) };
+
+ const actionToMove = findActionById(s.actions, actionId);
+ if (!actionToMove) return s;
+
+ const pruned = removeActionFromTree(s.actions, actionId);
+ const inserted = insertActionIntoTree(pruned, actionToMove, newParentId, newIndex);
+ return { ...s, actions: inserted };
});
return {
steps: stepsDraft,
- dirtyEntities: new Set([...state.dirtyEntities, stepId]),
+ dirtyEntities: new Set([...state.dirtyEntities, stepId, actionId]),
};
}),
+ reorderAction: (stepId: string, from: number, to: number) =>
+ get().moveAction(stepId, get().steps.find(s => s.id === stepId)?.actions[from]?.id!, null, to), // Legacy compat support (only works for root level reorder)
+
+ setInsertionProjection: (projection) => set({ insertionProjection: projection }),
+
/* -------------------------------- Dirty ---------------------------------- */
markDirty: (id: string) =>
set((state: DesignerState) => ({
diff --git a/src/components/experiments/designer/state/validators.ts b/src/components/experiments/designer/state/validators.ts
old mode 100644
new mode 100755
index 94dada3..31e3008
--- a/src/components/experiments/designer/state/validators.ts
+++ b/src/components/experiments/designer/state/validators.ts
@@ -643,13 +643,13 @@ export function validateExecution(
if (trialStartSteps.length > 1) {
trialStartSteps.slice(1).forEach((step) => {
issues.push({
- severity: "warning",
+ severity: "info",
message:
- "Multiple steps with trial_start trigger may cause execution conflicts",
+ "This step will start immediately at trial start. For sequential flow, use 'Previous Step' trigger.",
category: "execution",
field: "trigger.type",
stepId: step.id,
- suggestion: "Consider using sequential triggers for subsequent steps",
+ suggestion: "Change trigger to 'Previous Step' if this step should follow the previous one",
});
});
}
diff --git a/src/components/experiments/experiments-columns.tsx b/src/components/experiments/experiments-columns.tsx
old mode 100644
new mode 100755
index b8c7be7..89d858f
--- a/src/components/experiments/experiments-columns.tsx
+++ b/src/components/experiments/experiments-columns.tsx
@@ -114,14 +114,14 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
-
+
View Details
-
+
Open Designer
@@ -129,7 +129,7 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
{experiment.canEdit && (
-
+
Edit Experiment
@@ -202,7 +202,7 @@ export const experimentsColumns: ColumnDef[] = [
return (
diff --git a/src/components/experiments/experiments-data-table.tsx b/src/components/experiments/experiments-data-table.tsx
old mode 100644
new mode 100755
diff --git a/src/components/participants/ParticipantForm.tsx b/src/components/participants/ParticipantForm.tsx
old mode 100644
new mode 100755
index 05f6280..06f30e5
--- a/src/components/participants/ParticipantForm.tsx
+++ b/src/components/participants/ParticipantForm.tsx
@@ -114,39 +114,39 @@ export function ParticipantForm({
{ label: "Studies", href: "/studies" },
...(contextStudyId
? [
- {
- label: participant?.study?.name ?? "Study",
- href: `/studies/${contextStudyId}`,
- },
- {
- label: "Participants",
- href: `/studies/${contextStudyId}/participants`,
- },
- ...(mode === "edit" && participant
- ? [
- {
- label: participant.name ?? participant.participantCode,
- href: `/studies/${contextStudyId}/participants/${participant.id}`,
- },
- { label: "Edit" },
- ]
- : [{ label: "New Participant" }]),
- ]
+ {
+ label: participant?.study?.name ?? "Study",
+ href: `/studies/${contextStudyId}`,
+ },
+ {
+ label: "Participants",
+ href: `/studies/${contextStudyId}/participants`,
+ },
+ ...(mode === "edit" && participant
+ ? [
+ {
+ label: participant.name ?? participant.participantCode,
+ href: `/studies/${contextStudyId}/participants/${participant.id}`,
+ },
+ { label: "Edit" },
+ ]
+ : [{ label: "New Participant" }]),
+ ]
: [
- {
- label: "Participants",
- href: `/studies/${contextStudyId}/participants`,
- },
- ...(mode === "edit" && participant
- ? [
- {
- label: participant.name ?? participant.participantCode,
- href: `/studies/${contextStudyId}/participants/${participant.id}`,
- },
- { label: "Edit" },
- ]
- : [{ label: "New Participant" }]),
- ]),
+ {
+ label: "Participants",
+ href: `/studies/${contextStudyId}/participants`,
+ },
+ ...(mode === "edit" && participant
+ ? [
+ {
+ label: participant.name ?? participant.participantCode,
+ href: `/studies/${contextStudyId}/participants/${participant.id}`,
+ },
+ { label: "Edit" },
+ ]
+ : [{ label: "New Participant" }]),
+ ]),
];
useBreadcrumbsEffect(breadcrumbs);
@@ -203,7 +203,7 @@ export function ParticipantForm({
email: data.email ?? undefined,
demographics,
});
- router.push(`/participants/${newParticipant.id}`);
+ router.push(`/studies/${data.studyId}/participants/${newParticipant.id}`);
} else {
const updatedParticipant = await updateParticipantMutation.mutateAsync({
id: participantId!,
@@ -212,7 +212,7 @@ export function ParticipantForm({
email: data.email ?? undefined,
demographics,
});
- router.push(`/participants/${updatedParticipant.id}`);
+ router.push(`/studies/${contextStudyId}/participants/${updatedParticipant.id}`);
}
} catch (error) {
setError(
@@ -385,11 +385,11 @@ export function ParticipantForm({
form.setValue(
"gender",
value as
- | "male"
- | "female"
- | "non_binary"
- | "prefer_not_to_say"
- | "other",
+ | "male"
+ | "female"
+ | "non_binary"
+ | "prefer_not_to_say"
+ | "other",
)
}
>
@@ -505,7 +505,8 @@ export function ParticipantForm({
error={error}
onDelete={mode === "edit" ? onDelete : undefined}
isDeleting={isDeleting}
- sidebar={sidebar}
+ isDeleting={isDeleting}
+ // sidebar={sidebar} // Removed for cleaner UI per user request
submitText={mode === "create" ? "Register Participant" : "Save Changes"}
>
{formFields}
diff --git a/src/components/participants/ParticipantsTable.tsx b/src/components/participants/ParticipantsTable.tsx
old mode 100644
new mode 100755
index 53117e6..5d91954
--- a/src/components/participants/ParticipantsTable.tsx
+++ b/src/components/participants/ParticipantsTable.tsx
@@ -1,7 +1,7 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
-import { ArrowUpDown, MoreHorizontal } from "lucide-react";
+import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, Mail, Trash2 } from "lucide-react";
import * as React from "react";
import { formatDistanceToNow } from "date-fns";
@@ -27,6 +27,7 @@ import { api } from "~/trpc/react";
export type Participant = {
id: string;
+ studyId: string;
participantCode: string;
email: string | null;
name: string | null;
@@ -75,7 +76,7 @@ export const columns: ColumnDef
[] = [
cell: ({ row }) => (
{row.getValue("participantCode")}
@@ -176,6 +177,13 @@ export const columns: ColumnDef
[] = [
enableHiding: false,
cell: ({ row }) => {
const participant = row.original;
+ // Use studyId from participant or fallback might be needed but for now presume row has it?
+ // Wait, the Participant type definition above doesn't have studyId!
+ // I need to add studyId to the type definition in this file or rely on context if I'm inside the component,
+ // but 'columns' is defined outside.
+ // Best practice: Add studyId to the Participant type.
+
+ const studyId = participant.studyId;
return (
@@ -190,26 +198,27 @@ export const columns: ColumnDef[] = [
navigator.clipboard.writeText(participant.id)}
>
- Copy participant ID
+
+ Copy ID
- View details
-
-
-
+
+
Edit participant
-
+
+
+
+
+ Send consent
- {!participant.consentGiven && (
- Send consent form
- )}
- Remove participant
+
+ Remove
-
-
+
+
);
},
},
@@ -250,6 +259,7 @@ export function ParticipantsTable({ studyId }: ParticipantsTableProps = {}) {
return participantsData.participants.map(
(p): Participant => ({
id: p.id,
+ studyId: p.studyId,
participantCode: p.participantCode,
email: p.email,
name: p.name,
diff --git a/src/components/participants/ParticipantsView.tsx b/src/components/participants/ParticipantsView.tsx
old mode 100644
new mode 100755
diff --git a/src/components/plugins/plugin-store-browse.tsx b/src/components/plugins/plugin-store-browse.tsx
old mode 100644
new mode 100755
diff --git a/src/components/plugins/plugins-columns.tsx b/src/components/plugins/plugins-columns.tsx
old mode 100644
new mode 100755
diff --git a/src/components/plugins/plugins-data-table.tsx b/src/components/plugins/plugins-data-table.tsx
old mode 100644
new mode 100755
diff --git a/src/components/profile/password-change-form.tsx b/src/components/profile/password-change-form.tsx
old mode 100644
new mode 100755
diff --git a/src/components/profile/profile-edit-form.tsx b/src/components/profile/profile-edit-form.tsx
old mode 100644
new mode 100755
diff --git a/src/components/studies/InviteMemberDialog.tsx b/src/components/studies/InviteMemberDialog.tsx
old mode 100644
new mode 100755
diff --git a/src/components/studies/StudiesGrid.tsx b/src/components/studies/StudiesGrid.tsx
old mode 100644
new mode 100755
diff --git a/src/components/studies/StudiesTable.tsx b/src/components/studies/StudiesTable.tsx
old mode 100644
new mode 100755
diff --git a/src/components/studies/StudyCard.tsx b/src/components/studies/StudyCard.tsx
old mode 100644
new mode 100755
diff --git a/src/components/studies/StudyForm.tsx b/src/components/studies/StudyForm.tsx
old mode 100644
new mode 100755
diff --git a/src/components/studies/studies-columns.tsx b/src/components/studies/studies-columns.tsx
old mode 100644
new mode 100755
diff --git a/src/components/studies/studies-data-table.tsx b/src/components/studies/studies-data-table.tsx
old mode 100644
new mode 100755
diff --git a/src/components/theme/index.ts b/src/components/theme/index.ts
old mode 100644
new mode 100755
diff --git a/src/components/theme/theme-provider.tsx b/src/components/theme/theme-provider.tsx
old mode 100644
new mode 100755
diff --git a/src/components/theme/theme-script.tsx b/src/components/theme/theme-script.tsx
old mode 100644
new mode 100755
diff --git a/src/components/theme/theme-toggle.tsx b/src/components/theme/theme-toggle.tsx
old mode 100644
new mode 100755
diff --git a/src/components/theme/toaster.tsx b/src/components/theme/toaster.tsx
old mode 100644
new mode 100755
diff --git a/src/components/trials/TrialForm.tsx b/src/components/trials/TrialForm.tsx
old mode 100644
new mode 100755
index 87f2e0e..7a2e938
--- a/src/components/trials/TrialForm.tsx
+++ b/src/components/trials/TrialForm.tsx
@@ -96,33 +96,33 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
{ label: "Studies", href: "/studies" },
...(contextStudyId
? [
- {
- label: "Study",
- href: `/studies/${contextStudyId}`,
- },
- { label: "Trials", href: `/studies/${contextStudyId}/trials` },
- ...(mode === "edit" && trial
- ? [
- {
- label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
- href: `/studies/${contextStudyId}/trials/${trial.id}`,
- },
- { label: "Edit" },
- ]
- : [{ label: "New Trial" }]),
- ]
+ {
+ label: "Study",
+ href: `/studies/${contextStudyId}`,
+ },
+ { label: "Trials", href: `/studies/${contextStudyId}/trials` },
+ ...(mode === "edit" && trial
+ ? [
+ {
+ label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
+ href: `/studies/${contextStudyId}/trials/${trial.id}`,
+ },
+ { label: "Edit" },
+ ]
+ : [{ label: "New Trial" }]),
+ ]
: [
- { label: "Trials", href: `/studies/${contextStudyId}/trials` },
- ...(mode === "edit" && trial
- ? [
- {
- label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
- href: `/studies/${contextStudyId}/trials/${trial.id}`,
- },
- { label: "Edit" },
- ]
- : [{ label: "New Trial" }]),
- ]),
+ { label: "Trials", href: `/studies/${contextStudyId}/trials` },
+ ...(mode === "edit" && trial
+ ? [
+ {
+ label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
+ href: `/studies/${contextStudyId}/trials/${trial.id}`,
+ },
+ { label: "Edit" },
+ ]
+ : [{ label: "New Trial" }]),
+ ]),
];
useBreadcrumbsEffect(breadcrumbs);
@@ -161,7 +161,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
sessionNumber: data.sessionNumber ?? 1,
notes: data.notes ?? undefined,
});
- router.push(`/trials/${newTrial!.id}`);
+ router.push(`/studies/${contextStudyId}/trials/${newTrial!.id}`);
} else {
const updatedTrial = await updateTrialMutation.mutateAsync({
id: trialId!,
@@ -170,7 +170,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
sessionNumber: data.sessionNumber ?? 1,
notes: data.notes ?? undefined,
});
- router.push(`/trials/${updatedTrial!.id}`);
+ router.push(`/studies/${contextStudyId}/trials/${updatedTrial!.id}`);
}
} catch (error) {
setError(
diff --git a/src/components/trials/TrialsGrid.tsx b/src/components/trials/TrialsGrid.tsx
old mode 100644
new mode 100755
diff --git a/src/components/trials/TrialsTable.tsx b/src/components/trials/TrialsTable.tsx
old mode 100644
new mode 100755
index 0f341b9..fe0bbbf
--- a/src/components/trials/TrialsTable.tsx
+++ b/src/components/trials/TrialsTable.tsx
@@ -1,7 +1,7 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
-import { ArrowUpDown, ChevronDown, MoreHorizontal } from "lucide-react";
+import { ArrowUpDown, ChevronDown, MoreHorizontal, Copy, Eye, Play, Gamepad2, LineChart, Ban } from "lucide-react";
import * as React from "react";
import { format, formatDistanceToNow } from "date-fns";
@@ -51,27 +51,22 @@ const statusConfig = {
scheduled: {
label: "Scheduled",
className: "bg-blue-100 text-blue-800",
- icon: "📅",
},
in_progress: {
label: "In Progress",
className: "bg-yellow-100 text-yellow-800",
- icon: "▶️",
},
completed: {
label: "Completed",
className: "bg-green-100 text-green-800",
- icon: "✅",
},
aborted: {
label: "Aborted",
className: "bg-gray-100 text-gray-800",
- icon: "❌",
},
failed: {
label: "Failed",
className: "bg-red-100 text-red-800",
- icon: "⚠️",
},
};
@@ -145,7 +140,7 @@ export const columns: ColumnDef[] = [
{String(experimentName)}
@@ -175,7 +170,7 @@ export const columns: ColumnDef
[] = [
{participantId ? (
{(participantCode ?? "Unknown") as string}
@@ -232,10 +227,10 @@ export const columns: ColumnDef
[] = [
return (
- {statusInfo.icon}
{statusInfo.label}
);
+
},
},
{
@@ -366,79 +361,92 @@ export const columns: ColumnDef[] = [
{
id: "actions",
enableHiding: false,
- cell: ({ row }) => {
- const trial = row.original;
-
- if (!trial?.id) {
- return (
- No actions
- );
- }
-
- return (
-
-
-
- Open menu
-
-
-
-
- Actions
- navigator.clipboard.writeText(trial.id)}
- >
- Copy trial ID
-
-
-
-
- View details
-
-
- {trial.status === "scheduled" && (
-
-
- Start trial
-
-
- )}
- {trial.status === "in_progress" && (
-
-
- Control trial
-
-
- )}
- {trial.status === "completed" && (
-
-
- View analysis
-
-
- )}
-
-
-
- Edit trial
-
-
- {(trial.status === "scheduled" || trial.status === "failed") && (
-
- Cancel trial
-
- )}
-
-
- );
- },
+ cell: ({ row }) => ,
},
];
+function ActionsCell({ row }: { row: { original: Trial } }) {
+ const trial = row.original;
+ // ActionsCell is a component rendered by the table.
+
+ // importing useRouter is fine.
+
+ const utils = api.useUtils();
+ const duplicateMutation = api.trials.duplicate.useMutation({
+ onSuccess: () => {
+ utils.trials.list.invalidate();
+ // toast.success("Trial duplicated"); // We need toast
+ },
+ });
+
+ if (!trial?.id) {
+ return No actions ;
+ }
+
+ return (
+
+
+
+ Open menu
+
+
+
+
+ Actions
+ navigator.clipboard.writeText(trial.id)}
+ >
+
+ Copy ID
+
+
+
+
+
+ Details
+
+
+ {trial.status === "scheduled" && (
+
+
+
+ Start Trial
+
+
+ )}
+ {trial.status === "in_progress" && (
+
+
+
+ Control Trial
+
+
+ )}
+ {trial.status === "completed" && (
+
+
+
+ Analysis
+
+
+ )}
+
+ duplicateMutation.mutate({ id: trial.id })}>
+
+ Duplicate
+
+
+ {(trial.status === "scheduled" || trial.status === "failed") && (
+
+
+ Cancel
+
+ )}
+
+
+ );
+}
+
interface TrialsTableProps {
studyId?: string;
}
diff --git a/src/components/trials/execution/EventsLog.tsx b/src/components/trials/execution/EventsLog.tsx
old mode 100644
new mode 100755
diff --git a/src/components/trials/playback/EventTimeline.tsx b/src/components/trials/playback/EventTimeline.tsx
new file mode 100644
index 0000000..6b27f6a
--- /dev/null
+++ b/src/components/trials/playback/EventTimeline.tsx
@@ -0,0 +1,205 @@
+"use client";
+
+import React, { useMemo, useRef, useState } from "react";
+import { usePlayback } from "./PlaybackContext";
+import { cn } from "~/lib/utils";
+import {
+ AlertTriangle,
+ CheckCircle,
+ Flag,
+ MessageSquare,
+ Zap,
+ Circle,
+ Bot,
+ User,
+ Activity
+} from "lucide-react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger
+} from "~/components/ui/tooltip";
+
+function formatTime(seconds: number) {
+ const min = Math.floor(seconds / 60);
+ const sec = Math.floor(seconds % 60);
+ return `${min}:${sec.toString().padStart(2, "0")}`;
+}
+
+export function EventTimeline() {
+ const {
+ duration,
+ currentTime,
+ events,
+ seekTo,
+ startTime: contextStartTime
+ } = usePlayback();
+
+ // Determine effective time range
+ const sortedEvents = useMemo(() => {
+ return [...events].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
+ }, [events]);
+
+ const startTime = useMemo(() => {
+ if (contextStartTime) return new Date(contextStartTime).getTime();
+ if (sortedEvents.length > 0) return new Date(sortedEvents[0]!.timestamp).getTime();
+ return 0;
+ }, [contextStartTime, sortedEvents]);
+
+ const effectiveDuration = useMemo(() => {
+ if (duration > 0) return duration * 1000;
+ if (sortedEvents.length === 0) return 60000; // 1 min default
+ const end = new Date(sortedEvents[sortedEvents.length - 1]!.timestamp).getTime();
+ return Math.max(end - startTime, 1000);
+ }, [duration, sortedEvents, startTime]);
+
+ // Dimensions
+ const containerRef = useRef(null);
+
+ // Helpers
+ const getPercentage = (timestampMs: number) => {
+ const offset = timestampMs - startTime;
+ return Math.max(0, Math.min(100, (offset / effectiveDuration) * 100));
+ };
+
+ const handleSeek = (e: React.MouseEvent) => {
+ if (!containerRef.current) return;
+ const rect = containerRef.current.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const pct = Math.max(0, Math.min(1, x / rect.width));
+ seekTo(pct * (effectiveDuration / 1000));
+ };
+
+ const currentProgress = (currentTime * 1000 / effectiveDuration) * 100;
+
+ // Generate ticks for "number line" look
+ // We want a major tick every ~10% or meaningful time interval
+ const ticks = useMemo(() => {
+ const count = 10;
+ return Array.from({ length: count + 1 }).map((_, i) => ({
+ pct: (i / count) * 100,
+ label: formatTime((effectiveDuration / 1000) * (i / count))
+ }));
+ }, [effectiveDuration]);
+
+ const getEventIcon = (type: string) => {
+ if (type.includes("intervention") || type.includes("wizard")) return ;
+ if (type.includes("robot") || type.includes("action")) return ;
+ if (type.includes("completed")) return ;
+ if (type.includes("start")) return ;
+ if (type.includes("note")) return ;
+ if (type.includes("error")) return ;
+ return ;
+ };
+
+ const getEventColor = (type: string) => {
+ if (type.includes("intervention") || type.includes("wizard")) return "text-orange-500 border-orange-200 bg-orange-50";
+ if (type.includes("robot") || type.includes("action")) return "text-purple-500 border-purple-200 bg-purple-50";
+ if (type.includes("completed")) return "text-green-500 border-green-200 bg-green-50";
+ if (type.includes("start")) return "text-blue-500 border-blue-200 bg-blue-50";
+ if (type.includes("error")) return "text-red-500 border-red-200 bg-red-50";
+ return "text-slate-500 border-slate-200 bg-slate-50";
+ };
+
+ return (
+
+
+ {/* Timeline Track Container */}
+
+ {/* Background Grid/Ticks */}
+
+ {/* Major Ticks */}
+ {ticks.map((tick, i) => (
+
+
+ {tick.label}
+
+
+ ))}
+
+
+ {/* Central Axis Line */}
+
+
+ {/* Progress Fill (Subtle) */}
+
+
+ {/* Playhead */}
+
+
+ {/* Events "Lollipops" */}
+ {sortedEvents.map((event, i) => {
+ const pct = getPercentage(new Date(event.timestamp).getTime());
+ const isTop = i % 2 === 0; // Stagger events top/bottom
+
+ return (
+
+
+ {
+ e.stopPropagation();
+ seekTo((new Date(event.timestamp).getTime() - startTime) / 1000);
+ }}
+ >
+ {/* The Stem */}
+
+
+ {/* The Node */}
+
+ {getEventIcon(event.eventType)}
+
+
+
+
+ {event.eventType.replace(/_/g, " ")}
+
+ {new Date(event.timestamp).toLocaleTimeString()}
+
+ {event.data && (
+
+ {JSON.stringify(event.data as object).slice(0, 100)}
+
+ )}
+
+
+ );
+ })}
+
+
+
+ );
+}
+
diff --git a/src/components/trials/playback/PlaybackContext.tsx b/src/components/trials/playback/PlaybackContext.tsx
new file mode 100644
index 0000000..0a03c19
--- /dev/null
+++ b/src/components/trials/playback/PlaybackContext.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import React, { createContext, useContext, useEffect, useRef, useState } from "react";
+
+interface TrialEvent {
+ eventType: string;
+ timestamp: Date;
+ data?: unknown;
+}
+
+interface PlaybackContextType {
+ // State
+ currentTime: number;
+ duration: number;
+ isPlaying: boolean;
+ playbackRate: number;
+ startTime?: Date;
+
+ // Actions
+ play: () => void;
+ pause: () => void;
+ togglePlay: () => void;
+ seekTo: (time: number) => void;
+ setPlaybackRate: (rate: number) => void;
+ setDuration: (duration: number) => void;
+ setCurrentTime: (time: number) => void; // Used by VideoPlayer to update state
+
+ // Data
+ events: TrialEvent[];
+ currentEventIndex: number; // Index of the last event that happened before currentTime
+}
+
+const PlaybackContext = createContext(null);
+
+export function usePlayback() {
+ const context = useContext(PlaybackContext);
+ if (!context) {
+ throw new Error("usePlayback must be used within a PlaybackProvider");
+ }
+ return context;
+}
+
+interface PlaybackProviderProps {
+ children: React.ReactNode;
+ events?: TrialEvent[];
+ startTime?: Date;
+}
+
+export function PlaybackProvider({ children, events = [], startTime }: PlaybackProviderProps) {
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [playbackRate, setPlaybackRate] = useState(1);
+
+ // Derived state: find the latest event index based on currentTime
+ const currentEventIndex = React.useMemo(() => {
+ if (!startTime || events.length === 0) return -1;
+
+ // Find the last event that occurred before or at currentTime
+ // Events are assumed to be sorted by timestamp
+ // Using basic iteration for now, optimization possible for large lists
+ let lastIndex = -1;
+
+ for (let i = 0; i < events.length; i++) {
+ const eventTime = new Date(events[i]!.timestamp).getTime();
+ const startStr = new Date(startTime).getTime();
+ const relativeSeconds = (eventTime - startStr) / 1000;
+
+ if (relativeSeconds <= currentTime) {
+ lastIndex = i;
+ } else {
+ break; // Events are sorted, so we can stop
+ }
+ }
+ return lastIndex;
+ }, [currentTime, events, startTime]);
+
+ // Actions
+ const play = () => setIsPlaying(true);
+ const pause = () => setIsPlaying(false);
+ const togglePlay = () => setIsPlaying(p => !p);
+
+ const seekTo = (time: number) => {
+ setCurrentTime(time);
+ // Dispatch seek event to video player via some mechanism if needed,
+ // usually VideoPlayer observes this context or we use a Ref to control it.
+ // Actually, simple way: Context holds state, VideoPlayer listens to state?
+ // No, VideoPlayer usually drives time.
+ // Let's assume VideoPlayer updates `setCurrentTime` as it plays.
+ // But if *we* seek (e.g. from timeline), we need to tell VideoPlayer to jump.
+ // We might need a `seekRequest` timestamp or similar signal.
+ };
+
+ const value: PlaybackContextType = {
+ currentTime,
+ duration,
+ isPlaying,
+ playbackRate,
+ play,
+ pause,
+ togglePlay,
+ seekTo,
+ setPlaybackRate,
+ setDuration,
+ setCurrentTime,
+ events,
+ currentEventIndex,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/trials/playback/PlaybackPlayer.tsx b/src/components/trials/playback/PlaybackPlayer.tsx
new file mode 100644
index 0000000..9697fc8
--- /dev/null
+++ b/src/components/trials/playback/PlaybackPlayer.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import React, { useRef, useEffect } from "react";
+import { usePlayback } from "./PlaybackContext";
+import { AspectRatio } from "~/components/ui/aspect-ratio";
+import { Loader2, Play, Pause, Volume2, VolumeX, Maximize } from "lucide-react";
+import { Slider } from "~/components/ui/slider";
+import { Button } from "~/components/ui/button";
+
+interface PlaybackPlayerProps {
+ src: string;
+}
+
+export function PlaybackPlayer({ src }: PlaybackPlayerProps) {
+ const videoRef = useRef(null);
+ const {
+ currentTime,
+ isPlaying,
+ playbackRate,
+ setCurrentTime,
+ setDuration,
+ togglePlay,
+ play,
+ pause
+ } = usePlayback();
+
+ const [isBuffering, setIsBuffering] = React.useState(true);
+ const [volume, setVolume] = React.useState(1);
+ const [muted, setMuted] = React.useState(false);
+
+ // Sync Play/Pause state
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ if (isPlaying && video.paused) {
+ video.play().catch(console.error);
+ } else if (!isPlaying && !video.paused) {
+ video.pause();
+ }
+ }, [isPlaying]);
+
+ // Sync Playback Rate
+ useEffect(() => {
+ if (videoRef.current) {
+ videoRef.current.playbackRate = playbackRate;
+ }
+ }, [playbackRate]);
+
+ // Sync Seek (External seek request)
+ // Note: This is tricky because normal playback also updates currentTime.
+ // We need to differentiate between "time updated by video" and "time updated by user seek".
+ // For now, we'll let the video drive the context time, and rely on the Parent/Context
+ // to call a imperative sync if needed, or we implement a "seekRequest" signal in context.
+ // simpler: If context time differs significantly from video time, we seek.
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ if (Math.abs(video.currentTime - currentTime) > 0.5) {
+ video.currentTime = currentTime;
+ }
+ }, [currentTime]);
+
+ const handleTimeUpdate = () => {
+ if (videoRef.current) {
+ setCurrentTime(videoRef.current.currentTime);
+ }
+ };
+
+ const handleLoadedMetadata = () => {
+ if (videoRef.current) {
+ setDuration(videoRef.current.duration);
+ setIsBuffering(false);
+ }
+ };
+
+ const handleWaiting = () => setIsBuffering(true);
+ const handlePlaying = () => setIsBuffering(false);
+ const handleEnded = () => pause();
+
+ return (
+
+
+
+
+ {/* Overlay Controls (Visible on Hover/Pause) */}
+
+
+
+ {isPlaying ? : }
+
+
+
+ {
+ if (videoRef.current) {
+ videoRef.current.currentTime = val;
+ setCurrentTime(val);
+ }
+ }}
+ className="cursor-pointer"
+ />
+
+
+
+ {formatTime(currentTime)} / {formatTime(videoRef.current?.duration || 0)}
+
+
+
setMuted(!muted)}
+ >
+ {muted || volume === 0 ? : }
+
+
+
+
+ {isBuffering && (
+
+
+
+ )}
+
+
+ );
+}
+
+function formatTime(seconds: number) {
+ const m = Math.floor(seconds / 60);
+ const s = Math.floor(seconds % 60);
+ return `${m}:${s.toString().padStart(2, "0")}`;
+}
diff --git a/src/components/trials/timeline/HorizontalTimeline.tsx b/src/components/trials/timeline/HorizontalTimeline.tsx
new file mode 100644
index 0000000..2b53c43
--- /dev/null
+++ b/src/components/trials/timeline/HorizontalTimeline.tsx
@@ -0,0 +1,203 @@
+"use client";
+
+import React, { useState } from "react";
+import { Badge } from "~/components/ui/badge";
+import { Card, CardContent } from "~/components/ui/card";
+import { ScrollArea } from "~/components/ui/scroll-area";
+import { Flag, CheckCircle, Bot, User, MessageSquare, AlertTriangle, Activity } from "lucide-react";
+
+interface TimelineEvent {
+ type: string;
+ timestamp: Date;
+ message?: string;
+ data?: unknown;
+}
+
+interface HorizontalTimelineProps {
+ events: TimelineEvent[];
+ startTime?: Date;
+ endTime?: Date;
+}
+
+export function HorizontalTimeline({ events, startTime, endTime }: HorizontalTimelineProps) {
+ const [selectedEvent, setSelectedEvent] = useState(null);
+
+ if (events.length === 0) {
+ return (
+
+ No events recorded yet
+
+ );
+ }
+
+ // Calculate time range
+ const timestamps = events.map(e => e.timestamp.getTime());
+ const minTime = startTime?.getTime() ?? Math.min(...timestamps);
+ const maxTime = endTime?.getTime() ?? Math.max(...timestamps);
+ const duration = maxTime - minTime;
+
+ // Generate time markers (every 10 seconds or appropriate interval)
+ const getTimeMarkers = () => {
+ const markers: Date[] = [];
+ const interval = duration > 300000 ? 60000 : duration > 60000 ? 30000 : 10000; // 1min, 30s, or 10s intervals
+
+ for (let time = minTime; time <= maxTime; time += interval) {
+ markers.push(new Date(time));
+ }
+ if (markers[markers.length - 1]?.getTime() !== maxTime) {
+ markers.push(new Date(maxTime));
+ }
+ return markers;
+ };
+
+ const timeMarkers = getTimeMarkers();
+
+ // Get position percentage for a timestamp
+ const getPosition = (timestamp: Date) => {
+ if (duration === 0) return 50;
+ return ((timestamp.getTime() - minTime) / duration) * 100;
+ };
+
+ // Get color and icon for event type
+ const getEventStyle = (eventType: string) => {
+ if (eventType.includes("start") || eventType === "trial_started") {
+ return { color: "bg-blue-500", Icon: Flag };
+ } else if (eventType.includes("complete") || eventType === "trial_completed") {
+ return { color: "bg-green-500", Icon: CheckCircle };
+ } else if (eventType.includes("robot") || eventType.includes("action")) {
+ return { color: "bg-purple-500", Icon: Bot };
+ } else if (eventType.includes("wizard") || eventType.includes("intervention")) {
+ return { color: "bg-orange-500", Icon: User };
+ } else if (eventType.includes("note") || eventType.includes("annotation")) {
+ return { color: "bg-yellow-500", Icon: MessageSquare };
+ } else if (eventType.includes("error") || eventType.includes("issue")) {
+ return { color: "bg-red-500", Icon: AlertTriangle };
+ }
+ return { color: "bg-gray-500", Icon: Activity };
+ };
+
+ return (
+
+ {/* Timeline visualization */}
+
+
+
+ {/* Time markers */}
+
+ {/* Main horizontal line */}
+
+
+ {/* Time labels */}
+ {timeMarkers.map((marker, i) => {
+ const pos = getPosition(marker);
+ return (
+
+
+
+ {marker.toLocaleTimeString([], {
+ hour12: false,
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit'
+ })}
+
+
+
+
+ );
+ })}
+
+
+ {/* Event markers */}
+
+ {/* Timeline line for events */}
+
+
+ {events.map((event, i) => {
+ const pos = getPosition(event.timestamp);
+ const { color, Icon } = getEventStyle(event.type);
+ const isSelected = selectedEvent === event;
+
+ return (
+
+ {/* Clickable marker group */}
+
setSelectedEvent(isSelected ? null : event)}
+ className="flex flex-col items-center gap-1 cursor-pointer group"
+ title={event.message || event.type}
+ >
+ {/* Vertical dash */}
+
+
+ {/* Icon indicator */}
+
+
+
+
+
+ );
+ })}
+
+
+
+
+
+ {/* Selected event details */}
+ {selectedEvent && (
+
+
+
+
+
+ {selectedEvent.type.replace(/_/g, " ")}
+
+
+ {selectedEvent.timestamp.toLocaleTimeString([], {
+ hour12: false,
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ fractionalSecondDigits: 3
+ })}
+
+
+ {selectedEvent.message && (
+
{selectedEvent.message}
+ )}
+ {selectedEvent.data !== undefined && selectedEvent.data !== null && (
+
+
+ Event data
+
+
+ {JSON.stringify(selectedEvent.data, null, 2)}
+
+
+ )}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/trials/views/ObserverView.tsx b/src/components/trials/views/ObserverView.tsx
old mode 100644
new mode 100755
diff --git a/src/components/trials/views/ParticipantView.tsx b/src/components/trials/views/ParticipantView.tsx
old mode 100644
new mode 100755
diff --git a/src/components/trials/views/TrialAnalysisView.tsx b/src/components/trials/views/TrialAnalysisView.tsx
new file mode 100644
index 0000000..93dd65e
--- /dev/null
+++ b/src/components/trials/views/TrialAnalysisView.tsx
@@ -0,0 +1,189 @@
+"use client";
+
+import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
+import { Badge } from "~/components/ui/badge";
+import { LineChart, BarChart, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info } from "lucide-react";
+import { PlaybackProvider } from "../playback/PlaybackContext";
+import { PlaybackPlayer } from "../playback/PlaybackPlayer";
+import { EventTimeline } from "../playback/EventTimeline";
+import { api } from "~/trpc/react";
+import { ScrollArea } from "~/components/ui/scroll-area";
+import {
+ ResizableHandle,
+ ResizablePanel,
+ ResizablePanelGroup,
+} from "~/components/ui/resizable";
+
+interface TrialAnalysisViewProps {
+ trial: {
+ id: string;
+ status: string;
+ startedAt: Date | null;
+ completedAt: Date | null;
+ duration: number | null;
+ experiment: { name: string };
+ participant: { participantCode: string };
+ eventCount?: number;
+ mediaCount?: number;
+ media?: { url: string; contentType: string }[];
+ };
+}
+
+export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) {
+ // Fetch events for timeline
+ const { data: events = [] } = api.trials.getEvents.useQuery({
+ trialId: trial.id,
+ limit: 1000
+ });
+
+ const videoMedia = trial.media?.find(m => m.contentType.startsWith("video/"));
+ const videoUrl = videoMedia?.url;
+
+ return (
+
+
+ {/* Header Context */}
+
+
+
+
+ {trial.experiment.name}
+
+
+ {trial.participant.participantCode} • Session {trial.id.slice(0, 4)}...
+
+
+
+
+
+
+ {trial.startedAt?.toLocaleDateString()} {trial.startedAt?.toLocaleTimeString()}
+
+ {trial.duration && (
+
+ {Math.floor(trial.duration / 60)}m {trial.duration % 60}s
+
+ )}
+
+
+
+
+ {/* Main Resizable Workspace */}
+
+
+
+ {/* LEFT: Video & Timeline */}
+
+
+ {/* Top: Video Player */}
+
+ {videoUrl ? (
+
+ ) : (
+
+
+
No recording available.
+
+ )}
+
+
+
+
+ {/* Bottom: Timeline Track */}
+
+
+
+ Timeline Track
+
+
+
+
+
+
+
+
+ {/* RIGHT: Logs & Metrics */}
+
+ {/* Metrics Strip */}
+
+
+
+ Interventions
+
+ {events.filter(e => e.eventType.includes("intervention")).length}
+
+
+
+
+
+
+ Status
+
+ {trial.status === 'completed' ? 'PASS' : 'INC'}
+
+
+
+
+
+
+ {/* Log Title */}
+
+
+
+ Event Log
+
+ {events.length} Events
+
+
+ {/* Scrollable Event List */}
+
+
+
+ {events.map((event, i) => (
+
+
+ {formatTime(new Date(event.timestamp).getTime() - (trial.startedAt?.getTime() ?? 0))}
+
+
+
+
+ {event.eventType.replace(/_/g, " ")}
+
+
+ {event.data && (
+
+ {JSON.stringify(event.data as object, null, 1).replace(/"/g, '').replace(/[{}]/g, '').trim()}
+
+ )}
+
+
+ ))}
+ {events.length === 0 && (
+
+ No events found in log.
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
+
+function formatTime(ms: number) {
+ if (ms < 0) return "0:00";
+ const totalSeconds = Math.floor(ms / 1000);
+ const m = Math.floor(totalSeconds / 60);
+ const s = Math.floor(totalSeconds % 60);
+ return `${m}:${s.toString().padStart(2, "0")}`;
+}
diff --git a/src/components/trials/views/WizardView.tsx b/src/components/trials/views/WizardView.tsx
old mode 100644
new mode 100755
diff --git a/src/components/trials/wizard/ActionControls.tsx b/src/components/trials/wizard/ActionControls.tsx
old mode 100644
new mode 100755
diff --git a/src/components/trials/wizard/EventsLogSidebar.tsx b/src/components/trials/wizard/EventsLogSidebar.tsx
old mode 100644
new mode 100755
diff --git a/src/components/trials/wizard/ExecutionStepDisplay.tsx b/src/components/trials/wizard/ExecutionStepDisplay.tsx
old mode 100644
new mode 100755
diff --git a/src/components/trials/wizard/ParticipantInfo.tsx b/src/components/trials/wizard/ParticipantInfo.tsx
old mode 100644
new mode 100755
diff --git a/src/components/trials/wizard/RobotActionsPanel.tsx b/src/components/trials/wizard/RobotActionsPanel.tsx
new file mode 100755
index 0000000..60a2dbd
--- /dev/null
+++ b/src/components/trials/wizard/RobotActionsPanel.tsx
@@ -0,0 +1,1104 @@
+"use client";
+
+import React, { useState, useEffect, useMemo, useCallback } from "react";
+import {
+ Bot,
+ Play,
+ Settings,
+ AlertCircle,
+ CheckCircle,
+ Loader2,
+ Volume2,
+ Move,
+ Eye,
+ Hand,
+ Zap,
+ Wifi,
+ WifiOff,
+} from "lucide-react";
+import { Button } from "~/components/ui/button";
+import { Badge } from "~/components/ui/badge";
+import { Input } from "~/components/ui/input";
+import { Label } from "~/components/ui/label";
+import { Textarea } from "~/components/ui/textarea";
+import { Slider } from "~/components/ui/slider";
+import { Switch } from "~/components/ui/switch";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "~/components/ui/select";
+import { ScrollArea } from "~/components/ui/scroll-area";
+import { Separator } from "~/components/ui/separator";
+import { Alert, AlertDescription } from "~/components/ui/alert";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "~/components/ui/card";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "~/components/ui/collapsible";
+import { api } from "~/trpc/react";
+import { toast } from "sonner";
+import { useWizardRos } from "~/hooks/useWizardRos";
+
+interface RobotAction {
+ id: string;
+ name: string;
+ description: string;
+ category: string;
+ parameters?: Array<{
+ name: string;
+ type: "text" | "number" | "boolean" | "select";
+ description: string;
+ required: boolean;
+ min?: number;
+ max?: number;
+ step?: number;
+ default?: unknown;
+ options?: Array<{ value: string; label: string }>;
+ placeholder?: string;
+ maxLength?: number;
+ }>;
+}
+
+interface Plugin {
+ plugin: {
+ id: string;
+ name: string;
+ version: string;
+ description: string;
+ trustLevel: string;
+ actionDefinitions: RobotAction[];
+ };
+ installation: {
+ id: string;
+ configuration: Record;
+ installedAt: Date;
+ };
+}
+
+interface RobotActionsPanelProps {
+ studyId: string;
+ trialId: string;
+ onExecuteAction?: (
+ pluginName: string,
+ actionId: string,
+ parameters: Record,
+ ) => Promise;
+}
+
+// Helper functions moved outside component to prevent re-renders
+const getCategoryIcon = (category: string) => {
+ switch (category.toLowerCase()) {
+ case "movement":
+ return Move;
+ case "speech":
+ return Volume2;
+ case "sensors":
+ return Eye;
+ case "interaction":
+ return Hand;
+ default:
+ return Zap;
+ }
+};
+
+const groupActionsByCategory = (actions: RobotAction[]) => {
+ const grouped: Record = {};
+
+ actions.forEach((action) => {
+ const category = action.category ?? "other";
+ if (!grouped[category]) {
+ grouped[category] = [];
+ }
+ grouped[category]!.push(action);
+ });
+
+ return grouped;
+};
+
+export function RobotActionsPanel({
+ studyId,
+ trialId: _trialId,
+ onExecuteAction,
+}: RobotActionsPanelProps) {
+ const [selectedPlugin, setSelectedPlugin] = useState("");
+ const [selectedAction, setSelectedAction] = useState(
+ null,
+ );
+ const [actionParameters, setActionParameters] = useState<
+ Record
+ >({});
+ const [executingActions, setExecutingActions] = useState>(
+ new Set(),
+ );
+ const [expandedCategories, setExpandedCategories] = useState>(
+ new Set(["movement", "speech"]),
+ );
+
+ // WebSocket ROS integration
+ const {
+ isConnected: rosConnected,
+ isConnecting: rosConnecting,
+ connectionError: rosError,
+ robotStatus,
+ activeActions,
+ connect: connectRos,
+ disconnect: disconnectRos,
+ executeRobotAction: executeRosAction,
+ } = useWizardRos({
+ autoConnect: true,
+ onActionCompleted: (execution) => {
+ toast.success(`Completed: ${execution.actionId}`, {
+ description: `Action executed in ${execution.endTime ? execution.endTime.getTime() - execution.startTime.getTime() : 0}ms`,
+ });
+ // Remove from executing set
+ setExecutingActions((prev) => {
+ const next = new Set(prev);
+ next.delete(`${execution.pluginName}.${execution.actionId}`);
+ return next;
+ });
+ },
+ onActionFailed: (execution) => {
+ toast.error(`Failed: ${execution.actionId}`, {
+ description: execution.error || "Unknown error",
+ });
+ // Remove from executing set
+ setExecutingActions((prev) => {
+ const next = new Set(prev);
+ next.delete(`${execution.pluginName}.${execution.actionId}`);
+ return next;
+ });
+ },
+ });
+
+ // Get installed plugins for the study
+ const { data: plugins = [], isLoading } =
+ api.robots.plugins.getStudyPlugins.useQuery({
+ studyId,
+ });
+
+ // Get actions for selected plugin - memoized to prevent infinite re-renders
+ const selectedPluginData = useMemo(
+ () => plugins.find((p) => p.plugin.id === selectedPlugin),
+ [plugins, selectedPlugin],
+ );
+
+ // Initialize parameters when action changes
+ useEffect(() => {
+ if (selectedAction) {
+ const defaultParams: Record = {};
+
+ selectedAction.parameters?.forEach((param) => {
+ if (param.default !== undefined) {
+ defaultParams[param.name] = param.default;
+ } else if (param.required) {
+ // Set reasonable defaults for required params
+ switch (param.type) {
+ case "text":
+ defaultParams[param.name] = "";
+ break;
+ case "number":
+ defaultParams[param.name] = param.min ?? 0;
+ break;
+ case "boolean":
+ defaultParams[param.name] = false;
+ break;
+ case "select":
+ defaultParams[param.name] = param.options?.[0]?.value ?? "";
+ break;
+ }
+ }
+ });
+
+ setActionParameters(defaultParams);
+ } else {
+ setActionParameters({});
+ }
+ }, [selectedAction]);
+
+ const toggleCategory = useCallback((category: string) => {
+ setExpandedCategories((prev) => {
+ const next = new Set(prev);
+ if (next.has(category)) {
+ next.delete(category);
+ } else {
+ next.add(category);
+ }
+ return next;
+ });
+ }, []);
+
+ const handleExecuteAction = useCallback(async () => {
+ if (!selectedAction || !selectedPluginData) return;
+
+ const actionKey = `${selectedPluginData.plugin.name}.${selectedAction.id}`;
+ setExecutingActions((prev) => new Set([...prev, actionKey]));
+
+ try {
+ // Get action configuration from plugin
+ const actionDef = (
+ selectedPluginData.plugin.actionDefinitions as RobotAction[]
+ )?.find((def: RobotAction) => def.id === selectedAction.id);
+
+ // Try direct WebSocket execution first
+ if (rosConnected && actionDef) {
+ try {
+ // Look for ROS2 configuration in the action definition
+ const actionConfig = (actionDef as any).ros2
+ ? {
+ topic: (actionDef as any).ros2.topic,
+ messageType: (actionDef as any).ros2.messageType,
+ payloadMapping: (actionDef as any).ros2.payloadMapping,
+ }
+ : undefined;
+
+ await executeRosAction(
+ selectedPluginData.plugin.name,
+ selectedAction.id,
+ actionParameters,
+ actionConfig,
+ );
+
+ toast.success(`Executed: ${selectedAction.name}`, {
+ description: `Robot action completed via WebSocket`,
+ });
+ } catch (rosError) {
+ console.warn(
+ "WebSocket execution failed, falling back to tRPC:",
+ rosError,
+ );
+
+ // Fallback to tRPC execution
+ if (onExecuteAction) {
+ await onExecuteAction(
+ selectedPluginData.plugin.name,
+ selectedAction.id,
+ actionParameters,
+ );
+
+ toast.success(`Executed: ${selectedAction.name}`, {
+ description: `Robot action completed via tRPC fallback`,
+ });
+ } else {
+ throw rosError;
+ }
+ }
+ } else if (onExecuteAction) {
+ // Use tRPC execution if WebSocket not available
+ await onExecuteAction(
+ selectedPluginData.plugin.name,
+ selectedAction.id,
+ actionParameters,
+ );
+
+ toast.success(`Executed: ${selectedAction.name}`, {
+ description: `Robot action completed via tRPC`,
+ });
+ } else {
+ throw new Error("No execution method available");
+ }
+ } catch (error) {
+ toast.error(`Failed to execute: ${selectedAction.name}`, {
+ description: error instanceof Error ? error.message : "Unknown error",
+ });
+ } finally {
+ setExecutingActions((prev) => {
+ const next = new Set(prev);
+ next.delete(actionKey);
+ return next;
+ });
+ }
+ }, [
+ selectedAction,
+ selectedPluginData,
+ rosConnected,
+ executeRosAction,
+ onExecuteAction,
+ ]);
+
+ const handleParameterChange = useCallback(
+ (paramName: string, value: unknown) => {
+ setActionParameters((prev) => ({
+ ...prev,
+ [paramName]: value,
+ }));
+ },
+ [],
+ );
+
+ const renderParameterInput = (
+ param: NonNullable[0],
+ _paramIndex: number,
+ ) => {
+ if (!param) return null;
+
+ const value = actionParameters[param.name];
+
+ switch (param.type) {
+ case "text":
+ return (
+
+
+ {param.name} {param.required && "*"}
+
+ {param.maxLength && param.maxLength > 100 ? (
+
+ );
+
+ case "number":
+ return (
+
+
+ {param.name} {param.required && "*"}
+
+ {param.min !== undefined && param.max !== undefined ? (
+
+
+ handleParameterChange(param.name, newValue[0])
+ }
+ min={param.min}
+ max={param.max}
+ step={param.step || 0.1}
+ className="w-full"
+ />
+
+ {Number(value) || param.min}
+
+
+ ) : (
+
+ handleParameterChange(param.name, Number(e.target.value))
+ }
+ min={param.min}
+ max={param.max}
+ step={param.step}
+ />
+ )}
+
{param.description}
+
+ );
+
+ case "boolean":
+ return (
+
+
+ handleParameterChange(param.name, checked)
+ }
+ />
+
+ {param.name} {param.required && "*"}
+
+
+ {param.description}
+
+
+ );
+
+ case "select":
+ return (
+
+
+ {param.name} {param.required && "*"}
+
+
+ handleParameterChange(param.name, newValue)
+ }
+ >
+
+
+
+
+ {param.options?.map(
+ (option: { value: string; label: string }) => (
+
+ {option.label}
+
+ ),
+ )}
+
+
+
{param.description}
+
+ );
+
+ default:
+ return null;
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+
+ Loading robot plugins...
+
+ );
+ }
+
+ if (plugins.length === 0) {
+ return (
+
+
+
+ {rosConnected ? (
+
+ ) : rosConnecting ? (
+
+ ) : (
+
+ )}
+ ROS Bridge
+
+
+
+
+ {rosConnected
+ ? "Connected"
+ : rosConnecting
+ ? "Connecting"
+ : "Disconnected"}
+
+
+ {!rosConnected && !rosConnecting && (
+ connectRos()}>
+ Connect
+
+ )}
+
+ {rosConnected && (
+ disconnectRos()}
+ >
+ Disconnect
+
+ )}
+
+
+
+
+
+ No robot plugins are installed in this study. Install plugins to
+ control robots during trials.
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Plugin Selection */}
+
+
Select Robot Plugin
+
+
+
+
+
+ {plugins.map((plugin) => (
+
+
+
+
+ {plugin.plugin.name} v{plugin.plugin.version}
+
+
+ {plugin.plugin.trustLevel}
+
+
+
+ ))}
+
+
+
+
+ {/* Action Selection */}
+ {selectedPluginData && (
+
+
Available Actions
+
+
+ {Object.entries(
+ groupActionsByCategory(
+ (selectedPluginData.plugin
+ .actionDefinitions as RobotAction[]) || [],
+ ),
+ ).map(([category, actions]) => {
+ const CategoryIcon = getCategoryIcon(category);
+ const isExpanded = expandedCategories.has(category);
+
+ return (
+ toggleCategory(category)}
+ >
+
+
+
+ {category.charAt(0).toUpperCase() + category.slice(1)}
+
+ {actions.length}
+
+
+
+
+ {actions.map((action) => (
+ setSelectedAction(action)}
+ >
+ {action.name}
+
+ ))}
+
+
+ );
+ })}
+
+
+
+ )}
+
+ {/* Action Configuration */}
+ {selectedAction && (
+
+
+
+
+ {selectedAction.name}
+
+ {selectedAction.description}
+
+
+ {/* Parameters */}
+ {selectedAction.parameters &&
+ selectedAction.parameters.length > 0 ? (
+
+ Parameters
+ {selectedAction.parameters.map((param, index) =>
+ renderParameterInput(param, index),
+ )}
+
+ ) : (
+
+ This action requires no parameters.
+
+ )}
+
+
+
+ {/* Execute Button */}
+
+ {selectedPluginData &&
+ executingActions.has(
+ `${selectedPluginData.plugin.name}.${selectedAction.id}`,
+ ) ? (
+ <>
+
+ Executing...
+ >
+ ) : (
+ <>
+
+ Execute Action
+ >
+ )}
+
+
+
+ )}
+
+ {/* Quick Actions */}
+
+
+
+
+ Quick Actions
+
+
+ Common robot actions for quick execution
+
+
+
+
+
{
+ if (rosConnected) {
+ executeRosAction("nao6-ros2", "say_text", {
+ text: "Hello, I am ready!",
+ }).catch((error) => {
+ console.error("Quick action failed:", error);
+ });
+ }
+ }}
+ disabled={!rosConnected || rosConnecting}
+ >
+
+ Say Hello
+
+
+
{
+ if (rosConnected) {
+ executeRosAction("nao6-ros2", "emergency_stop", {}).catch(
+ (error) => {
+ console.error("Emergency stop failed:", error);
+ },
+ );
+ }
+ }}
+ disabled={!rosConnected || rosConnecting}
+ >
+
+ Stop
+
+
+
{
+ if (rosConnected) {
+ executeRosAction("nao6-ros2", "turn_head", {
+ yaw: 0,
+ pitch: 0,
+ speed: 0.3,
+ }).catch((error) => {
+ console.error("Head center failed:", error);
+ });
+ }
+ }}
+ disabled={!rosConnected || rosConnecting}
+ >
+
+ Center Head
+
+
+
{
+ if (rosConnected) {
+ executeRosAction("nao6-ros2", "walk_forward", {
+ speed: 0.1,
+ duration: 2,
+ }).catch((error) => {
+ console.error("Walk forward failed:", error);
+ });
+ }
+ }}
+ disabled={!rosConnected || rosConnecting}
+ >
+
+ Walk Test
+
+
+
+
+
+ );
+
+ function ConnectionStatus() {
+ return (
+
+
+ {rosConnected ? (
+
+ ) : rosConnecting ? (
+
+ ) : (
+
+ )}
+ ROS Bridge
+
+
+
+
+ {rosConnected
+ ? "Connected"
+ : rosConnecting
+ ? "Connecting"
+ : "Disconnected"}
+
+
+ {!rosConnected && !rosConnecting && (
+ connectRos()}>
+ Connect
+
+ )}
+
+ {rosConnected && (
+ disconnectRos()}>
+ Disconnect
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {rosConnected ? (
+
+ ) : rosConnecting ? (
+
+ ) : (
+
+ )}
+ ROS Bridge
+
+
+
+
+ {rosConnected
+ ? "Connected"
+ : rosConnecting
+ ? "Connecting"
+ : "Disconnected"}
+
+
+ {!rosConnected && !rosConnecting && (
+ connectRos()}>
+ Connect
+
+ )}
+
+ {rosConnected && (
+ disconnectRos()}>
+ Disconnect
+
+ )}
+
+
+ {/* Plugin Selection */}
+
+
Select Robot Plugin
+
+
+
+
+
+ {plugins.map((plugin) => (
+
+
+
+
+ {plugin.plugin.name} v{plugin.plugin.version}
+
+
+ {plugin.plugin.trustLevel}
+
+
+
+ ))}
+
+
+
+
+ {/* Action Selection */}
+ {selectedPluginData && (
+
+
Available Actions
+
+
+ {selectedPluginData &&
+ Object.entries(
+ groupActionsByCategory(
+ (selectedPluginData.plugin
+ .actionDefinitions as RobotAction[]) ?? [],
+ ),
+ ).map(([category, actions]) => {
+ const CategoryIcon = getCategoryIcon(category);
+ const isExpanded = expandedCategories.has(category);
+
+ return (
+ toggleCategory(category)}
+ >
+
+
+
+ {category.charAt(0).toUpperCase() + category.slice(1)}
+
+ {actions.length}
+
+
+
+
+ {actions.map((action) => (
+ setSelectedAction(action)}
+ >
+ {action.name}
+
+ ))}
+
+
+ );
+ })}
+
+
+
+ )}
+
+ {/* Action Configuration */}
+ {selectedAction && (
+
+
+
+
+ {selectedAction?.name}
+
+ {selectedAction?.description}
+
+
+ {/* Parameters */}
+ {selectedAction?.parameters &&
+ (selectedAction.parameters?.length ?? 0) > 0 ? (
+
+ Parameters
+ {selectedAction?.parameters?.map((param, index) =>
+ renderParameterInput(param, index),
+ )}
+
+ ) : (
+
+ This action requires no parameters.
+
+ )}
+
+
+
+ {/* Execute Button */}
+
+ {selectedPluginData &&
+ selectedAction &&
+ executingActions.has(
+ `${selectedPluginData?.plugin.name}.${selectedAction?.id}`,
+ ) ? (
+ <>
+
+ Executing...
+ >
+ ) : (
+ <>
+
+ Execute Action
+ >
+ )}
+
+
+
+ )}
+
+ {/* Quick Actions */}
+
+
+
+
+ Quick Actions
+
+
+ Common robot actions for quick execution
+
+
+
+
+
{
+ if (rosConnected) {
+ executeRosAction("nao6-ros2", "say_text", {
+ text: "Hello, I am ready!",
+ }).catch((error: unknown) => {
+ console.error("Quick action failed:", error);
+ });
+ }
+ }}
+ disabled={!rosConnected || rosConnecting}
+ >
+
+ Say Hello
+
+
+
{
+ if (rosConnected) {
+ executeRosAction("nao6-ros2", "emergency_stop", {}).catch(
+ (error: unknown) => {
+ console.error("Emergency stop failed:", error);
+ },
+ );
+ }
+ }}
+ disabled={!rosConnected || rosConnecting}
+ >
+
+ Stop
+
+
+
{
+ if (rosConnected) {
+ executeRosAction("nao6-ros2", "turn_head", {
+ yaw: 0,
+ pitch: 0,
+ speed: 0.3,
+ }).catch((error: unknown) => {
+ console.error("Head center failed:", error);
+ });
+ }
+ }}
+ disabled={!rosConnected || rosConnecting}
+ >
+
+ Center Head
+
+
+
{
+ if (rosConnected) {
+ executeRosAction("nao6-ros2", "walk_forward", {
+ speed: 0.1,
+ duration: 2,
+ }).catch((error: unknown) => {
+ console.error("Walk forward failed:", error);
+ });
+ }
+ }}
+ disabled={!rosConnected || rosConnecting}
+ >
+
+ Walk Test
+
+
+
+
+
+ );
+}
diff --git a/src/components/trials/wizard/StepDisplay.tsx b/src/components/trials/wizard/StepDisplay.tsx
old mode 100644
new mode 100755
diff --git a/src/components/trials/wizard/TrialProgress.tsx b/src/components/trials/wizard/TrialProgress.tsx
old mode 100644
new mode 100755
diff --git a/src/components/trials/wizard/WizardInterface.tsx b/src/components/trials/wizard/WizardInterface.tsx
old mode 100644
new mode 100755
index 3bb9f26..4f37788
--- a/src/components/trials/wizard/WizardInterface.tsx
+++ b/src/components/trials/wizard/WizardInterface.tsx
@@ -1,7 +1,8 @@
"use client";
-import React, { useState, useEffect } from "react";
+import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Play, CheckCircle, X, Clock, AlertCircle } from "lucide-react";
+import { useRouter } from "next/navigation";
import { Badge } from "~/components/ui/badge";
import { Progress } from "~/components/ui/progress";
import { Alert, AlertDescription } from "~/components/ui/alert";
@@ -9,8 +10,14 @@ import { PanelsContainer } from "~/components/experiments/designer/layout/Panels
import { WizardControlPanel } from "./panels/WizardControlPanel";
import { WizardExecutionPanel } from "./panels/WizardExecutionPanel";
import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel";
+import { WizardObservationPane } from "./panels/WizardObservationPane";
+import {
+ ResizableHandle,
+ ResizablePanel,
+ ResizablePanelGroup,
+} from "~/components/ui/resizable";
import { api } from "~/trpc/react";
-// import { useTrialWebSocket } from "~/hooks/useWebSocket"; // Removed WebSocket dependency
+import { useWizardRos } from "~/hooks/useWizardRos";
import { toast } from "sonner";
interface WizardInterfaceProps {
@@ -42,20 +49,31 @@ interface WizardInterfaceProps {
userRole: string;
}
+interface ActionData {
+ id: string;
+ name: string;
+ description: string | null;
+ type: string;
+ parameters: Record;
+ order: number;
+ pluginId: string | null;
+}
+
interface StepData {
id: string;
name: string;
description: string | null;
type:
- | "wizard_action"
- | "robot_action"
- | "parallel_steps"
- | "conditional_branch";
+ | "wizard_action"
+ | "robot_action"
+ | "parallel_steps"
+ | "conditional_branch";
parameters: Record;
order: number;
+ actions: ActionData[];
}
-export function WizardInterface({
+export const WizardInterface = React.memo(function WizardInterface({
trial: initialTrial,
userRole: _userRole,
}: WizardInterfaceProps) {
@@ -65,17 +83,25 @@ export function WizardInterface({
initialTrial.startedAt ? new Date(initialTrial.startedAt) : null,
);
const [elapsedTime, setElapsedTime] = useState(0);
+ const router = useRouter();
// Persistent tab states to prevent resets from parent re-renders
const [controlPanelTab, setControlPanelTab] = useState<
- "control" | "step" | "actions"
+ "control" | "step" | "actions" | "robot"
>("control");
const [executionPanelTab, setExecutionPanelTab] = useState<
"current" | "timeline" | "events"
>(trial.status === "in_progress" ? "current" : "timeline");
+ const [isExecutingAction, setIsExecutingAction] = useState(false);
const [monitoringPanelTab, setMonitoringPanelTab] = useState<
"status" | "robot" | "events"
>("status");
+ const [completedActionsCount, setCompletedActionsCount] = useState(0);
+
+ // Reset completed actions when step changes
+ useEffect(() => {
+ setCompletedActionsCount(0);
+ }, [currentStepIndex]);
// Get experiment steps from API
const { data: experimentSteps } = api.experiments.getSteps.useQuery(
@@ -86,6 +112,27 @@ export function WizardInterface({
},
);
+ // Robot action execution mutation
+ const executeRobotActionMutation = api.trials.executeRobotAction.useMutation({
+ onSuccess: (result) => {
+ toast.success("Robot action executed successfully", {
+ description: `Completed in ${result.duration}ms`,
+ });
+ },
+ onError: (error) => {
+ toast.error("Failed to execute robot action", {
+ description: error.message,
+ });
+ },
+ });
+
+ // Log robot action mutation (for client-side execution)
+ const logRobotActionMutation = api.trials.logRobotAction.useMutation({
+ onError: (error) => {
+ console.error("Failed to log robot action:", error);
+ },
+ });
+
// Map database step types to component step types
const mapStepType = (dbType: string) => {
switch (dbType) {
@@ -102,38 +149,123 @@ export function WizardInterface({
}
};
- // Use polling for real-time updates (no WebSocket dependency)
+ // Memoized callbacks to prevent infinite re-renders
+ const onActionCompleted = useCallback((execution: { actionId: string }) => {
+ toast.success(`Robot action completed: ${execution.actionId}`);
+ }, []);
+
+ const onActionFailed = useCallback((execution: { actionId: string; error?: string }) => {
+ toast.error(`Robot action failed: ${execution.actionId}`, {
+ description: execution.error,
+ });
+ }, []);
+
+ // ROS WebSocket connection for robot control
+ const {
+ isConnected: rosConnected,
+ isConnecting: rosConnecting,
+ connectionError: rosError,
+ robotStatus,
+ connect: connectRos,
+ disconnect: disconnectRos,
+ executeRobotAction: executeRosAction,
+ setAutonomousLife,
+ } = useWizardRos({
+ autoConnect: true,
+ onActionCompleted,
+ onActionFailed,
+ });
+
+ // Use polling for trial status updates (no trial WebSocket server exists)
const { data: pollingData } = api.trials.get.useQuery(
{ id: trial.id },
{
- refetchInterval: 2000, // Poll every 2 seconds
+ refetchInterval: trial.status === "in_progress" ? 5000 : 15000,
+ staleTime: 2000,
+ refetchOnWindowFocus: false,
},
);
- // Mock trial events for now (can be populated from database later)
- const trialEvents: Array<{
- type: string;
- timestamp: Date;
- data?: unknown;
- message?: string;
- }> = [];
-
- // Update trial data from polling
- React.useEffect(() => {
- if (pollingData) {
- setTrial({
- ...pollingData,
- metadata: pollingData.metadata as Record | null,
- participant: {
- ...pollingData.participant,
- demographics: pollingData.participant.demographics as Record<
- string,
- unknown
- > | null,
- },
- });
+ // Poll for trial events
+ const { data: fetchedEvents } = api.trials.getEvents.useQuery(
+ { trialId: trial.id, limit: 100 },
+ {
+ refetchInterval: 3000,
+ staleTime: 1000,
}
- }, [pollingData]);
+ );
+
+ // Update local trial state from polling only if changed
+ useEffect(() => {
+ if (pollingData && JSON.stringify(pollingData) !== JSON.stringify(trial)) {
+ // Only update if specific fields we care about have changed to avoid
+ // unnecessary re-renders that might cause UI flashing
+ if (pollingData.status !== trial.status ||
+ pollingData.startedAt?.getTime() !== trial.startedAt?.getTime() ||
+ pollingData.completedAt?.getTime() !== trial.completedAt?.getTime()) {
+
+ setTrial((prev) => ({
+ ...prev,
+ status: pollingData.status,
+ startedAt: pollingData.startedAt
+ ? new Date(pollingData.startedAt)
+ : prev.startedAt,
+ completedAt: pollingData.completedAt
+ ? new Date(pollingData.completedAt)
+ : prev.completedAt,
+ }));
+ }
+ }
+ }, [pollingData, trial]);
+
+ // Auto-start trial on mount if scheduled
+ useEffect(() => {
+ if (trial.status === "scheduled") {
+ handleStartTrial();
+ }
+ }, []); // Run once on mount
+
+ // Trial events from robot actions
+
+ const trialEvents = useMemo<
+ Array<{
+ type: string;
+ timestamp: Date;
+ data?: unknown;
+ message?: string;
+ }>
+ >(() => {
+ return (fetchedEvents ?? []).map(event => {
+ let message: string | undefined;
+ const eventData = event.data as any;
+
+ // Extract or generate message based on event type
+ if (event.eventType.startsWith('annotation_')) {
+ message = eventData?.description || eventData?.label || 'Annotation added';
+ } else if (event.eventType.startsWith('robot_action_')) {
+ const actionName = event.eventType.replace('robot_action_', '').replace(/_/g, ' ');
+ message = `Robot action: ${actionName}`;
+ } else if (event.eventType === 'trial_started') {
+ message = 'Trial started';
+ } else if (event.eventType === 'trial_completed') {
+ message = 'Trial completed';
+ } else if (event.eventType === 'step_changed') {
+ message = `Step changed to: ${eventData?.stepName || 'next step'}`;
+ } else if (event.eventType.startsWith('wizard_')) {
+ message = eventData?.notes || eventData?.message || event.eventType.replace('wizard_', '').replace(/_/g, ' ');
+ } else {
+ // Generic fallback
+ message = eventData?.notes || eventData?.message || eventData?.description || event.eventType.replace(/_/g, ' ');
+ }
+
+ return {
+ type: event.eventType,
+ timestamp: new Date(event.timestamp),
+ data: event.data,
+ message,
+ };
+ }).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); // Newest first
+ }, [fetchedEvents]);
// Transform experiment steps to component format
const steps: StepData[] =
@@ -144,6 +276,15 @@ export function WizardInterface({
type: mapStepType(step.type),
parameters: step.parameters ?? {},
order: step.order ?? index,
+ actions: step.actions?.map((action) => ({
+ id: action.id,
+ name: action.name,
+ description: action.description,
+ type: action.type,
+ parameters: action.parameters ?? {},
+ order: action.order,
+ pluginId: action.pluginId,
+ })) ?? [],
})) ?? [];
const currentStep = steps[currentStepIndex] ?? null;
@@ -218,6 +359,8 @@ export function WizardInterface({
status: data.status,
completedAt: data.completedAt,
});
+ toast.success("Trial completed! Redirecting to analysis...");
+ router.push(`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`);
}
},
});
@@ -271,6 +414,7 @@ export function WizardInterface({
const handleNextStep = () => {
if (currentStepIndex < steps.length - 1) {
+ setCompletedActionsCount(0); // Reset immediately to prevent flickering/double-click issues
setCurrentStepIndex(currentStepIndex + 1);
// Note: Step transitions can be enhanced later with database logging
}
@@ -292,18 +436,182 @@ export function WizardInterface({
}
};
+ // Mutations for annotations
+ const addAnnotationMutation = api.trials.addAnnotation.useMutation({
+ onSuccess: () => {
+ toast.success("Note added");
+ },
+ onError: (error) => {
+ toast.error("Failed to add note", { description: error.message });
+ },
+ });
+
+ const handleAddAnnotation = async (
+ description: string,
+ category?: string,
+ tags?: string[],
+ ) => {
+ await addAnnotationMutation.mutateAsync({
+ trialId: trial.id,
+ description,
+ category,
+ tags,
+ });
+ };
+
+ // Mutation for events (Acknowledge)
+ const logEventMutation = api.trials.logEvent.useMutation({
+ onSuccess: () => toast.success("Event logged"),
+ });
+
+ // Mutation for interventions
+ const addInterventionMutation = api.trials.addIntervention.useMutation({
+ onSuccess: () => toast.success("Intervention logged"),
+ });
+
const handleExecuteAction = async (
actionId: string,
parameters?: Record,
) => {
try {
console.log("Executing action:", actionId, parameters);
+
+ if (actionId === "acknowledge") {
+ await logEventMutation.mutateAsync({
+ trialId: trial.id,
+ type: "wizard_acknowledge",
+ data: parameters,
+ });
+ handleNextStep();
+ } else if (actionId === "intervene") {
+ await addInterventionMutation.mutateAsync({
+ trialId: trial.id,
+ type: "manual_intervention",
+ description: "Wizard manual intervention triggered",
+ data: parameters,
+ });
+ } else if (actionId === "note") {
+ await addAnnotationMutation.mutateAsync({
+ trialId: trial.id,
+ description: String(parameters?.content || "Quick note"),
+ category: String(parameters?.category || "quick_note")
+ });
+ }
+
// Note: Action execution can be enhanced later with tRPC mutations
} catch (error) {
console.error("Failed to execute action:", error);
+ toast.error("Failed to execute action");
}
};
+ const handleExecuteRobotAction = useCallback(
+ async (
+ pluginName: string,
+ actionId: string,
+ parameters: Record,
+ options?: { autoAdvance?: boolean },
+ ) => {
+ try {
+ setIsExecutingAction(true);
+ // Try direct WebSocket execution first for better performance
+ if (rosConnected) {
+ try {
+ const result = await executeRosAction(pluginName, actionId, parameters);
+
+ const duration =
+ result.endTime && result.startTime
+ ? result.endTime.getTime() - result.startTime.getTime()
+ : 0;
+
+ // Log to trial events for data capture
+ await logRobotActionMutation.mutateAsync({
+ trialId: trial.id,
+ pluginName,
+ actionId,
+ parameters,
+ duration,
+ result: { status: result.status },
+ });
+
+ toast.success(`Robot action executed: ${actionId}`);
+ if (options?.autoAdvance) {
+ handleNextStep();
+ }
+ } catch (rosError) {
+ console.warn(
+ "WebSocket execution failed, falling back to tRPC:",
+ rosError,
+ );
+
+ // Fallback to tRPC-only execution
+ await executeRobotActionMutation.mutateAsync({
+ trialId: trial.id,
+ pluginName,
+ actionId,
+ parameters,
+ });
+
+ toast.success(`Robot action executed via fallback: ${actionId}`);
+ if (options?.autoAdvance) {
+ handleNextStep();
+ }
+ }
+ } else {
+ // Use tRPC execution if WebSocket not connected
+ await executeRobotActionMutation.mutateAsync({
+ trialId: trial.id,
+ pluginName,
+ actionId,
+ parameters,
+ });
+
+ toast.success(`Robot action executed: ${actionId}`);
+ if (options?.autoAdvance) {
+ handleNextStep();
+ }
+ }
+ } catch (error) {
+ console.error("Failed to execute robot action:", error);
+ toast.error(`Failed to execute robot action: ${actionId}`, {
+ description: error instanceof Error ? error.message : "Unknown error",
+ });
+ } finally {
+ setIsExecutingAction(false);
+ }
+ },
+ [rosConnected, executeRosAction, executeRobotActionMutation, trial.id],
+ );
+
+ const handleSkipAction = useCallback(
+ async (
+ pluginName: string,
+ actionId: string,
+ parameters: Record,
+ options?: { autoAdvance?: boolean },
+ ) => {
+ try {
+ await logRobotActionMutation.mutateAsync({
+ trialId: trial.id,
+ pluginName,
+ actionId,
+ parameters,
+ duration: 0,
+ result: { skipped: true },
+ });
+
+ toast.info(`Action skipped: ${actionId}`);
+ if (options?.autoAdvance) {
+ handleNextStep();
+ }
+ } catch (error) {
+ console.error("Failed to skip action:", error);
+ toast.error("Failed to skip action");
+ }
+ },
+ [logRobotActionMutation, trial.id],
+ );
+
return (
{/* Compact Status Bar */}
@@ -340,71 +648,97 @@ export function WizardInterface({
{trial.experiment.name}
{trial.participant.participantCode}
-
- Polling
+
+ {rosConnected ? "ROS Connected" : "ROS Offline"}
- {/* Connection Status */}
-
-
-
- Using polling mode for trial updates (refreshes every 2 seconds).
-
-
-
- {/* Main Content - Three Panel Layout */}
+ {/* Main Content with Vertical Resizable Split */}
-
+
+
+ }
+ center={
+ setCurrentStepIndex(index)}
+ onExecuteAction={handleExecuteAction}
+ onExecuteRobotAction={handleExecuteRobotAction}
+ activeTab={executionPanelTab}
+ onTabChange={setExecutionPanelTab}
+ onSkipAction={handleSkipAction}
+ isExecuting={isExecutingAction}
+ onNextStep={handleNextStep}
+ completedActionsCount={completedActionsCount}
+ onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
+ onCompleteTrial={handleCompleteTrial}
+ readOnly={trial.status === 'completed' || _userRole === 'observer'}
+ />
+ }
+ right={
+
+ }
+ showDividers={true}
+ className="h-full"
/>
- }
- center={
-
+
+
+
+
+ setCurrentStepIndex(index)}
- onExecuteAction={handleExecuteAction}
- activeTab={executionPanelTab}
- onTabChange={setExecutionPanelTab}
+ // Observation pane is where observers usually work, so not readOnly for them?
+ // But maybe we want 'readOnly' for completed trials.
+ readOnly={trial.status === 'completed'}
/>
- }
- right={
-
- }
- showDividers={true}
- className="h-full"
- />
+
+
);
-}
+});
export default WizardInterface;
diff --git a/src/components/trials/wizard/panels/ExecutionPanel.tsx b/src/components/trials/wizard/panels/ExecutionPanel.tsx
old mode 100644
new mode 100755
diff --git a/src/components/trials/wizard/panels/MonitoringPanel.tsx b/src/components/trials/wizard/panels/MonitoringPanel.tsx
old mode 100644
new mode 100755
diff --git a/src/components/trials/wizard/panels/TrialControlPanel.tsx b/src/components/trials/wizard/panels/TrialControlPanel.tsx
old mode 100644
new mode 100755
diff --git a/src/components/trials/wizard/panels/WebcamPanel.tsx b/src/components/trials/wizard/panels/WebcamPanel.tsx
new file mode 100644
index 0000000..8f6a50e
--- /dev/null
+++ b/src/components/trials/wizard/panels/WebcamPanel.tsx
@@ -0,0 +1,268 @@
+"use client";
+
+import React, { useCallback, useRef, useState } from "react";
+import Webcam from "react-webcam";
+import { Camera, CameraOff, Video, StopCircle, Loader2 } from "lucide-react";
+import { Button } from "~/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "~/components/ui/select";
+import { Alert, AlertDescription } from "~/components/ui/alert";
+import { AspectRatio } from "~/components/ui/aspect-ratio";
+import { toast } from "sonner";
+import { api } from "~/trpc/react";
+
+export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
+ const [deviceId, setDeviceId] = useState
(null);
+ const [devices, setDevices] = useState([]);
+ const [isCameraEnabled, setIsCameraEnabled] = useState(false);
+ const [isRecording, setIsRecording] = useState(false);
+ const [uploading, setUploading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const webcamRef = useRef(null);
+ const mediaRecorderRef = useRef(null);
+ const chunksRef = useRef([]);
+
+ // TRPC mutation for presigned URL
+ const getUploadUrlMutation = api.storage.getUploadPresignedUrl.useMutation();
+
+ const handleDevices = useCallback(
+ (mediaDevices: MediaDeviceInfo[]) => {
+ setDevices(mediaDevices.filter(({ kind, deviceId }) => kind === "videoinput" && deviceId !== ""));
+ },
+ [setDevices],
+ );
+
+ React.useEffect(() => {
+ navigator.mediaDevices.enumerateDevices().then(handleDevices);
+ }, [handleDevices]);
+
+ const handleEnableCamera = () => {
+ setIsCameraEnabled(true);
+ setError(null);
+ };
+
+ const handleDisableCamera = () => {
+ if (isRecording) {
+ handleStopRecording();
+ }
+ setIsCameraEnabled(false);
+ };
+
+ const handleStartRecording = () => {
+ if (!webcamRef.current?.stream) return;
+
+ setIsRecording(true);
+ chunksRef.current = [];
+
+ try {
+ const recorder = new MediaRecorder(webcamRef.current.stream, {
+ mimeType: "video/webm"
+ });
+
+ recorder.ondataavailable = (event) => {
+ if (event.data.size > 0) {
+ chunksRef.current.push(event.data);
+ }
+ };
+
+ recorder.onstop = async () => {
+ const blob = new Blob(chunksRef.current, { type: "video/webm" });
+ await handleUpload(blob);
+ };
+
+ recorder.start();
+ mediaRecorderRef.current = recorder;
+ toast.success("Recording started");
+ } catch (e) {
+ console.error("Failed to start recorder:", e);
+ toast.error("Failed to start recording");
+ setIsRecording(false);
+ }
+ };
+
+ const handleStopRecording = () => {
+ if (mediaRecorderRef.current && isRecording) {
+ mediaRecorderRef.current.stop();
+ setIsRecording(false);
+ }
+ };
+
+ const handleUpload = async (blob: Blob) => {
+ setUploading(true);
+ const filename = `recording-${Date.now()}.webm`;
+
+ try {
+ // 1. Get Presigned URL
+ const { url } = await getUploadUrlMutation.mutateAsync({
+ filename,
+ contentType: "video/webm",
+ });
+
+ // 2. Upload to S3
+ const response = await fetch(url, {
+ method: "PUT",
+ body: blob,
+ headers: {
+ "Content-Type": "video/webm",
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error("Upload failed");
+ }
+
+ toast.success("Recording uploaded successfully");
+ console.log("Uploaded recording:", filename);
+ } catch (e) {
+ console.error("Upload error:", e);
+ toast.error("Failed to upload recording");
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ return (
+
+
+
+
+ Webcam Feed
+
+
+ {!readOnly && (
+
+ {devices.length > 0 && (
+
+
+
+
+
+ {devices.map((device, key) => (
+
+ {device.label || `Camera ${key + 1}`}
+
+ ))}
+
+
+ )}
+
+ {isCameraEnabled && (
+ !isRecording ? (
+
+
+ Record
+
+ ) : (
+
+
+ Stop Rec
+
+ )
+ )}
+
+ {isCameraEnabled ? (
+
+
+ Off
+
+ ) : (
+
+
+ Start Camera
+
+ )}
+
+ )}
+
+
+
+ {isCameraEnabled ? (
+
+
+ setError(String(err))}
+ className="object-contain w-full h-full"
+ />
+
+
+ {/* Recording Overlay */}
+ {isRecording && (
+
+ )}
+
+ {/* Uploading Overlay */}
+ {uploading && (
+
+ )}
+
+ {error && (
+
+ )}
+
+ ) : (
+
+
+
Camera is disabled
+
+ Enable Camera
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/trials/wizard/panels/WizardControlPanel.tsx b/src/components/trials/wizard/panels/WizardControlPanel.tsx
old mode 100644
new mode 100755
index 055a5db..59bdc02
--- a/src/components/trials/wizard/panels/WizardControlPanel.tsx
+++ b/src/components/trials/wizard/panels/WizardControlPanel.tsx
@@ -12,26 +12,40 @@ import {
Settings,
Zap,
User,
+ Bot,
+ Eye,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Progress } from "~/components/ui/progress";
import { Separator } from "~/components/ui/separator";
+import { Switch } from "~/components/ui/switch";
+import { Label } from "~/components/ui/label";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import { ScrollArea } from "~/components/ui/scroll-area";
+import { RobotActionsPanel } from "../RobotActionsPanel";
interface StepData {
id: string;
name: string;
description: string | null;
type:
- | "wizard_action"
- | "robot_action"
- | "parallel_steps"
- | "conditional_branch";
+ | "wizard_action"
+ | "robot_action"
+ | "parallel_steps"
+ | "conditional_branch";
parameters: Record;
order: number;
+ actions?: {
+ id: string;
+ name: string;
+ description: string | null;
+ type: string;
+ parameters: Record;
+ order: number;
+ pluginId: string | null;
+ }[];
}
interface TrialData {
@@ -73,10 +87,18 @@ interface WizardControlPanelProps {
actionId: string,
parameters?: Record,
) => void;
+ onExecuteRobotAction?: (
+ pluginName: string,
+ actionId: string,
+ parameters: Record,
+ ) => Promise;
+ studyId?: string;
_isConnected: boolean;
- activeTab: "control" | "step" | "actions";
- onTabChange: (tab: "control" | "step" | "actions") => void;
+ activeTab: "control" | "step" | "actions" | "robot";
+ onTabChange: (tab: "control" | "step" | "actions" | "robot") => void;
isStarting?: boolean;
+ onSetAutonomousLife?: (enabled: boolean) => Promise;
+ readOnly?: boolean;
}
export function WizardControlPanel({
@@ -90,75 +112,46 @@ export function WizardControlPanel({
onCompleteTrial,
onAbortTrial,
onExecuteAction,
+ onExecuteRobotAction,
+ studyId,
_isConnected,
activeTab,
onTabChange,
isStarting = false,
+ onSetAutonomousLife,
+ readOnly = false,
}: WizardControlPanelProps) {
- const progress =
- steps.length > 0 ? ((currentStepIndex + 1) / steps.length) * 100 : 0;
+ const [autonomousLife, setAutonomousLife] = React.useState(true);
- const getStatusConfig = (status: string) => {
- switch (status) {
- case "scheduled":
- return { variant: "outline" as const, icon: Clock };
- case "in_progress":
- return { variant: "default" as const, icon: Play };
- case "completed":
- return { variant: "secondary" as const, icon: CheckCircle };
- case "aborted":
- case "failed":
- return { variant: "destructive" as const, icon: X };
- default:
- return { variant: "outline" as const, icon: Clock };
+ const handleAutonomousLifeChange = async (checked: boolean) => {
+ setAutonomousLife(checked); // Optimistic update
+ if (onSetAutonomousLife) {
+ try {
+ const result = await onSetAutonomousLife(checked);
+ if (result === false) {
+ throw new Error("Service unavailable");
+ }
+ } catch (error) {
+ console.error("Failed to set autonomous life:", error);
+ setAutonomousLife(!checked); // Revert on failure
+ // Optional: Toast error?
+ }
}
};
- const statusConfig = getStatusConfig(trial.status);
- const StatusIcon = statusConfig.icon;
-
return (
- {/* Trial Info Header */}
-
-
-
-
-
- {trial.status.replace("_", " ")}
-
-
- Session #{trial.sessionNumber}
-
-
-
-
- {trial.participant.participantCode}
-
-
- {trial.status === "in_progress" && steps.length > 0 && (
-
-
- Progress
-
- {currentStepIndex + 1} of {steps.length}
-
-
-
-
- )}
-
-
-
{/* Tabbed Content */}
{
- if (value === "control" || value === "step" || value === "actions") {
- onTabChange(value);
+ if (
+ value === "control" ||
+ value === "step" ||
+ value === "actions" ||
+ value === "robot"
+ ) {
+ onTabChange(value as "control" | "step" | "actions");
}
}}
className="flex min-h-0 flex-1 flex-col"
@@ -166,11 +159,11 @@ export function WizardControlPanel({
-
+
Control
-
+
Step
@@ -196,7 +189,7 @@ export function WizardControlPanel({
}}
className="w-full"
size="sm"
- disabled={isStarting}
+ disabled={isStarting || readOnly}
>
{isStarting ? "Starting..." : "Start Trial"}
@@ -210,14 +203,14 @@ export function WizardControlPanel({
onClick={onPauseTrial}
variant="outline"
size="sm"
- disabled={false}
+ disabled={readOnly}
>
Pause
= steps.length - 1}
+ disabled={(currentStepIndex >= steps.length - 1) || readOnly}
size="sm"
>
@@ -232,6 +225,7 @@ export function WizardControlPanel({
variant="outline"
className="w-full"
size="sm"
+ disabled={readOnly}
>
Complete Trial
@@ -242,6 +236,7 @@ export function WizardControlPanel({
variant="destructive"
className="w-full"
size="sm"
+ disabled={readOnly}
>
Abort Trial
@@ -251,25 +246,44 @@ export function WizardControlPanel({
{(trial.status === "completed" ||
trial.status === "aborted") && (
-
-
-
- Trial has ended. All controls are disabled.
-
-
- )}
+
+
+
+ Trial has ended. All controls are disabled.
+
+
+ )}
- {/* Connection Status */}
-
-
Connection
+
+
Robot Status
+
- Status
+ Connection
-
- Polling
-
+ {_isConnected ? (
+
+ Connected
+
+ ) : (
+
+ Polling...
+
+ )}
+
+
+
@@ -358,7 +372,7 @@ export function WizardControlPanel({
console.log("[WizardControlPanel] Acknowledge clicked");
onExecuteAction("acknowledge");
}}
- disabled={false}
+ disabled={readOnly}
>
Acknowledge
@@ -372,7 +386,7 @@ export function WizardControlPanel({
console.log("[WizardControlPanel] Intervene clicked");
onExecuteAction("intervene");
}}
- disabled={false}
+ disabled={readOnly}
>
Intervene
@@ -386,7 +400,7 @@ export function WizardControlPanel({
console.log("[WizardControlPanel] Add Note clicked");
onExecuteAction("note", { content: "Wizard note" });
}}
- disabled={false}
+ disabled={readOnly}
>
Add Note
@@ -402,7 +416,7 @@ export function WizardControlPanel({
size="sm"
className="w-full justify-start"
onClick={() => onExecuteAction("step_complete")}
- disabled={false}
+ disabled={readOnly}
>
Mark Complete
@@ -422,6 +436,34 @@ export function WizardControlPanel({
+
+ {/* Robot Actions Tab */}
+
+
+
+ {studyId && onExecuteRobotAction ? (
+
+
+
+ ) : (
+
+
+
+ Robot actions are not available. Study ID or action
+ handler is missing.
+
+
+ )}
+
+
+
diff --git a/src/components/trials/wizard/panels/WizardExecutionPanel.tsx b/src/components/trials/wizard/panels/WizardExecutionPanel.tsx
old mode 100644
new mode 100755
index d85e91a..6335778
--- a/src/components/trials/wizard/panels/WizardExecutionPanel.tsx
+++ b/src/components/trials/wizard/panels/WizardExecutionPanel.tsx
@@ -10,26 +10,34 @@ import {
User,
Activity,
Zap,
- Eye,
- List,
+ ArrowRight,
+ AlertTriangle,
+ RotateCcw,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
-import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import { ScrollArea } from "~/components/ui/scroll-area";
-import { Alert, AlertDescription } from "~/components/ui/alert";
interface StepData {
id: string;
name: string;
description: string | null;
type:
- | "wizard_action"
- | "robot_action"
- | "parallel_steps"
- | "conditional_branch";
+ | "wizard_action"
+ | "robot_action"
+ | "parallel_steps"
+ | "conditional_branch";
parameters: Record;
order: number;
+ actions?: {
+ id: string;
+ name: string;
+ description: string | null;
+ type: string;
+ parameters: Record;
+ order: number;
+ pluginId: string | null;
+ }[];
}
interface TrialData {
@@ -75,8 +83,26 @@ interface WizardExecutionPanelProps {
actionId: string,
parameters?: Record,
) => void;
- activeTab: "current" | "timeline" | "events";
- onTabChange: (tab: "current" | "timeline" | "events") => void;
+ onExecuteRobotAction: (
+ pluginName: string,
+ actionId: string,
+ parameters: Record,
+ options?: { autoAdvance?: boolean },
+ ) => Promise;
+ activeTab: "current" | "timeline" | "events"; // Deprecated/Ignored
+ onTabChange: (tab: "current" | "timeline" | "events") => void; // Deprecated/Ignored
+ onSkipAction: (
+ pluginName: string,
+ actionId: string,
+ parameters: Record,
+ options?: { autoAdvance?: boolean },
+ ) => Promise;
+ isExecuting?: boolean;
+ onNextStep?: () => void;
+ onCompleteTrial?: () => void;
+ completedActionsCount: number;
+ onActionCompleted: () => void;
+ readOnly?: boolean;
}
export function WizardExecutionPanel({
@@ -87,43 +113,21 @@ export function WizardExecutionPanel({
trialEvents,
onStepSelect,
onExecuteAction,
+ onExecuteRobotAction,
activeTab,
onTabChange,
+ onSkipAction,
+ isExecuting = false,
+ onNextStep,
+ onCompleteTrial,
+ completedActionsCount,
+ onActionCompleted,
+ readOnly = false,
}: WizardExecutionPanelProps) {
- const getStepIcon = (type: string) => {
- switch (type) {
- case "wizard_action":
- return User;
- case "robot_action":
- return Bot;
- case "parallel_steps":
- return Activity;
- case "conditional_branch":
- return AlertCircle;
- default:
- return Play;
- }
- };
+ // Local state removed in favor of parent state to prevent reset on re-render
+ // const [completedCount, setCompletedCount] = React.useState(0);
- const getStepStatus = (stepIndex: number) => {
- if (stepIndex < currentStepIndex) return "completed";
- if (stepIndex === currentStepIndex && trial.status === "in_progress")
- return "active";
- return "pending";
- };
-
- const getStepVariant = (status: string) => {
- switch (status) {
- case "completed":
- return "default";
- case "active":
- return "secondary";
- case "pending":
- return "outline";
- default:
- return "outline";
- }
- };
+ const activeActionIndex = completedActionsCount;
// Pre-trial state
if (trial.status === "scheduled") {
@@ -169,7 +173,7 @@ export function WizardExecutionPanel({
{trial.completedAt &&
- `Ended at ${new Date(trial.completedAt).toLocaleTimeString()}`}
+ `Ended at ${new Date(trial.completedAt).toLocaleTimeString()} `}
@@ -209,281 +213,228 @@ export function WizardExecutionPanel({
)}
- {/* Tabbed Content */}
- {
- if (
- value === "current" ||
- value === "timeline" ||
- value === "events"
- ) {
- onTabChange(value);
- }
- }}
- className="flex min-h-0 flex-1 flex-col"
- >
-
-
-
-
- Current
-
-
-
- Timeline
-
-
-
- Events
- {trialEvents.length > 0 && (
-
- {trialEvents.length}
-
- )}
-
-
-
-
-
- {/* Current Step Tab */}
-
-
- {currentStep ? (
-
- {/* Current Step Display */}
-
-
-
- {React.createElement(getStepIcon(currentStep.type), {
- className: "h-5 w-5 text-primary",
- })}
-
-
-
- {currentStep.name}
-
-
- {currentStep.type.replace("_", " ")}
-
-
-
-
+ {/* Simplified Content - Sequential Focus */}
+
+
+ {currentStep ? (
+
+ {/* Header Info (Simplified) */}
+
+
+
+
{currentStep.name}
{currentStep.description && (
-
- {currentStep.description}
-
- )}
-
- {/* Step-specific content */}
- {currentStep.type === "wizard_action" && (
-
-
- Available Actions
-
-
- onExecuteAction("acknowledge")}
- >
-
- Acknowledge Step
-
- onExecuteAction("intervene")}
- >
-
- Manual Intervention
-
-
- onExecuteAction("note", {
- content: "Step observation",
- })
- }
- >
-
- Add Observation
-
-
-
- )}
-
- {currentStep.type === "robot_action" && (
-
-
-
-
- Robot Action in Progress
-
-
- The robot is executing this step. Monitor status in
- the monitoring panel.
-
-
-
- )}
-
- {currentStep.type === "parallel_steps" && (
-
-
-
- Parallel Execution
-
- Multiple actions are running simultaneously.
-
-
-
+
{currentStep.description}
)}
- ) : (
-
-
-
- No current step available
+
+
+ {/* Action Sequence */}
+ {currentStep.actions && currentStep.actions.length > 0 && (
+
+
+
+ Execution Sequence
+
+
+
+
+ {currentStep.actions.map((action, idx) => {
+ const isCompleted = idx < activeActionIndex;
+ const isActive = idx === activeActionIndex;
+
+ return (
+
+
+ {isCompleted ? : idx + 1}
+
+
+
+
{action.name}
+ {action.description && (
+
+ {action.description}
+
+ )}
+
+
+ {action.pluginId && isActive && (
+
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ console.log("Skip clicked");
+ // Fire and forget
+ onSkipAction(
+ action.pluginId!,
+ action.type.includes(".")
+ ? action.type.split(".").pop()!
+ : action.type,
+ action.parameters || {},
+ { autoAdvance: false }
+ );
+ onActionCompleted();
+ }}
+ disabled={readOnly}
+ >
+ Skip
+
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ console.log("Execute clicked");
+ onExecuteRobotAction(
+ action.pluginId!,
+ action.type.includes(".")
+ ? action.type.split(".").pop()!
+ : action.type,
+ action.parameters || {},
+ { autoAdvance: false },
+ );
+ onActionCompleted();
+ }}
+ disabled={readOnly || isExecuting}
+ >
+
+ Execute
+
+
+ )}
+
+ {/* Fallback for actions with no plugin ID (e.g. manual steps) */}
+ {!action.pluginId && isActive && (
+
+ {
+ e.preventDefault();
+ onActionCompleted();
+ }}
+ disabled={readOnly || isExecuting}
+ >
+ Mark Done
+
+
+ )}
+
+ {/* Completed State Indicator */}
+ {isCompleted && (
+
+
+ Done
+
+ {action.pluginId && (
+ <>
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ // Execute again without advancing count
+ onExecuteRobotAction(
+ action.pluginId!,
+ action.type.includes(".") ? action.type.split(".").pop()! : action.type,
+ action.parameters || {},
+ { autoAdvance: false },
+ );
+ }}
+ disabled={readOnly || isExecuting}
+ >
+
+
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ onExecuteAction("note", {
+ content: `Reported issue with action: ${action.name}`,
+ category: "system_issue"
+ });
+ }}
+ disabled={readOnly}
+ >
+
+
+ >
+ )}
+
+ )}
+
+ )
+ })}
+
+
+ {/* Manual Advance Button */}
+ {activeActionIndex >= (currentStep.actions?.length || 0) && (
+
+
+ {currentStepIndex === steps.length - 1 ? "Complete Trial" : "Complete Step"}
+
+
+ )}
+
+ )}
+
+ {/* Manual Wizard Controls (If applicable) */}
+ {currentStep.type === "wizard_action" && (
+
+
Manual Controls
+
+ onExecuteAction("intervene")}
+ disabled={readOnly}
+ >
+
+ Flag Issue / Intervention
+
)}
-
-
- {/* Timeline Tab */}
-
-
-
- {steps.map((step, index) => {
- const status = getStepStatus(index);
- const StepIcon = getStepIcon(step.type);
- const isActive = index === currentStepIndex;
-
- return (
-
onStepSelect(index)}
- >
- {/* Step Number and Status */}
-
-
- {status === "completed" ? (
-
- ) : (
- index + 1
- )}
-
- {index < steps.length - 1 && (
-
- )}
-
-
- {/* Step Content */}
-
-
-
-
- {step.name}
-
-
- {step.type.replace("_", " ")}
-
-
-
- {step.description && (
-
- {step.description}
-
- )}
-
- {isActive && trial.status === "in_progress" && (
-
- )}
-
-
- );
- })}
-
-
-
-
- {/* Events Tab */}
-
-
-
- {trialEvents.length === 0 ? (
-
-
- No events recorded yet
-
-
- ) : (
-
- {trialEvents
- .slice()
- .reverse()
- .map((event, index) => (
-
-
-
-
- {event.type.replace(/_/g, " ")}
-
- {event.message && (
-
- {event.message}
-
- )}
-
- {event.timestamp.toLocaleTimeString()}
-
-
-
- ))}
-
- )}
-
-
-
-
-
-
+ ) : (
+
+ No active step
+
+ )}
+
+ {/* Scroll Hint Fade */}
+
+
+
);
}
diff --git a/src/components/trials/wizard/panels/WizardMonitoringPanel.tsx b/src/components/trials/wizard/panels/WizardMonitoringPanel.tsx
old mode 100644
new mode 100755
index fa1dd3c..20e3ff6
--- a/src/components/trials/wizard/panels/WizardMonitoringPanel.tsx
+++ b/src/components/trials/wizard/panels/WizardMonitoringPanel.tsx
@@ -3,670 +3,358 @@
import React from "react";
import {
Bot,
- User,
- Activity,
- Wifi,
- WifiOff,
- AlertCircle,
- CheckCircle,
- Clock,
Power,
PowerOff,
- Eye,
+ AlertCircle,
} from "lucide-react";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Alert, AlertDescription } from "~/components/ui/alert";
-import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
-import { Progress } from "~/components/ui/progress";
import { Button } from "~/components/ui/button";
-// import { useRosBridge } from "~/hooks/useRosBridge"; // Removed ROS dependency
-
-interface TrialData {
- id: string;
- status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
- scheduledAt: Date | null;
- startedAt: Date | null;
- completedAt: Date | null;
- duration: number | null;
- sessionNumber: number | null;
- notes: string | null;
- experimentId: string;
- participantId: string | null;
- wizardId: string | null;
- experiment: {
- id: string;
- name: string;
- description: string | null;
- studyId: string;
- };
- participant: {
- id: string;
- participantCode: string;
- demographics: Record
| null;
- };
-}
-
-interface TrialEvent {
- type: string;
- timestamp: Date;
- data?: unknown;
- message?: string;
-}
+import { WebcamPanel } from "./WebcamPanel";
interface WizardMonitoringPanelProps {
- trial: TrialData;
- trialEvents: TrialEvent[];
- isConnected: boolean;
- wsError?: string;
- activeTab: "status" | "robot" | "events";
- onTabChange: (tab: "status" | "robot" | "events") => void;
+ rosConnected: boolean;
+ rosConnecting: boolean;
+ rosError?: string;
+ robotStatus: {
+ connected: boolean;
+ battery: number;
+ position: { x: number; y: number; theta: number };
+ joints: Record;
+ sensors: Record;
+ lastUpdate: Date;
+ };
+ connectRos: () => Promise;
+ disconnectRos: () => void;
+ executeRosAction: (
+ pluginName: string,
+ actionId: string,
+ parameters: Record,
+ ) => Promise;
+ readOnly?: boolean;
}
-export function WizardMonitoringPanel({
- trial,
- trialEvents,
- isConnected,
- wsError,
- activeTab,
- onTabChange,
+const WizardMonitoringPanel = function WizardMonitoringPanel({
+ rosConnected,
+ rosConnecting,
+ rosError,
+ robotStatus,
+ connectRos,
+ disconnectRos,
+ executeRosAction,
+ readOnly = false,
}: WizardMonitoringPanelProps) {
- // Mock robot status for development (ROS bridge removed for now)
- const mockRobotStatus = {
- connected: false,
- battery: 85,
- position: { x: 0, y: 0, theta: 0 },
- joints: {},
- sensors: {},
- lastUpdate: new Date(),
- };
-
- const rosConnected = false;
- const rosConnecting = false;
- const rosError = null;
- const robotStatus = mockRobotStatus;
- // const connectRos = () => console.log("ROS connection not implemented yet");
- const disconnectRos = () =>
- console.log("ROS disconnection not implemented yet");
- const executeRobotAction = (
- action: string,
- parameters?: Record,
- ) => console.log("Robot action:", action, parameters);
-
- const formatTimestamp = (timestamp: Date) => {
- return new Date(timestamp).toLocaleTimeString();
- };
-
- const getEventIcon = (eventType: string) => {
- switch (eventType.toLowerCase()) {
- case "trial_started":
- case "trial_resumed":
- return CheckCircle;
- case "trial_paused":
- case "trial_stopped":
- return AlertCircle;
- case "step_completed":
- case "action_completed":
- return CheckCircle;
- case "robot_action":
- case "robot_status":
- return Bot;
- case "wizard_action":
- case "wizard_intervention":
- return User;
- case "system_error":
- case "connection_error":
- return AlertCircle;
- default:
- return Activity;
- }
- };
-
- const getEventColor = (eventType: string) => {
- switch (eventType.toLowerCase()) {
- case "trial_started":
- case "trial_resumed":
- case "step_completed":
- case "action_completed":
- return "text-green-600";
- case "trial_paused":
- case "trial_stopped":
- return "text-yellow-600";
- case "system_error":
- case "connection_error":
- case "trial_failed":
- return "text-red-600";
- case "robot_action":
- case "robot_status":
- return "text-blue-600";
- case "wizard_action":
- case "wizard_intervention":
- return "text-purple-600";
- default:
- return "text-muted-foreground";
- }
- };
-
return (
-
- {/* Header */}
-
-
-
Monitoring
-
- {isConnected ? (
-
- ) : (
-
- )}
-
- {isConnected ? "Live" : "Offline"}
-
-
-
- {wsError && (
-
-
- {wsError}
-
- )}
+
+ {/* Camera View - Always Visible */}
+
+
- {/* Tabbed Content */}
-
{
- if (value === "status" || value === "robot" || value === "events") {
- onTabChange(value);
- }
- }}
- className="flex min-h-0 flex-1 flex-col"
- >
-
-
-
-
- Status
-
-
-
- Robot
-
-
-
- Events
- {trialEvents.length > 0 && (
-
- {trialEvents.length}
-
- )}
-
-
+ {/* Robot Controls - Scrollable */}
+
+
+
+ Robot Control
-
-
- {/* Status Tab */}
-
-
-
- {/* Connection Status */}
-
-
Connection
-
-
-
- WebSocket
-
-
- {isConnected ? "Connected" : "Offline"}
-
-
-
-
- Data Mode
-
-
- {isConnected ? "Real-time" : "Polling"}
-
-
-
+
+
+ {/* Robot Status */}
+
+
+
Robot Status
+
+ {rosConnected ? (
+
+ ) : (
+
+ )}
-
-
-
- {/* Trial Information */}
-
-
Trial Info
-
-
- ID
-
- {trial.id.slice(-8)}
+
+
+
+
+ ROS Bridge
+
+
+
+ {rosConnecting
+ ? "Connecting..."
+ : rosConnected
+ ? "Ready"
+ : rosError
+ ? "Failed"
+ : "Offline"}
+
+ {rosConnected && (
+
+ ●
-
-
-
- Session
-
- #{trial.sessionNumber}
-
-
-
- Status
-
-
- {trial.status.replace("_", " ")}
-
-
- {trial.startedAt && (
-
-
- Started
-
-
- {formatTimestamp(new Date(trial.startedAt))}
-
-
)}
-
-
-
-
-
- {/* Participant Information */}
-
-
Participant
-
-
-
- Code
+ {rosConnecting && (
+
+ ⟳
-
- {trial.participant.participantCode}
-
-
-
-
- Session
-
- #{trial.sessionNumber}
-
- {trial.participant.demographics && (
-
-
- Demographics
-
-
- {Object.keys(trial.participant.demographics).length}{" "}
- fields
-
-
)}
-
-
-
- {/* System Information */}
-
-
System
-
-
-
- Experiment
-
-
- {trial.experiment.name}
-
-
-
-
- Study
-
-
- {trial.experiment.studyId.slice(-8)}
-
-
-
-
- Platform
-
- HRIStudio
-
-
-
-
-
- {/* Robot Tab */}
-
-
-
- {/* Robot Status */}
-
-
-
Robot Status
-
- {rosConnected ? (
-
- ) : (
-
- )}
-
-
-
-
-
- ROS Bridge
-
-
- {rosConnecting
- ? "Connecting..."
- : rosConnected
- ? "Connected"
- : "Offline"}
-
-
-
-
- Battery
-
-
-
- {robotStatus
- ? `${Math.round(robotStatus.battery * 100)}%`
- : "--"}
-
-
-
-
-
-
- Position
-
-
- {robotStatus
- ? `(${robotStatus.position.x.toFixed(1)}, ${robotStatus.position.y.toFixed(1)})`
- : "--"}
-
-
-
-
- Last Update
-
-
- {robotStatus
- ? robotStatus.lastUpdate.toLocaleTimeString()
- : "--"}
-
-
-
-
- {/* ROS Connection Controls */}
-
- {!rosConnected ? (
-
- console.log("Connect robot (not implemented)")
- }
- disabled={true}
- >
-
- Connect Robot (Coming Soon)
-
- ) : (
-
-
- Disconnect Robot
-
- )}
-
-
- {rosError && (
-
-
-
- ROS Error: {rosError}
-
-
- )}
-
-
-
-
- {/* Robot Actions */}
-
-
Active Actions
-
-
- No active actions
-
-
-
-
-
-
- {/* Recent Trial Events */}
-
-
Recent Events
-
- {trialEvents
- .filter((e) => e.type.includes("robot"))
- .slice(-2)
- .map((event, index) => (
-
-
- {event.type.replace(/_/g, " ")}
-
-
- {formatTimestamp(event.timestamp)}
-
-
- ))}
- {trialEvents.filter((e) => e.type.includes("robot"))
- .length === 0 && (
-
- No robot events yet
-
- )}
-
-
-
-
-
- {/* Robot Configuration */}
-
-
Configuration
-
-
-
- Type
-
- NAO6
-
-
-
- ROS Bridge
-
- localhost:9090
-
-
-
- Platform
-
- NAOqi
-
- {robotStatus &&
- Object.keys(robotStatus.joints).length > 0 && (
-
-
- Joints
-
-
- {Object.keys(robotStatus.joints).length} active
-
-
- )}
-
-
-
- {/* Quick Robot Actions */}
- {rosConnected && (
-
-
Quick Actions
-
-
- executeRobotAction("say_text", {
- text: "Hello from wizard!",
- })
- }
- >
- Say Hello
-
-
- executeRobotAction("play_animation", {
- animation: "Hello",
- })
- }
- >
- Wave
-
-
- executeRobotAction("set_led_color", {
- color: "blue",
- intensity: 1.0,
- })
- }
- >
- Blue LEDs
-
-
- executeRobotAction("turn_head", {
- yaw: 0,
- pitch: 0,
- speed: 0.3,
- })
- }
- >
- Center Head
-
-
-
+ {/* ROS Connection Controls */}
+
+ {!rosConnected ? (
+
connectRos()}
+ disabled={rosConnecting || rosConnected || readOnly}
+ >
+
+ {rosConnecting
+ ? "Connecting..."
+ : rosConnected
+ ? "Connected ✓"
+ : "Connect to NAO6"}
+
+ ) : (
+
disconnectRos()}
+ disabled={readOnly}
+ >
+
+ Disconnect
+
)}
+
- {!rosConnected && !rosConnecting && (
-
+ {rosError && (
+
+
+
+ {rosError}
+
+
+ )}
+
+ {!rosConnected && !rosConnecting && (
+
+
Connect to ROS bridge for live robot monitoring and
- control
+ control.
- )}
+
+ )}
+
+
+
+
+ {/* Movement Controls */}
+ {rosConnected && (
+
+
Movement
+
+ {/* Row 1: Turn Left, Forward, Turn Right */}
+
{
+ executeRosAction("nao6-ros2", "turn_left", {
+ speed: 0.3,
+ }).catch(console.error);
+ }}
+ disabled={readOnly}
+ >
+ ↺ Turn L
+
+
{
+ executeRosAction("nao6-ros2", "walk_forward", {
+ speed: 0.5,
+ }).catch(console.error);
+ }}
+ disabled={readOnly}
+ >
+ ↑ Forward
+
+
{
+ executeRosAction("nao6-ros2", "turn_right", {
+ speed: 0.3,
+ }).catch(console.error);
+ }}
+ disabled={readOnly}
+ >
+ Turn R ↻
+
+
+ {/* Row 2: Left, Stop, Right */}
+
{
+ executeRosAction("nao6-ros2", "strafe_left", {
+ speed: 0.3,
+ }).catch(console.error);
+ }}
+ disabled={readOnly}
+ >
+ ← Left
+
+
{
+ executeRosAction("nao6-ros2", "emergency_stop", {}).catch(
+ console.error,
+ );
+ }}
+ disabled={readOnly}
+ >
+ ■ Stop
+
+
{
+ executeRosAction("nao6-ros2", "strafe_right", {
+ speed: 0.3,
+ }).catch(console.error);
+ }}
+ disabled={readOnly}
+ >
+ Right →
+
+
+ {/* Row 3: Empty, Back, Empty */}
+
+
{
+ executeRosAction("nao6-ros2", "walk_backward", {
+ speed: 0.3,
+ }).catch(console.error);
+ }}
+ disabled={readOnly}
+ >
+ ↓ Back
+
+
+
-
-
+ )}
- {/* Events Tab */}
-
-
-
- {trialEvents.length === 0 ? (
-
- No events recorded yet
-
- ) : (
-
-
- Live Events
-
- {trialEvents.length}
-
-
+
- {trialEvents
- .slice()
- .reverse()
- .map((event, index) => {
- const EventIcon = getEventIcon(event.type);
- const eventColor = getEventColor(event.type);
+ {/* Quick Actions */}
+ {rosConnected && (
+
+
Quick Actions
- return (
-
-
-
-
-
-
- {event.type.replace(/_/g, " ")}
-
- {event.message && (
-
- {event.message}
-
- )}
-
-
- {formatTimestamp(event.timestamp)}
-
-
-
- );
- })}
-
- )}
+ {/* TTS Input */}
+
+ {
+ if (e.key === "Enter" && e.currentTarget.value.trim() && !readOnly) {
+ executeRosAction("nao6-ros2", "say_text", {
+ text: e.currentTarget.value.trim(),
+ }).catch(console.error);
+ e.currentTarget.value = "";
+ }
+ }}
+ />
+ {
+ const input = e.currentTarget.previousElementSibling as HTMLInputElement;
+ if (input?.value.trim()) {
+ executeRosAction("nao6-ros2", "say_text", {
+ text: input.value.trim(),
+ }).catch(console.error);
+ input.value = "";
+ }
+ }}
+ disabled={readOnly}
+ >
+ Say
+
+
+
+ {/* Preset Actions */}
+
+ {
+ if (rosConnected) {
+ executeRosAction("nao6-ros2", "say_text", {
+ text: "Hello! I am NAO!",
+ }).catch(console.error);
+ }
+ }}
+ disabled={readOnly}
+ >
+ Say Hello
+
+ {
+ if (rosConnected) {
+ executeRosAction("nao6-ros2", "say_text", {
+ text: "I am ready!",
+ }).catch(console.error);
+ }
+ }}
+ disabled={readOnly}
+ >
+ Say Ready
+
+
-
-
-
-
+ )}
+
+
+
);
-}
+};
+
+export { WizardMonitoringPanel };
diff --git a/src/components/trials/wizard/panels/WizardObservationPane.tsx b/src/components/trials/wizard/panels/WizardObservationPane.tsx
new file mode 100644
index 0000000..918ac77
--- /dev/null
+++ b/src/components/trials/wizard/panels/WizardObservationPane.tsx
@@ -0,0 +1,158 @@
+"use client";
+
+import React, { useState } from "react";
+import { Send, Hash, Tag, Clock, Flag, CheckCircle, Bot, User, MessageSquare, AlertTriangle, Activity } from "lucide-react";
+import { Button } from "~/components/ui/button";
+import { Textarea } from "~/components/ui/textarea";
+import { ScrollArea } from "~/components/ui/scroll-area";
+import { Badge } from "~/components/ui/badge";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "~/components/ui/select";
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
+import { HorizontalTimeline } from "~/components/trials/timeline/HorizontalTimeline";
+
+interface TrialEvent {
+ type: string;
+ timestamp: Date;
+ data?: unknown;
+ message?: string;
+}
+
+interface WizardObservationPaneProps {
+ onAddAnnotation: (
+ description: string,
+ category?: string,
+ tags?: string[],
+ ) => Promise
;
+ isSubmitting?: boolean;
+}
+
+export function WizardObservationPane({
+ onAddAnnotation,
+ isSubmitting = false,
+ trialEvents = [],
+}: WizardObservationPaneProps & { trialEvents?: TrialEvent[] }) {
+ const [note, setNote] = useState("");
+ const [category, setCategory] = useState("observation");
+ const [tags, setTags] = useState([]);
+ const [currentTag, setCurrentTag] = useState("");
+
+ const handleSubmit = async () => {
+ if (!note.trim()) return;
+
+ await onAddAnnotation(note, category, tags);
+ setNote("");
+ setTags([]);
+ setCurrentTag("");
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
+ handleSubmit();
+ }
+ };
+
+ const addTag = () => {
+ const trimmed = currentTag.trim();
+ if (trimmed && !tags.includes(trimmed)) {
+ setTags([...tags, trimmed]);
+ setCurrentTag("");
+ }
+ };
+
+ return (
+
+
+
+
+
+ Notes & Observations
+
+
+ Timeline
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx
old mode 100644
new mode 100755
diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx
old mode 100644
new mode 100755
diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx
old mode 100644
new mode 100755
diff --git a/src/components/ui/aspect-ratio.tsx b/src/components/ui/aspect-ratio.tsx
new file mode 100644
index 0000000..3df3fd0
--- /dev/null
+++ b/src/components/ui/aspect-ratio.tsx
@@ -0,0 +1,11 @@
+"use client"
+
+import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
+
+function AspectRatio({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+export { AspectRatio }
diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx
old mode 100644
new mode 100755
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
old mode 100644
new mode 100755
diff --git a/src/components/ui/breadcrumb-provider.tsx b/src/components/ui/breadcrumb-provider.tsx
old mode 100644
new mode 100755
diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx
old mode 100644
new mode 100755
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
old mode 100644
new mode 100755
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
old mode 100644
new mode 100755
diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx
old mode 100644
new mode 100755
diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx
old mode 100644
new mode 100755
diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx
old mode 100644
new mode 100755
diff --git a/src/components/ui/data-table-column-header.tsx b/src/components/ui/data-table-column-header.tsx
old mode 100644
new mode 100755
diff --git a/src/components/ui/data-table-pagination.tsx b/src/components/ui/data-table-pagination.tsx
old mode 100644
new mode 100755
diff --git a/src/components/ui/data-table-view-options.tsx b/src/components/ui/data-table-view-options.tsx
old mode 100644
new mode 100755
diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx
old mode 100644
new mode 100755
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
old mode 100644
new mode 100755
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx
old mode 100644
new mode 100755
diff --git a/src/components/ui/entity-form.tsx b/src/components/ui/entity-form.tsx
old mode 100644
new mode 100755
index 327db9e..ad8d16b
--- a/src/components/ui/entity-form.tsx
+++ b/src/components/ui/entity-form.tsx
@@ -119,8 +119,8 @@ export function EntityForm({
{/* Form Layout */}
diff --git a/src/components/ui/entity-view.tsx b/src/components/ui/entity-view.tsx
old mode 100644
new mode 100755
index d785312..5de6df8
--- a/src/components/ui/entity-view.tsx
+++ b/src/components/ui/entity-view.tsx
@@ -1,6 +1,7 @@
"use client";
import * as LucideIcons from "lucide-react";
+import { cn } from "~/lib/utils";
import { type ReactNode } from "react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
@@ -45,10 +46,15 @@ interface EntityViewSidebarProps {
children: ReactNode;
}
+
interface EntityViewProps {
children: ReactNode;
+ layout?: "default" | "full-width";
}
+// ... existing code ...
+
+
export function EntityViewHeader({
title,
subtitle,
@@ -115,8 +121,15 @@ export function EntityViewSidebar({ children }: EntityViewSidebarProps) {
return
{children}
;
}
-export function EntityView({ children }: EntityViewProps) {
- return
{children}
;
+export function EntityView({ children, layout = "default" }: EntityViewProps) {
+ // Simplification: Always take full width of the parent container provided by DashboardLayout
+ // The DashboardLayout already provides padding (p-4).
+ // We remove 'container mx-auto max-w-5xl' to stop it from shrinking.
+ return (
+
+ {children}
+
+ );
}
// Utility component for empty states
@@ -158,13 +171,12 @@ interface InfoGridProps {
export function InfoGrid({ items, columns = 2 }: InfoGridProps) {
return (
{items.map((item, index) => (
void;
+ onDisconnected?: () => void;
+ onError?: (error: unknown) => void;
+ onActionCompleted?: (execution: RobotActionExecution) => void;
+ onActionFailed?: (execution: RobotActionExecution) => void;
+}
+
+export interface UseWizardRosReturn {
+ isConnected: boolean;
+ isConnecting: boolean;
+ connectionError: string | null;
+ robotStatus: RobotStatus;
+ activeActions: RobotActionExecution[];
+ connect: () => Promise
;
+ disconnect: () => void;
+ executeRobotAction: (
+ pluginName: string,
+ actionId: string,
+ parameters: Record,
+ actionConfig?: {
+ topic: string;
+ messageType: string;
+ payloadMapping: {
+ type: string;
+ payload?: Record;
+ transformFn?: string;
+ };
+ },
+ ) => Promise;
+ callService: (service: string, args?: Record) => Promise;
+ setAutonomousLife: (enabled: boolean) => Promise;
+}
+
+
+export function useWizardRos(
+ options: UseWizardRosOptions = {},
+): UseWizardRosReturn {
+ const {
+ autoConnect = true,
+ onConnected,
+ onDisconnected,
+ onError,
+ onActionCompleted,
+ onActionFailed,
+ } = options;
+
+ const [isConnected, setIsConnected] = useState(false);
+ const [isConnecting, setIsConnecting] = useState(false);
+ const [connectionError, setConnectionError] = useState(null);
+ const [robotStatus, setRobotStatus] = useState({
+ connected: false,
+ battery: 0,
+ position: { x: 0, y: 0, theta: 0 },
+ joints: {},
+ sensors: {},
+ lastUpdate: new Date(),
+ });
+ const [activeActions, setActiveActions] = useState(
+ [],
+ );
+
+ // Prevent multiple connections
+ const isInitializedRef = useRef(false);
+ const connectAttemptRef = useRef(false);
+
+ const serviceRef = useRef(null);
+ const mountedRef = useRef(true);
+
+ // Use refs for callbacks to prevent infinite re-renders
+ const onConnectedRef = useRef(onConnected);
+ const onDisconnectedRef = useRef(onDisconnected);
+ const onErrorRef = useRef(onError);
+ const onActionCompletedRef = useRef(onActionCompleted);
+ const onActionFailedRef = useRef(onActionFailed);
+
+ // Update refs when callbacks change
+ onConnectedRef.current = onConnected;
+ onDisconnectedRef.current = onDisconnected;
+ onErrorRef.current = onError;
+ onActionCompletedRef.current = onActionCompleted;
+ onActionFailedRef.current = onActionFailed;
+
+ // Initialize service (only once)
+ useEffect(() => {
+ if (!isInitializedRef.current) {
+ serviceRef.current = getWizardRosService();
+ isInitializedRef.current = true;
+ }
+
+ return () => {
+ mountedRef.current = false;
+ };
+ }, []);
+
+ // Set up event listeners with stable callbacks
+ useEffect(() => {
+ const service = serviceRef.current;
+ if (!service) return;
+
+ const handleConnected = () => {
+ console.log("[useWizardRos] handleConnected called, mountedRef:", mountedRef.current);
+ // Set state immediately, before checking mounted status
+ setIsConnected(true);
+ setIsConnecting(false);
+ setConnectionError(null);
+
+ if (mountedRef.current) {
+ onConnectedRef.current?.();
+ }
+ };
+
+ const handleDisconnected = () => {
+ if (!mountedRef.current) return;
+ console.log("[useWizardRos] Disconnected from ROS bridge");
+ setIsConnected(false);
+ setIsConnecting(false);
+ onDisconnectedRef.current?.();
+ };
+
+ const handleError = (error: unknown) => {
+ if (!mountedRef.current) return;
+ console.error("[useWizardRos] ROS connection error:", error);
+ setConnectionError(
+ error instanceof Error ? error.message : "Connection error",
+ );
+ setIsConnecting(false);
+ onErrorRef.current?.(error);
+ };
+
+ const handleRobotStatusUpdated = (status: RobotStatus) => {
+ if (!mountedRef.current) return;
+ setRobotStatus(status);
+ };
+
+ const handleActionStarted = (execution: RobotActionExecution) => {
+ if (!mountedRef.current) return;
+ setActiveActions((prev) => {
+ const filtered = prev.filter((action) => action.id !== execution.id);
+ return [...filtered, execution];
+ });
+ };
+
+ const handleActionCompleted = (execution: RobotActionExecution) => {
+ if (!mountedRef.current) return;
+ setActiveActions((prev) =>
+ prev.map((action) => (action.id === execution.id ? execution : action)),
+ );
+ onActionCompletedRef.current?.(execution);
+ };
+
+ const handleActionFailed = (execution: RobotActionExecution) => {
+ if (!mountedRef.current) return;
+ setActiveActions((prev) =>
+ prev.map((action) => (action.id === execution.id ? execution : action)),
+ );
+ onActionFailedRef.current?.(execution);
+ };
+
+ const handleMaxReconnectsReached = () => {
+ if (!mountedRef.current) return;
+ setConnectionError("Maximum reconnection attempts reached");
+ setIsConnecting(false);
+ };
+
+ // Add event listeners
+ service.on("connected", handleConnected);
+ service.on("disconnected", handleDisconnected);
+ service.on("error", handleError);
+ service.on("robot_status_updated", handleRobotStatusUpdated);
+ service.on("action_started", handleActionStarted);
+ service.on("action_completed", handleActionCompleted);
+ service.on("action_failed", handleActionFailed);
+ service.on("max_reconnects_reached", handleMaxReconnectsReached);
+
+ // Initialize connection status
+ setIsConnected(service.getConnectionStatus());
+ setRobotStatus(service.getRobotStatus());
+ setActiveActions(service.getActiveActions());
+
+ return () => {
+ service.off("connected", handleConnected);
+ service.off("disconnected", handleDisconnected);
+ service.off("error", handleError);
+ service.off("robot_status_updated", handleRobotStatusUpdated);
+ service.off("action_started", handleActionStarted);
+ service.off("action_completed", handleActionCompleted);
+ service.off("action_failed", handleActionFailed);
+ service.off("max_reconnects_reached", handleMaxReconnectsReached);
+ };
+ }, []); // Empty deps since we use refs
+
+ const connect = useCallback(async (): Promise => {
+ const service = serviceRef.current;
+ if (!service || isConnected || isConnecting || connectAttemptRef.current)
+ return;
+
+ connectAttemptRef.current = true;
+ setIsConnecting(true);
+ setConnectionError(null);
+
+ try {
+ await service.connect();
+ // Connection successful - state will be updated by event handler
+ } catch (error) {
+ if (mountedRef.current) {
+ setIsConnecting(false);
+ setConnectionError(
+ error instanceof Error ? error.message : "Connection failed",
+ );
+ }
+ throw error;
+ } finally {
+ connectAttemptRef.current = false;
+ }
+ }, [isConnected, isConnecting]);
+
+ // Auto-connect if enabled (only once per hook instance)
+ useEffect(() => {
+ if (
+ autoConnect &&
+ serviceRef.current &&
+ !isConnected &&
+ !isConnecting &&
+ !connectAttemptRef.current
+ ) {
+ const timeoutId = setTimeout(() => {
+ connect().catch((error) => {
+ console.error("[useWizardRos] Auto-connect failed:", error);
+ // Don't retry automatically - let user manually connect
+ });
+ }, 100); // Small delay to prevent immediate connection attempts
+
+ return () => clearTimeout(timeoutId);
+ }
+ }, [autoConnect, isConnected, isConnecting, connect]);
+
+ const disconnect = useCallback((): void => {
+ const service = serviceRef.current;
+ if (!service) return;
+
+ connectAttemptRef.current = false;
+ service.disconnect();
+ setIsConnected(false);
+ setIsConnecting(false);
+ setConnectionError(null);
+ }, []);
+
+ const executeRobotAction = useCallback(
+ async (
+ pluginName: string,
+ actionId: string,
+ parameters: Record,
+ actionConfig?: {
+ topic: string;
+ messageType: string;
+ payloadMapping: {
+ type: string;
+ payload?: Record;
+ transformFn?: string;
+ };
+ },
+ ): Promise => {
+ const service = serviceRef.current;
+ if (!service) {
+ throw new Error("ROS service not initialized");
+ }
+
+ if (!isConnected) {
+ throw new Error("Not connected to ROS bridge");
+ }
+
+ return service.executeRobotAction(
+ pluginName,
+ actionId,
+ parameters,
+ actionConfig,
+ );
+ },
+ [isConnected],
+ );
+
+ const callService = useCallback(
+ async (service: string, args?: Record): Promise => {
+ const srv = serviceRef.current;
+ if (!srv || !isConnected) throw new Error("Not connected");
+ return srv.callService(service, args);
+ },
+ [isConnected],
+ );
+
+ const setAutonomousLife = useCallback(
+ async (enabled: boolean): Promise => {
+ const srv = serviceRef.current;
+ if (!srv || !isConnected) throw new Error("Not connected");
+ return srv.setAutonomousLife(enabled);
+ },
+ [isConnected],
+ );
+
+ return {
+ isConnected,
+ isConnecting,
+ connectionError,
+ robotStatus,
+ activeActions,
+ connect,
+ disconnect,
+ executeRobotAction,
+ callService,
+ setAutonomousLife,
+ };
+}
diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts
old mode 100644
new mode 100755
diff --git a/src/lib/auth-error-handler.ts b/src/lib/auth-error-handler.ts
old mode 100644
new mode 100755
diff --git a/src/lib/experiment-designer/__tests__/block-converter.test.ts b/src/lib/experiment-designer/__tests__/block-converter.test.ts
old mode 100644
new mode 100755
diff --git a/src/lib/experiment-designer/block-converter.ts b/src/lib/experiment-designer/block-converter.ts
old mode 100644
new mode 100755
diff --git a/src/lib/experiment-designer/execution-compiler.ts b/src/lib/experiment-designer/execution-compiler.ts
old mode 100644
new mode 100755
index 7e08978..14bad64
--- a/src/lib/experiment-designer/execution-compiler.ts
+++ b/src/lib/experiment-designer/execution-compiler.ts
@@ -64,6 +64,7 @@ export interface CompiledExecutionAction {
parameterSchemaRaw?: unknown;
timeout?: number;
retryable?: boolean;
+ children?: CompiledExecutionAction[];
}
/* ---------- Compile Entry Point ---------- */
@@ -136,11 +137,12 @@ function compileAction(
robotId: action.source.robotId,
baseActionId: action.source.baseActionId,
},
- execution: action.execution,
+ execution: action.execution!, // Assumes validation passed
parameters: action.parameters,
parameterSchemaRaw: action.parameterSchemaRaw,
- timeout: action.execution.timeoutMs,
- retryable: action.execution.retryable,
+ timeout: action.execution?.timeoutMs,
+ retryable: action.execution?.retryable,
+ children: action.children?.map((child, i) => compileAction(child, i)),
};
}
@@ -149,17 +151,24 @@ function compileAction(
export function collectPluginDependencies(design: ExperimentDesign): string[] {
const set = new Set();
for (const step of design.steps) {
- for (const action of step.actions) {
- if (action.source.kind === "plugin" && action.source.pluginId) {
- const versionPart = action.source.pluginVersion
- ? `@${action.source.pluginVersion}`
- : "";
- set.add(`${action.source.pluginId}${versionPart}`);
- }
- }
+ collectDependenciesFromActions(step.actions, set);
}
return Array.from(set).sort();
}
+// Helper to recursively collect from actions list directly would be cleaner
+function collectDependenciesFromActions(actions: ExperimentAction[], set: Set) {
+ for (const action of actions) {
+ if (action.source.kind === "plugin" && action.source.pluginId) {
+ const versionPart = action.source.pluginVersion
+ ? `@${action.source.pluginVersion}`
+ : "";
+ set.add(`${action.source.pluginId}${versionPart}`);
+ }
+ if (action.children) {
+ collectDependenciesFromActions(action.children, set);
+ }
+ }
+}
/* ---------- Integrity Hash Generation ---------- */
@@ -199,6 +208,12 @@ function buildStructuralSignature(
timeout: a.timeout,
retryable: a.retryable ?? false,
parameterKeys: summarizeParametersForHash(a.parameters),
+ children: a.children?.map(c => ({
+ id: c.id,
+ // Recurse structural signature for children
+ type: c.type,
+ parameterKeys: summarizeParametersForHash(c.parameters),
+ })),
})),
})),
pluginDependencies,
diff --git a/src/lib/experiment-designer/types.ts b/src/lib/experiment-designer/types.ts
old mode 100644
new mode 100755
index 2635b7c..9b5fa82
--- a/src/lib/experiment-designer/types.ts
+++ b/src/lib/experiment-designer/types.ts
@@ -53,15 +53,18 @@ export interface ActionDefinition {
};
execution?: ExecutionDescriptor;
parameterSchemaRaw?: unknown; // snapshot of original schema for validation/audit
+ nestable?: boolean; // If true, this action can contain child actions
}
export interface ExperimentAction {
id: string;
- type: ActionType;
+ type: string; // e.g. "wizard_speak", "robot_move"
name: string;
+ description?: string; // Optional description
parameters: Record;
- duration?: number;
+ duration?: number; // Estimated duration in seconds
category: ActionCategory;
+ // Provenance (where did this come from?)
source: {
kind: "core" | "plugin";
pluginId?: string;
@@ -69,8 +72,14 @@ export interface ExperimentAction {
robotId?: string | null;
baseActionId?: string;
};
- execution: ExecutionDescriptor;
+ // Execution (how do we run this?)
+ execution?: ExecutionDescriptor;
+
+ // Snapshot of parameter schema at the time of addition (for drift detection)
parameterSchemaRaw?: unknown;
+
+ // Nested actions (control flow)
+ children?: ExperimentAction[];
}
export interface StepTrigger {
diff --git a/src/lib/experiment-designer/visual-design-guard.ts b/src/lib/experiment-designer/visual-design-guard.ts
old mode 100644
new mode 100755
index baabe8c..c0da880
--- a/src/lib/experiment-designer/visual-design-guard.ts
+++ b/src/lib/experiment-designer/visual-design-guard.ts
@@ -90,17 +90,26 @@ const executionDescriptorSchema = z
// Action parameter snapshot is a free-form structure retained for audit
const parameterSchemaRawSchema = z.unknown().optional();
-// Action schema (loose input → normalized internal)
-const visualActionInputSchema = z
- .object({
- id: z.string().min(1),
- type: z.string().min(1),
- name: z.string().min(1),
- category: actionCategoryEnum.optional(),
- parameters: z.record(z.string(), z.unknown()).default({}),
- source: actionSourceSchema.optional(),
- execution: executionDescriptorSchema.optional(),
- parameterSchemaRaw: parameterSchemaRawSchema,
+// Base action schema (without recursion)
+const baseActionSchema = z.object({
+ id: z.string().min(1),
+ type: z.string().min(1),
+ name: z.string().min(1),
+ description: z.string().optional(),
+ category: actionCategoryEnum.optional(),
+ parameters: z.record(z.string(), z.unknown()).default({}),
+ source: actionSourceSchema.optional(),
+ execution: executionDescriptorSchema.optional(),
+ parameterSchemaRaw: parameterSchemaRawSchema,
+});
+
+type VisualActionInput = z.infer & {
+ children?: VisualActionInput[];
+};
+
+const visualActionInputSchema: z.ZodType = baseActionSchema
+ .extend({
+ children: z.lazy(() => z.array(visualActionInputSchema)).optional(),
})
.strict();
@@ -144,8 +153,7 @@ export function parseVisualDesignSteps(raw: unknown): {
issues.push(
...zodErr.issues.map(
(issue) =>
- `steps${
- issue.path.length ? "." + issue.path.join(".") : ""
+ `steps${issue.path.length ? "." + issue.path.join(".") : ""
}: ${issue.message} (code=${issue.code})`,
),
);
@@ -155,69 +163,73 @@ export function parseVisualDesignSteps(raw: unknown): {
// Normalize to internal ExperimentStep[] shape
const inputSteps = parsed.data;
- const normalized: ExperimentStep[] = inputSteps.map((s, idx) => {
- const actions: ExperimentAction[] = s.actions.map((a) => {
- // Default provenance
- const source: {
- kind: "core" | "plugin";
- pluginId?: string;
- pluginVersion?: string;
- robotId?: string | null;
- baseActionId?: string;
- } = a.source
+ const normalizeAction = (a: VisualActionInput): ExperimentAction => {
+ // Default provenance
+ const source: {
+ kind: "core" | "plugin";
+ pluginId?: string;
+ pluginVersion?: string;
+ robotId?: string | null;
+ baseActionId?: string;
+ } = a.source
? {
- kind: a.source.kind,
- pluginId: a.source.pluginId,
- pluginVersion: a.source.pluginVersion,
- robotId: a.source.robotId ?? null,
- baseActionId: a.source.baseActionId,
- }
+ kind: a.source.kind,
+ pluginId: a.source.pluginId,
+ pluginVersion: a.source.pluginVersion,
+ robotId: a.source.robotId ?? null,
+ baseActionId: a.source.baseActionId,
+ }
: { kind: "core" };
- // Default execution
- const execution: ExecutionDescriptor = a.execution
- ? {
- transport: a.execution.transport,
- timeoutMs: a.execution.timeoutMs,
- retryable: a.execution.retryable,
- ros2: a.execution.ros2,
- rest: a.execution.rest
- ? {
- method: a.execution.rest.method,
- path: a.execution.rest.path,
- headers: a.execution.rest.headers
- ? Object.fromEntries(
- Object.entries(a.execution.rest.headers).filter(
- (kv): kv is [string, string] =>
- typeof kv[1] === "string",
- ),
- )
- : undefined,
- }
+ // Default execution
+ const execution: ExecutionDescriptor = a.execution
+ ? {
+ transport: a.execution.transport,
+ timeoutMs: a.execution.timeoutMs,
+ retryable: a.execution.retryable,
+ ros2: a.execution.ros2,
+ rest: a.execution.rest
+ ? {
+ method: a.execution.rest.method,
+ path: a.execution.rest.path,
+ headers: a.execution.rest.headers
+ ? Object.fromEntries(
+ Object.entries(a.execution.rest.headers).filter(
+ (kv): kv is [string, string] =>
+ typeof kv[1] === "string",
+ ),
+ )
: undefined,
}
- : { transport: "internal" };
+ : undefined,
+ }
+ : { transport: "internal" };
- return {
- id: a.id,
- type: a.type, // dynamic (pluginId.actionId)
- name: a.name,
- parameters: a.parameters ?? {},
- duration: undefined,
- category: (a.category ?? "wizard") as ActionCategory,
- source: {
- kind: source.kind,
- pluginId: source.kind === "plugin" ? source.pluginId : undefined,
- pluginVersion:
- source.kind === "plugin" ? source.pluginVersion : undefined,
- robotId: source.kind === "plugin" ? (source.robotId ?? null) : null,
- baseActionId:
- source.kind === "plugin" ? source.baseActionId : undefined,
- },
- execution,
- parameterSchemaRaw: a.parameterSchemaRaw,
- };
- });
+ return {
+ id: a.id,
+ type: a.type,
+ name: a.name,
+ description: a.description,
+ parameters: a.parameters ?? {},
+ duration: undefined,
+ category: (a.category ?? "wizard") as ActionCategory,
+ source: {
+ kind: source.kind,
+ pluginId: source.kind === "plugin" ? source.pluginId : undefined,
+ pluginVersion:
+ source.kind === "plugin" ? source.pluginVersion : undefined,
+ robotId: source.kind === "plugin" ? (source.robotId ?? null) : null,
+ baseActionId:
+ source.kind === "plugin" ? source.baseActionId : undefined,
+ },
+ execution,
+ parameterSchemaRaw: a.parameterSchemaRaw,
+ children: a.children?.map(normalizeAction) ?? [],
+ };
+ };
+
+ const normalized: ExperimentStep[] = inputSteps.map((s, idx) => {
+ const actions: ExperimentAction[] = s.actions.map(normalizeAction);
// Construct step
return {
diff --git a/src/lib/nao6-transforms.ts b/src/lib/nao6-transforms.ts
old mode 100644
new mode 100755
index e5a7db1..6728776
--- a/src/lib/nao6-transforms.ts
+++ b/src/lib/nao6-transforms.ts
@@ -70,7 +70,7 @@ export function getCameraImage(
): Record {
const camera = params.camera as string;
const topic =
- camera === "front" ? "/camera/front/image_raw" : "/camera/bottom/image_raw";
+ camera === "front" ? "/naoqi_driver/camera/front/image_raw" : "/naoqi_driver/camera/bottom/image_raw";
return {
subscribe: true,
@@ -88,7 +88,7 @@ export function getJointStates(
): Record {
return {
subscribe: true,
- topic: "/joint_states",
+ topic: "/naoqi_driver/joint_states",
messageType: "sensor_msgs/msg/JointState",
once: true,
};
@@ -102,7 +102,7 @@ export function getImuData(
): Record {
return {
subscribe: true,
- topic: "/imu/torso",
+ topic: "/naoqi_driver/imu/torso",
messageType: "sensor_msgs/msg/Imu",
once: true,
};
@@ -116,7 +116,7 @@ export function getBumperStatus(
): Record {
return {
subscribe: true,
- topic: "/bumper",
+ topic: "/naoqi_driver/bumper",
messageType: "naoqi_bridge_msgs/msg/Bumper",
once: true,
};
@@ -129,7 +129,7 @@ export function getTouchSensors(
params: Record,
): Record {
const sensorType = params.sensor_type as string;
- const topic = sensorType === "hand" ? "/hand_touch" : "/head_touch";
+ const topic = sensorType === "hand" ? "/naoqi_driver/hand_touch" : "/naoqi_driver/head_touch";
const messageType =
sensorType === "hand"
? "naoqi_bridge_msgs/msg/HandTouch"
@@ -153,12 +153,12 @@ export function getSonarRange(
let topic: string;
if (sensor === "left") {
- topic = "/sonar/left";
+ topic = "/naoqi_driver/sonar/left";
} else if (sensor === "right") {
- topic = "/sonar/right";
+ topic = "/naoqi_driver/sonar/right";
} else {
// For "both", we'll default to left and let the wizard interface handle multiple calls
- topic = "/sonar/left";
+ topic = "/naoqi_driver/sonar/left";
}
return {
@@ -177,7 +177,7 @@ export function getRobotInfo(
): Record {
return {
subscribe: true,
- topic: "/info",
+ topic: "/naoqi_driver/info",
messageType: "naoqi_bridge_msgs/msg/RobotInfo",
once: true,
};
diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts
old mode 100644
new mode 100755
diff --git a/src/lib/ros-bridge.ts b/src/lib/ros-bridge.ts
old mode 100644
new mode 100755
diff --git a/src/lib/ros/wizard-ros-service.ts b/src/lib/ros/wizard-ros-service.ts
new file mode 100644
index 0000000..a12f47a
--- /dev/null
+++ b/src/lib/ros/wizard-ros-service.ts
@@ -0,0 +1,828 @@
+"use client";
+
+import { EventEmitter } from "events";
+
+export interface RosMessage {
+ op: string;
+ topic?: string;
+ type?: string;
+ msg?: Record;
+ service?: string;
+ args?: Record;
+ id?: string;
+ result?: boolean;
+ values?: Record;
+}
+
+export interface ServiceRequest {
+ service: string;
+ args?: Record;
+}
+
+export interface ServiceResponse {
+ result: boolean;
+ values?: Record;
+ error?: string;
+}
+
+export interface RobotStatus {
+ connected: boolean;
+ battery: number;
+ position: { x: number; y: number; theta: number };
+ joints: Record;
+ sensors: Record;
+ lastUpdate: Date;
+}
+
+export interface RobotActionExecution {
+ id: string;
+ actionId: string;
+ pluginName: string;
+ parameters: Record;
+ status: "pending" | "executing" | "completed" | "failed";
+ startTime: Date;
+ endTime?: Date;
+ error?: string;
+}
+
+/**
+ * Unified ROS WebSocket service for wizard interface
+ * Manages connection to rosbridge and handles robot action execution
+ */
+export class WizardRosService extends EventEmitter {
+ private ws: WebSocket | null = null;
+ private url: string;
+ private reconnectInterval = 3000;
+ private reconnectTimer: NodeJS.Timeout | null = null;
+ private messageId = 0;
+ private isConnected = false;
+ private connectionAttempts = 0;
+ private maxReconnectAttempts = 5;
+ private isConnecting = false;
+
+ // Robot state
+ private robotStatus: RobotStatus = {
+ connected: false,
+ battery: 0,
+ position: { x: 0, y: 0, theta: 0 },
+ joints: {},
+ sensors: {},
+ lastUpdate: new Date(),
+ };
+
+ // Active action tracking
+ private activeActions: Map = new Map();
+
+ constructor(url: string = "ws://localhost:9090") {
+ super();
+ this.url = url;
+ }
+
+ /**
+ * Connect to ROS bridge WebSocket
+ */
+ async connect(): Promise {
+ return new Promise((resolve, reject) => {
+ if (
+ this.isConnected ||
+ this.ws?.readyState === WebSocket.OPEN ||
+ this.isConnecting
+ ) {
+ if (this.isConnected) resolve();
+ return;
+ }
+
+ this.isConnecting = true;
+ console.log(`[WizardROS] Connecting to ${this.url}`);
+ this.ws = new WebSocket(this.url);
+
+ const connectionTimeout = setTimeout(() => {
+ if (this.ws?.readyState !== WebSocket.OPEN) {
+ this.ws?.close();
+ reject(new Error("Connection timeout"));
+ }
+ }, 10000);
+
+ this.ws.onopen = () => {
+ clearTimeout(connectionTimeout);
+ console.log("[WizardROS] Connected successfully");
+ this.isConnected = true;
+ this.isConnecting = false;
+ this.connectionAttempts = 0;
+ this.clearReconnectTimer();
+
+ // Subscribe to robot topics
+ this.subscribeToRobotTopics();
+
+ this.emit("connected");
+ resolve();
+ };
+
+ this.ws.onmessage = (event) => {
+ try {
+ const message = JSON.parse(event.data) as RosMessage;
+ this.handleMessage(message);
+ } catch (error) {
+ console.error("[WizardROS] Failed to parse message:", error);
+ }
+ };
+
+ this.ws.onclose = (event) => {
+ console.log(
+ `[WizardROS] Connection closed: ${event.code} - ${event.reason}`,
+ );
+ this.isConnected = false;
+ this.isConnecting = false;
+ this.emit("disconnected");
+
+ // Schedule reconnect if not manually closed
+ if (
+ event.code !== 1000 &&
+ this.connectionAttempts < this.maxReconnectAttempts
+ ) {
+ this.scheduleReconnect();
+ }
+ };
+
+ this.ws.onerror = (error) => {
+ console.error("[WizardROS] WebSocket error:", error);
+ clearTimeout(connectionTimeout);
+ this.isConnecting = false;
+ this.emit("error", error);
+ reject(error);
+ };
+ });
+ }
+
+ /**
+ * Disconnect from ROS bridge
+ */
+ disconnect(): void {
+ this.clearReconnectTimer();
+
+ if (this.ws) {
+ this.ws.close(1000, "Manual disconnect");
+ this.ws = null;
+ }
+
+ this.isConnected = false;
+ this.isConnecting = false;
+ this.robotStatus.connected = false;
+ this.emit("disconnected");
+ }
+
+ /**
+ * Check if connected to ROS bridge
+ */
+ getConnectionStatus(): boolean {
+ return this.isConnected && this.ws?.readyState === WebSocket.OPEN;
+ }
+
+ /**
+ * Get current robot status
+ */
+ getRobotStatus(): RobotStatus {
+ return { ...this.robotStatus };
+ }
+
+ /**
+ * Execute robot action using plugin configuration
+ */
+ async executeRobotAction(
+ pluginName: string,
+ actionId: string,
+ parameters: Record,
+ actionConfig?: {
+ topic: string;
+ messageType: string;
+ payloadMapping: {
+ type: string;
+ payload?: Record;
+ transformFn?: string;
+ };
+ },
+ ): Promise {
+ if (!this.isConnected) {
+ throw new Error("Not connected to ROS bridge");
+ }
+
+ const executionId = `${pluginName}_${actionId}_${Date.now()}`;
+ const execution: RobotActionExecution = {
+ id: executionId,
+ actionId,
+ pluginName,
+ parameters,
+ status: "pending",
+ startTime: new Date(),
+ };
+
+ this.activeActions.set(executionId, execution);
+ this.emit("action_started", execution);
+
+ try {
+ execution.status = "executing";
+ this.activeActions.set(executionId, execution);
+
+ // Execute based on action configuration or built-in mappings
+ if (actionConfig) {
+ await this.executeWithConfig(actionConfig, parameters);
+ } else {
+ await this.executeBuiltinAction(actionId, parameters);
+ }
+
+ execution.status = "completed";
+ execution.endTime = new Date();
+ this.emit("action_completed", execution);
+ } catch (error) {
+ execution.status = "failed";
+ execution.error = error instanceof Error ? error.message : String(error);
+ execution.endTime = new Date();
+ this.emit("action_failed", execution);
+ }
+
+ this.activeActions.set(executionId, execution);
+ return execution;
+ }
+
+ /**
+ * Get list of active actions
+ */
+ getActiveActions(): RobotActionExecution[] {
+ return Array.from(this.activeActions.values());
+ }
+
+ /**
+ * Subscribe to robot sensor topics
+ */
+ private subscribeToRobotTopics(): void {
+ const topics = [
+ { topic: "/joint_states", type: "sensor_msgs/JointState" },
+ // Battery topic removed - BatteryState message type doesn't exist in naoqi_bridge_msgs
+ // Battery info can be obtained through diagnostics or other means if needed
+ { topic: "/naoqi_driver/bumper", type: "naoqi_bridge_msgs/Bumper" },
+ {
+ topic: "/naoqi_driver/hand_touch",
+ type: "naoqi_bridge_msgs/HandTouch",
+ },
+ {
+ topic: "/naoqi_driver/head_touch",
+ type: "naoqi_bridge_msgs/HeadTouch",
+ },
+ { topic: "/naoqi_driver/sonar/left", type: "sensor_msgs/Range" },
+ { topic: "/naoqi_driver/sonar/right", type: "sensor_msgs/Range" },
+ ];
+
+ topics.forEach(({ topic, type }) => {
+ this.subscribe(topic, type);
+ });
+ }
+
+ /**
+ * Subscribe to a ROS topic
+ */
+ private subscribe(topic: string, messageType: string): void {
+ const message: RosMessage = {
+ op: "subscribe",
+ topic,
+ type: messageType,
+ id: `sub_${this.messageId++}`,
+ };
+
+ this.send(message);
+ }
+
+ /**
+ * Publish message to ROS topic
+ */
+ private publish(
+ topic: string,
+ messageType: string,
+ msg: Record,
+ ): void {
+ const message: RosMessage = {
+ op: "publish",
+ topic,
+ type: messageType,
+ msg,
+ };
+
+ this.send(message);
+ }
+
+ /**
+ * Send WebSocket message
+ */
+ private send(message: RosMessage): void {
+ if (this.ws?.readyState === WebSocket.OPEN) {
+ this.ws.send(JSON.stringify(message));
+ } else {
+ console.warn("[WizardROS] Cannot send message - not connected");
+ }
+ }
+
+ /**
+ * Handle incoming ROS messages
+ */
+ private handleMessage(message: RosMessage): void {
+ if (message.topic) {
+ this.handleTopicMessage(message);
+ }
+
+ this.emit("message", message);
+ }
+
+ /**
+ * Handle topic-specific messages
+ */
+ private handleTopicMessage(message: RosMessage): void {
+ if (!message.topic || !message.msg) return;
+
+ switch (message.topic) {
+ case "/joint_states":
+ this.updateJointStates(message.msg);
+ break;
+ case "/naoqi_driver/battery":
+ this.updateBatteryStatus(message.msg);
+ break;
+ case "/naoqi_driver/bumper":
+ case "/naoqi_driver/hand_touch":
+ case "/naoqi_driver/head_touch":
+ case "/naoqi_driver/sonar/left":
+ case "/naoqi_driver/sonar/right":
+ this.updateSensorData(message.topic, message.msg);
+ break;
+ }
+
+ this.robotStatus.lastUpdate = new Date();
+ this.emit("robot_status_updated", this.robotStatus);
+ }
+
+ /**
+ * Update joint states from ROS message
+ */
+ private updateJointStates(msg: Record): void {
+ if (
+ msg.name &&
+ msg.position &&
+ Array.isArray(msg.name) &&
+ Array.isArray(msg.position)
+ ) {
+ const joints: Record = {};
+
+ for (let i = 0; i < msg.name.length; i++) {
+ const jointName = msg.name[i] as string;
+ const position = msg.position[i] as number;
+ if (jointName && typeof position === "number") {
+ joints[jointName] = position;
+ }
+ }
+
+ this.robotStatus.joints = joints;
+ this.robotStatus.connected = true;
+ }
+ }
+
+ /**
+ * Update battery status from ROS message
+ */
+ private updateBatteryStatus(msg: Record): void {
+ if (typeof msg.percentage === "number") {
+ this.robotStatus.battery = msg.percentage;
+ }
+ }
+
+ /**
+ * Update sensor data from ROS message
+ */
+ private updateSensorData(topic: string, msg: Record): void {
+ this.robotStatus.sensors[topic] = msg;
+ }
+
+ /**
+ * Execute action with plugin configuration
+ */
+ private async executeWithConfig(
+ config: {
+ topic: string;
+ messageType: string;
+ payloadMapping: {
+ type: string;
+ payload?: Record;
+ transformFn?: string;
+ };
+ },
+ parameters: Record,
+ ): Promise {
+ let msg: Record;
+
+ if (
+ (config.payloadMapping.type === "template" ||
+ config.payloadMapping.type === "static") &&
+ config.payloadMapping.payload
+ ) {
+ // Template-based payload construction
+ msg = this.buildTemplatePayload(
+ config.payloadMapping.payload,
+ parameters,
+ );
+ } else if (config.payloadMapping.transformFn) {
+ // Custom transform function
+ msg = this.applyTransformFunction(
+ config.payloadMapping.transformFn,
+ parameters,
+ );
+ } else {
+ // Direct parameter mapping
+ msg = parameters;
+ }
+
+ this.publish(config.topic, config.messageType, msg);
+
+ // Wait for action completion (simple delay for now)
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+
+ /**
+ * Execute built-in robot actions
+ */
+ private async executeBuiltinAction(
+ actionId: string,
+ parameters: Record,
+ ): Promise {
+ switch (actionId) {
+ case "say_text":
+ this.publish("/speech", "std_msgs/String", {
+ data: parameters.text || "Hello",
+ });
+ break;
+
+ case "walk_forward":
+ case "walk_backward":
+ case "turn_left":
+ case "turn_right":
+ this.executeMovementAction(actionId, parameters);
+ break;
+
+ case "move_head":
+ case "turn_head":
+ this.executeTurnHead(parameters);
+ break;
+
+ case "move_arm":
+ this.executeMoveArm(parameters);
+ break;
+
+ case "emergency_stop":
+ this.publish("/cmd_vel", "geometry_msgs/Twist", {
+ linear: { x: 0, y: 0, z: 0 },
+ angular: { x: 0, y: 0, z: 0 },
+ });
+ break;
+
+ default:
+ throw new Error(`Unknown action: ${actionId}`);
+ }
+
+ // Wait for action completion
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+
+ /**
+ * Execute movement actions
+ */
+ private executeMovementAction(
+ actionId: string,
+ parameters: Record,
+ ): void {
+ let linear = { x: 0, y: 0, z: 0 };
+ let angular = { x: 0, y: 0, z: 0 };
+
+ const speed = Number(parameters.speed) || 0.1;
+
+ switch (actionId) {
+ case "walk_forward":
+ linear.x = speed;
+ break;
+ case "walk_backward":
+ linear.x = -speed;
+ break;
+ case "turn_left":
+ angular.z = speed;
+ break;
+ case "turn_right":
+ angular.z = -speed;
+ break;
+ }
+
+ this.publish("/naoqi_driver/cmd_vel", "geometry_msgs/Twist", { linear, angular });
+ }
+
+ /**
+ * Execute head turn action
+ */
+ private executeTurnHead(parameters: Record): void {
+ const yaw = Number(parameters.yaw) || 0;
+ const pitch = Number(parameters.pitch) || 0;
+ const speed = Number(parameters.speed) || 0.3;
+
+ this.publish("/joint_angles", "naoqi_bridge_msgs/JointAnglesWithSpeed", {
+ joint_names: ["HeadYaw", "HeadPitch"],
+ joint_angles: [yaw, pitch],
+ speed: speed,
+ });
+ }
+
+ /**
+ * Execute arm movement
+ */
+ private executeMoveArm(parameters: Record): void {
+ const arm = String(parameters.arm || "Right");
+ const roll = Number(parameters.roll) || 0;
+ const pitch = Number(parameters.pitch) || 0;
+ const speed = Number(parameters.speed) || 0.2;
+
+ const prefix = arm === "Left" ? "L" : "R";
+ const jointNames = [`${prefix}ShoulderPitch`, `${prefix}ShoulderRoll`];
+ const jointAngles = [pitch, roll];
+
+ this.publish("/naoqi_driver/joint_angles", "naoqi_bridge_msgs/JointAnglesWithSpeed", {
+ joint_names: jointNames,
+ joint_angles: jointAngles,
+ speed: speed,
+ });
+ }
+
+ /**
+ * Call a ROS service
+ */
+ async callService(
+ service: string,
+ args: Record = {},
+ ): Promise {
+ if (!this.isConnected) {
+ throw new Error("Not connected to ROS bridge");
+ }
+
+ const id = `call_${this.messageId++}`;
+
+ return new Promise((resolve, reject) => {
+ const handleResponse = (message: RosMessage) => {
+ if (message.op === "service_response" && message.id === id) {
+ this.off("message", handleResponse);
+ if (message.result === false) {
+ resolve({ result: false, error: String(message.values || "Service call failed") });
+ } else {
+ resolve({ result: true, values: message.values });
+ }
+ }
+ };
+
+ this.on("message", handleResponse);
+
+ this.send({
+ op: "call_service",
+ service,
+ args,
+ id,
+ });
+
+ setTimeout(() => {
+ this.off("message", handleResponse);
+ reject(new Error("Service call timed out"));
+ }, 5000);
+ });
+ }
+
+ /**
+ * Set Autonomous Life state with fallbacks
+ */
+ async setAutonomousLife(enabled: boolean): Promise {
+ const desiredState = enabled ? "interactive" : "disabled";
+
+ // List of services to try in order
+ const attempts = [
+ // Standard NaoQi Bridge pattern
+ {
+ service: "/naoqi_driver/ALAutonomousLife/setState",
+ args: { state: desiredState }
+ },
+ {
+ service: "/naoqi_driver/ALAutonomousLife/set_state",
+ args: { state: desiredState }
+ },
+ // Direct module mapping
+ {
+ service: "/ALAutonomousLife/setState",
+ args: { state: desiredState }
+ },
+ // Shortcuts/Aliases
+ {
+ service: "/naoqi_driver/set_autonomous_life",
+ args: { state: desiredState }
+ },
+ {
+ service: "/autonomous_life/set_state",
+ args: { state: desiredState }
+ },
+ // Fallback: Enable/Disable topics/services
+ {
+ service: enabled ? "/life/enable" : "/life/disable",
+ args: {}
+ },
+ // Last resort: Generic proxy call (if available)
+ {
+ service: "/naoqi_driver/function_call",
+ args: {
+ service: "ALAutonomousLife",
+ function: "setState",
+ args: [desiredState]
+ }
+ }
+ ];
+
+ console.log(`[WizardROS] Setting Autonomous Life to: ${desiredState}`);
+
+ for (const attempt of attempts) {
+ try {
+ console.log(`[WizardROS] Trying service: ${attempt.service}`);
+ const response = await this.callService(attempt.service, attempt.args);
+
+ // If the service call didn't timeout (it resolved), check result
+ if (response.result) {
+ console.log(`[WizardROS] Success via ${attempt.service}`);
+ return true;
+ } else {
+ // Resolved but failed? (e.g. internal error)
+ console.warn(`[WizardROS] Service ${attempt.service} returned false result:`, response.error);
+ }
+ } catch (error) {
+ // Service call failed or timed out
+ console.warn(`[WizardROS] Service ${attempt.service} failed/timeout:`, error);
+ }
+ }
+
+ console.error("[WizardROS] All Autonomous Life service attempts failed.");
+ return false;
+ }
+
+ /**
+ * Build template-based payload
+ */
+ private buildTemplatePayload(
+ template: Record,
+ parameters: Record,
+ ): Record {
+ const result: Record = {};
+
+ for (const [key, value] of Object.entries(template)) {
+ if (typeof value === "string" && value.includes("{{")) {
+ // Template substitution
+ let substituted = value;
+ for (const [paramKey, paramValue] of Object.entries(parameters)) {
+ const placeholder = `{{${paramKey}}}`;
+ substituted = substituted.replace(
+ new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"),
+ String(paramValue ?? ""),
+ );
+ }
+ result[key] = isNaN(Number(substituted))
+ ? substituted
+ : Number(substituted);
+ } else if (typeof value === "object" && value !== null) {
+ result[key] = this.buildTemplatePayload(
+ value as Record,
+ parameters,
+ );
+ } else {
+ result[key] = value;
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Apply transform function for NAO6 actions
+ */
+ private applyTransformFunction(
+ transformFn: string,
+ parameters: Record,
+ ): Record {
+ switch (transformFn) {
+ case "naoVelocityTransform":
+ return {
+ linear: {
+ x: Number(parameters.linear) || 0,
+ y: 0,
+ z: 0,
+ },
+ angular: {
+ x: 0,
+ y: 0,
+ z: Number(parameters.angular) || 0,
+ },
+ };
+
+ case "naoSpeechTransform":
+ case "transformToStringMessage":
+ return {
+ data: String(parameters.text || "Hello"),
+ };
+
+ case "naoHeadTransform":
+ case "transformToHeadMovement":
+ return {
+ joint_names: ["HeadYaw", "HeadPitch"],
+ joint_angles: [
+ Number(parameters.yaw) || 0,
+ Number(parameters.pitch) || 0,
+ ],
+ speed: Number(parameters.speed) || 0.3,
+ };
+
+ case "transformToJointAngles":
+ return {
+ joint_names: [String(parameters.joint_name || "HeadYaw")],
+ joint_angles: [Number(parameters.angle) || 0],
+ speed: Number(parameters.speed) || 0.2,
+ };
+
+ default:
+ console.warn(`Unknown transform function: ${transformFn}`);
+ return parameters;
+ }
+ }
+
+ /**
+ * Schedule reconnection attempt
+ */
+ private scheduleReconnect(): void {
+ if (this.reconnectTimer) return;
+
+ this.connectionAttempts++;
+ console.log(
+ `[WizardROS] Scheduling reconnect attempt ${this.connectionAttempts}/${this.maxReconnectAttempts}`,
+ );
+
+ this.reconnectTimer = setTimeout(async () => {
+ this.reconnectTimer = null;
+ try {
+ await this.connect();
+ } catch (error) {
+ console.error("[WizardROS] Reconnect failed:", error);
+ if (this.connectionAttempts < this.maxReconnectAttempts) {
+ this.scheduleReconnect();
+ } else {
+ this.emit("max_reconnects_reached");
+ }
+ }
+ }, this.reconnectInterval);
+ }
+
+ /**
+ * Clear reconnect timer
+ */
+ private clearReconnectTimer(): void {
+ if (this.reconnectTimer) {
+ clearTimeout(this.reconnectTimer);
+ this.reconnectTimer = null;
+ }
+ }
+}
+
+// Global service instance
+let wizardRosService: WizardRosService | null = null;
+let isCreatingInstance = false;
+
+/**
+ * Get or create the global wizard ROS service (true singleton)
+ */
+export function getWizardRosService(): WizardRosService {
+ // Prevent multiple instances during creation
+ if (isCreatingInstance && !wizardRosService) {
+ throw new Error("WizardRosService is being initialized, please wait");
+ }
+
+ if (!wizardRosService) {
+ isCreatingInstance = true;
+ try {
+ wizardRosService = new WizardRosService();
+ } finally {
+ isCreatingInstance = false;
+ }
+ }
+ return wizardRosService;
+}
+
+/**
+ * Initialize wizard ROS service with connection
+ */
+export async function initWizardRosService(): Promise {
+ const service = getWizardRosService();
+
+ if (!service.getConnectionStatus()) {
+ await service.connect();
+ }
+
+ return service;
+}
diff --git a/src/lib/storage/minio.ts b/src/lib/storage/minio.ts
old mode 100644
new mode 100755
diff --git a/src/lib/study-context.tsx b/src/lib/study-context.tsx
old mode 100644
new mode 100755
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
old mode 100644
new mode 100755
index bd0c391..778697a
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -4,3 +4,15 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+
+export function formatBytes(bytes: number, decimals = 2) {
+ if (!+bytes) return "0 Bytes";
+
+ const k = 1024;
+ const dm = decimals < 0 ? 0 : decimals;
+ const sizes = ["Bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"];
+
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
+}
diff --git a/src/server/api/root.ts b/src/server/api/root.ts
old mode 100644
new mode 100755
index 5699e11..346e5ae
--- a/src/server/api/root.ts
+++ b/src/server/api/root.ts
@@ -4,12 +4,14 @@ import { authRouter } from "~/server/api/routers/auth";
import { collaborationRouter } from "~/server/api/routers/collaboration";
import { dashboardRouter } from "~/server/api/routers/dashboard";
import { experimentsRouter } from "~/server/api/routers/experiments";
+import { filesRouter } from "~/server/api/routers/files";
import { mediaRouter } from "~/server/api/routers/media";
import { participantsRouter } from "~/server/api/routers/participants";
import { robotsRouter } from "~/server/api/routers/robots";
import { studiesRouter } from "~/server/api/routers/studies";
import { trialsRouter } from "~/server/api/routers/trials";
import { usersRouter } from "~/server/api/routers/users";
+import { storageRouter } from "~/server/api/routers/storage";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
/**
@@ -25,11 +27,13 @@ export const appRouter = createTRPCRouter({
participants: participantsRouter,
trials: trialsRouter,
robots: robotsRouter,
+ files: filesRouter,
media: mediaRouter,
analytics: analyticsRouter,
collaboration: collaborationRouter,
admin: adminRouter,
dashboard: dashboardRouter,
+ storage: storageRouter,
});
// export type definition of API
diff --git a/src/server/api/routers/admin.ts b/src/server/api/routers/admin.ts
old mode 100644
new mode 100755
diff --git a/src/server/api/routers/analytics.ts b/src/server/api/routers/analytics.ts
old mode 100644
new mode 100755
diff --git a/src/server/api/routers/auth.ts b/src/server/api/routers/auth.ts
old mode 100644
new mode 100755
diff --git a/src/server/api/routers/collaboration.ts b/src/server/api/routers/collaboration.ts
old mode 100644
new mode 100755
diff --git a/src/server/api/routers/dashboard.ts b/src/server/api/routers/dashboard.ts
old mode 100644
new mode 100755
diff --git a/src/server/api/routers/experiments.ts b/src/server/api/routers/experiments.ts
old mode 100644
new mode 100755
index 16e1574..f79cda8
--- a/src/server/api/routers/experiments.ts
+++ b/src/server/api/routers/experiments.ts
@@ -152,10 +152,10 @@ export const experimentsRouter = createTRPCRouter({
.select({
experimentId: trials.experimentId,
latest: sql`max(GREATEST(
- COALESCE(${trials.completedAt}, 'epoch'::timestamptz),
- COALESCE(${trials.startedAt}, 'epoch'::timestamptz),
- COALESCE(${trials.createdAt}, 'epoch'::timestamptz)
- ))`.as("latest"),
+ COALESCE(${trials.completedAt}, 'epoch':: timestamptz),
+ COALESCE(${trials.startedAt}, 'epoch':: timestamptz),
+ COALESCE(${trials.createdAt}, 'epoch':: timestamptz)
+))`.as("latest"),
})
.from(trials)
.where(inArray(trials.experimentId, experimentIds))
@@ -360,24 +360,24 @@ export const experimentsRouter = createTRPCRouter({
const executionGraphSummary = stepsArray
? {
- steps: stepsArray.length,
- actions: stepsArray.reduce((total, step) => {
- const acts = step.actions;
- return (
- total +
- (Array.isArray(acts)
- ? acts.reduce(
- (aTotal, a) =>
- aTotal +
- (Array.isArray(a?.actions) ? a.actions.length : 0),
- 0,
- )
- : 0)
- );
- }, 0),
- generatedAt: eg?.generatedAt ?? null,
- version: eg?.version ?? null,
- }
+ steps: stepsArray.length,
+ actions: stepsArray.reduce((total, step) => {
+ const acts = step.actions;
+ return (
+ total +
+ (Array.isArray(acts)
+ ? acts.reduce(
+ (aTotal, a) =>
+ aTotal +
+ (Array.isArray(a?.actions) ? a.actions.length : 0),
+ 0,
+ )
+ : 0)
+ );
+ }, 0),
+ generatedAt: eg?.generatedAt ?? null,
+ version: eg?.version ?? null,
+ }
: null;
return {
@@ -511,8 +511,7 @@ export const experimentsRouter = createTRPCRouter({
return {
valid: false,
issues: [
- `Compilation failed: ${
- err instanceof Error ? err.message : "Unknown error"
+ `Compilation failed: ${err instanceof Error ? err.message : "Unknown error"
}`,
],
pluginDependencies: [],
@@ -541,13 +540,13 @@ export const experimentsRouter = createTRPCRouter({
integrityHash: compiledGraph?.hash ?? null,
compiled: compiledGraph
? {
- steps: compiledGraph.steps.length,
- actions: compiledGraph.steps.reduce(
- (acc, s) => acc + s.actions.length,
- 0,
- ),
- transportSummary: summarizeTransports(compiledGraph.steps),
- }
+ steps: compiledGraph.steps.length,
+ actions: compiledGraph.steps.reduce(
+ (acc, s) => acc + s.actions.length,
+ 0,
+ ),
+ transportSummary: summarizeTransports(compiledGraph.steps),
+ }
: null,
};
}),
@@ -570,6 +569,7 @@ export const experimentsRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const { id, createSteps, compileExecution, ...updateData } = input;
const userId = ctx.session.user.id;
+ console.log("[DEBUG] experiments.update called", { id, visualDesign: updateData.visualDesign, createSteps });
// Get experiment to check study access
const experiment = await ctx.db.query.experiments.findFirst({
@@ -607,7 +607,7 @@ export const experimentsRouter = createTRPCRouter({
if (issues.length) {
throw new TRPCError({
code: "BAD_REQUEST",
- message: `Visual design validation failed:\n- ${issues.join("\n- ")}`,
+ message: `Visual design validation failed: \n - ${issues.join("\n- ")}`,
});
}
normalizedSteps = guardedSteps;
@@ -637,11 +637,10 @@ export const experimentsRouter = createTRPCRouter({
} catch (compileErr) {
throw new TRPCError({
code: "BAD_REQUEST",
- message: `Execution graph compilation failed: ${
- compileErr instanceof Error
- ? compileErr.message
- : "Unknown error"
- }`,
+ message: `Execution graph compilation failed: ${compileErr instanceof Error
+ ? compileErr.message
+ : "Unknown error"
+ }`,
});
}
}
@@ -735,11 +734,13 @@ export const experimentsRouter = createTRPCRouter({
const updatedExperiment = updatedExperimentResults[0];
if (!updatedExperiment) {
+ console.error("[DEBUG] Failed to update experiment - no result returned");
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to update experiment",
});
}
+ console.log("[DEBUG] Experiment updated successfully", { updatedAt: updatedExperiment.updatedAt });
// Log activity
await ctx.db.insert(activityLogs).values({
@@ -1541,6 +1542,15 @@ export const experimentsRouter = createTRPCRouter({
parameters: step.conditions as Record,
parentId: undefined, // Not supported in current schema
children: [], // TODO: implement hierarchical steps if needed
+ actions: step.actions.map((action) => ({
+ id: action.id,
+ name: action.name,
+ description: action.description,
+ type: action.type,
+ order: action.orderIndex,
+ parameters: action.parameters as Record,
+ pluginId: action.pluginId,
+ })),
}));
}),
diff --git a/src/server/api/routers/files.ts b/src/server/api/routers/files.ts
new file mode 100644
index 0000000..8d1dc68
--- /dev/null
+++ b/src/server/api/routers/files.ts
@@ -0,0 +1,146 @@
+import { z } from "zod";
+import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
+import { participantDocuments } from "~/server/db/schema";
+import { TRPCError } from "@trpc/server";
+import { env } from "~/env";
+import * as Minio from "minio";
+import { uuid } from "drizzle-orm/pg-core";
+import { eq, desc } from "drizzle-orm";
+
+// Initialize MinIO client
+// Note: In production, ensure these ENV vars are set.
+// For development with docker-compose, we use localhost:9000
+const minioClient = new Minio.Client({
+ endPoint: (env.MINIO_ENDPOINT ?? "localhost").split(":")[0] ?? "localhost",
+ port: parseInt((env.MINIO_ENDPOINT ?? "9000").split(":")[1] ?? "9000"),
+ useSSL: false, // Default to false for local dev; adjust for prod
+ accessKey: env.MINIO_ACCESS_KEY ?? "minioadmin",
+ secretKey: env.MINIO_SECRET_KEY ?? "minioadmin",
+});
+
+const BUCKET_NAME = env.MINIO_BUCKET_NAME ?? "hristudio-assets";
+
+// Ensure bucket exists on startup (best effort)
+const ensureBucket = async () => {
+ try {
+ const exists = await minioClient.bucketExists(BUCKET_NAME);
+ if (!exists) {
+ await minioClient.makeBucket(BUCKET_NAME, env.MINIO_REGION ?? "us-east-1");
+ // Set public policy if needed? For now, keep private and use presigned URLs.
+ }
+ } catch (e) {
+ console.error("Error ensuring MinIO bucket exists:", e);
+ }
+}
+void ensureBucket(); // Fire and forget on load
+
+export const filesRouter = createTRPCRouter({
+ // Get a presigned URL for uploading a file
+ getPresignedUrl: protectedProcedure
+ .input(z.object({
+ filename: z.string(),
+ contentType: z.string(),
+ participantId: z.string(),
+ }))
+ .mutation(async ({ input }) => {
+ const fileExtension = input.filename.split(".").pop();
+ const uniqueFilename = `${input.participantId}/${crypto.randomUUID()}.${fileExtension}`;
+
+ try {
+ const presignedUrl = await minioClient.presignedPutObject(
+ BUCKET_NAME,
+ uniqueFilename,
+ 60 * 5 // 5 minutes expiry
+ );
+
+ return {
+ url: presignedUrl,
+ storagePath: uniqueFilename, // Pass this back to client to save in DB after upload
+ };
+ } catch (error) {
+ console.error("Error generating presigned URL:", error);
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to generate upload URL",
+ });
+ }
+ }),
+
+ // Get a presigned URL for downloading/viewing a file
+ getDownloadUrl: protectedProcedure
+ .input(z.object({
+ storagePath: z.string(),
+ }))
+ .query(async ({ input }) => {
+ try {
+ const url = await minioClient.presignedGetObject(
+ BUCKET_NAME,
+ input.storagePath,
+ 60 * 60 // 1 hour
+ );
+ return { url };
+ } catch (error) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "File not found or storage error",
+ });
+ }
+ }),
+
+ // Record a successful upload in the database
+ registerUpload: protectedProcedure
+ .input(z.object({
+ participantId: z.string(),
+ name: z.string(),
+ type: z.string().optional(),
+ storagePath: z.string(),
+ fileSize: z.number().optional(),
+ }))
+ .mutation(async ({ ctx, input }) => {
+ await ctx.db.insert(participantDocuments).values({
+ participantId: input.participantId,
+ name: input.name,
+ type: input.type,
+ storagePath: input.storagePath,
+ fileSize: input.fileSize,
+ uploadedBy: ctx.session.user.id,
+ });
+ }),
+
+ // List documents for a participant
+ listParticipantDocuments: protectedProcedure
+ .input(z.object({ participantId: z.string() }))
+ .query(async ({ ctx, input }) => {
+ return await ctx.db.query.participantDocuments.findMany({
+ where: eq(participantDocuments.participantId, input.participantId),
+ orderBy: [desc(participantDocuments.createdAt)],
+ with: {
+ // Optional: join with uploader info if needed
+ }
+ });
+ }),
+
+ // Delete a document
+ deleteDocument: protectedProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const doc = await ctx.db.query.participantDocuments.findFirst({
+ where: eq(participantDocuments.id, input.id),
+ });
+
+ if (!doc) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Document not found" });
+ }
+
+ // Delete from database
+ await ctx.db.delete(participantDocuments).where(eq(participantDocuments.id, input.id));
+
+ // Delete from MinIO (fire and forget or await)
+ try {
+ await minioClient.removeObject(BUCKET_NAME, doc.storagePath);
+ } catch (e) {
+ console.error("Failed to delete object from S3:", e);
+ // We still consider the operation successful for the user as the DB record is gone.
+ }
+ }),
+});
diff --git a/src/server/api/routers/media.ts b/src/server/api/routers/media.ts
old mode 100644
new mode 100755
diff --git a/src/server/api/routers/participants.ts b/src/server/api/routers/participants.ts
old mode 100644
new mode 100755
diff --git a/src/server/api/routers/robots.ts b/src/server/api/routers/robots.ts
old mode 100644
new mode 100755
diff --git a/src/server/api/routers/storage.ts b/src/server/api/routers/storage.ts
new file mode 100644
index 0000000..87735b1
--- /dev/null
+++ b/src/server/api/routers/storage.ts
@@ -0,0 +1,71 @@
+
+import { z } from "zod";
+import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
+import { s3Client } from "~/server/storage";
+import { PutObjectCommand } from "@aws-sdk/client-s3";
+import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
+import { env } from "~/env";
+import { TRPCError } from "@trpc/server";
+import { db } from "~/server/db";
+import { mediaCaptures } from "~/server/db/schema";
+
+export const storageRouter = createTRPCRouter({
+ getUploadPresignedUrl: protectedProcedure
+ .input(
+ z.object({
+ filename: z.string(),
+ contentType: z.string(),
+ })
+ )
+ .mutation(async ({ input }) => {
+ const bucket = env.MINIO_BUCKET_NAME ?? "hristudio-data";
+ const key = input.filename;
+
+ try {
+ const command = new PutObjectCommand({
+ Bucket: bucket,
+ Key: key,
+ ContentType: input.contentType,
+ });
+
+ const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
+
+ return {
+ url,
+ key,
+ bucket,
+ };
+ } catch (error) {
+ console.error("Error generating presigned URL:", error);
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to generate upload URL",
+ });
+ }
+ }),
+ saveRecording: protectedProcedure
+ .input(
+ z.object({
+ trialId: z.string(),
+ storagePath: z.string(),
+ fileSize: z.number().optional(),
+ format: z.string().optional(),
+ mediaType: z.enum(["video", "audio", "image"]).default("video"),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { db } = ctx;
+
+ await db.insert(mediaCaptures).values({
+ trialId: input.trialId,
+ mediaType: input.mediaType,
+ storagePath: input.storagePath,
+ fileSize: input.fileSize,
+ format: input.format,
+ startTimestamp: new Date(), // Approximate
+ // metadata: { uploadedBy: ctx.session.user.id }
+ });
+
+ return { success: true };
+ }),
+});
diff --git a/src/server/api/routers/studies.ts b/src/server/api/routers/studies.ts
old mode 100644
new mode 100755
diff --git a/src/server/api/routers/trials.ts b/src/server/api/routers/trials.ts
old mode 100644
new mode 100755
index 44978ee..71b843a
--- a/src/server/api/routers/trials.ts
+++ b/src/server/api/routers/trials.ts
@@ -24,8 +24,16 @@ import {
wizardInterventions,
mediaCaptures,
users,
+ annotations,
} from "~/server/db/schema";
-import { TrialExecutionEngine } from "~/server/services/trial-execution";
+import {
+ TrialExecutionEngine,
+ type ActionDefinition,
+} from "~/server/services/trial-execution";
+import { s3Client } from "~/server/storage";
+import { GetObjectCommand } from "@aws-sdk/client-s3";
+import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
+import { env } from "~/env";
// Helper function to check if user has access to trial
async function checkTrialAccess(
@@ -260,7 +268,41 @@ export const trialsRouter = createTRPCRouter({
});
}
- return trial[0];
+ // Fetch additional stats
+ const eventCount = await db
+ .select({ count: count() })
+ .from(trialEvents)
+ .where(eq(trialEvents.trialId, input.id));
+
+ const media = await db
+ .select()
+ .from(mediaCaptures)
+ .where(eq(mediaCaptures.trialId, input.id))
+ .orderBy(desc(mediaCaptures.createdAt)); // Get latest first
+
+ return {
+ ...trial[0],
+ eventCount: eventCount[0]?.count ?? 0,
+ mediaCount: media.length,
+ media: await Promise.all(media.map(async (m) => {
+ let url = "";
+ try {
+ // Generate Presigned GET URL
+ const command = new GetObjectCommand({
+ Bucket: env.MINIO_BUCKET_NAME ?? "hristudio-data",
+ Key: m.storagePath,
+ });
+ url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
+ } catch (e) {
+ console.error("Failed to sign URL for media", m.id, e);
+ }
+ return {
+ ...m,
+ url, // Add the signed URL to the response
+ contentType: m.format === 'webm' ? 'video/webm' : 'application/octet-stream', // Infer or store content type
+ };
+ })),
+ };
}),
create: protectedProcedure
@@ -381,6 +423,58 @@ export const trialsRouter = createTRPCRouter({
return trial;
}),
+ duplicate: protectedProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { db } = ctx;
+ const userId = ctx.session.user.id;
+
+ await checkTrialAccess(db, userId, input.id, [
+ "owner",
+ "researcher",
+ "wizard",
+ ]);
+
+ // Get source trial
+ const sourceTrial = await db
+ .select()
+ .from(trials)
+ .where(eq(trials.id, input.id))
+ .limit(1);
+
+ if (!sourceTrial[0]) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Source trial not found",
+ });
+ }
+
+ // Create new trial based on source
+ const [newTrial] = await db
+ .insert(trials)
+ .values({
+ experimentId: sourceTrial[0].experimentId,
+ participantId: sourceTrial[0].participantId,
+ // Scheduled for now + 1 hour by default, or null? Let's use null or source time?
+ // New duplicate usually implies "planning to run soon".
+ // I'll leave scheduledAt null or same as source if future?
+ // Let's set it to tomorrow by default to avoid confusion
+ scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
+ wizardId: sourceTrial[0].wizardId,
+ sessionNumber: (sourceTrial[0].sessionNumber || 0) + 1, // Increment session
+ status: "scheduled",
+ notes: `Duplicate of trial ${sourceTrial[0].id}. ${sourceTrial[0].notes || ""}`,
+ metadata: sourceTrial[0].metadata,
+ })
+ .returning();
+
+ return newTrial;
+ }),
+
start: protectedProcedure
.input(
z.object({
@@ -411,10 +505,15 @@ export const trialsRouter = createTRPCRouter({
});
}
+ // Idempotency: If already in progress, return success
+ if (currentTrial[0].status === "in_progress") {
+ return currentTrial[0];
+ }
+
if (currentTrial[0].status !== "scheduled") {
throw new TRPCError({
code: "BAD_REQUEST",
- message: "Trial can only be started from scheduled status",
+ message: `Trial is in ${currentTrial[0].status} status and cannot be started`,
});
}
@@ -596,6 +695,61 @@ export const trialsRouter = createTRPCRouter({
return intervention;
}),
+ addAnnotation: protectedProcedure
+ .input(
+ z.object({
+ trialId: z.string(),
+ category: z.string().optional(),
+ label: z.string().optional(),
+ description: z.string().optional(),
+ timestampStart: z.date().optional(),
+ tags: z.array(z.string()).optional(),
+ metadata: z.any().optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { db } = ctx;
+ const userId = ctx.session.user.id;
+
+ await checkTrialAccess(db, userId, input.trialId, [
+ "owner",
+ "researcher",
+ "wizard",
+ ]);
+
+ const [annotation] = await db
+ .insert(annotations)
+ .values({
+ trialId: input.trialId,
+ annotatorId: userId,
+ category: input.category,
+ label: input.label,
+ description: input.description,
+ timestampStart: input.timestampStart ?? new Date(),
+ tags: input.tags,
+ metadata: input.metadata,
+ })
+ .returning();
+
+ // Also create a trial event so it appears in the timeline
+ if (annotation) {
+ await db.insert(trialEvents).values({
+ trialId: input.trialId,
+ eventType: `annotation_${input.category || 'note'}`,
+ timestamp: input.timestampStart ?? new Date(),
+ data: {
+ annotationId: annotation.id,
+ description: input.description,
+ category: input.category,
+ label: input.label,
+ tags: input.tags,
+ },
+ });
+ }
+
+ return annotation;
+ }),
+
getEvents: protectedProcedure
.input(
z.object({
@@ -722,51 +876,51 @@ export const trialsRouter = createTRPCRouter({
const filteredTrials =
trialIds.length > 0
? await ctx.db.query.trials.findMany({
- where: inArray(trials.id, trialIds),
- with: {
- experiment: {
- with: {
- study: {
- columns: {
- id: true,
- name: true,
- },
+ where: inArray(trials.id, trialIds),
+ with: {
+ experiment: {
+ with: {
+ study: {
+ columns: {
+ id: true,
+ name: true,
},
},
- columns: {
- id: true,
- name: true,
- studyId: true,
- },
},
- participant: {
- columns: {
- id: true,
- participantCode: true,
- email: true,
- name: true,
- },
- },
- wizard: {
- columns: {
- id: true,
- name: true,
- email: true,
- },
- },
- events: {
- columns: {
- id: true,
- },
- },
- mediaCaptures: {
- columns: {
- id: true,
- },
+ columns: {
+ id: true,
+ name: true,
+ studyId: true,
},
},
- orderBy: [desc(trials.scheduledAt)],
- })
+ participant: {
+ columns: {
+ id: true,
+ participantCode: true,
+ email: true,
+ name: true,
+ },
+ },
+ wizard: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ },
+ events: {
+ columns: {
+ id: true,
+ },
+ },
+ mediaCaptures: {
+ columns: {
+ id: true,
+ },
+ },
+ },
+ orderBy: [desc(trials.scheduledAt)],
+ })
: [];
// Get total count
@@ -892,6 +1046,118 @@ export const trialsRouter = createTRPCRouter({
createdBy: ctx.session.user.id,
});
+ return { success: true };
+ }),
+
+ executeRobotAction: protectedProcedure
+ .input(
+ z.object({
+ trialId: z.string(),
+ pluginName: z.string(),
+ actionId: z.string(),
+ parameters: z.record(z.string(), z.unknown()).optional().default({}),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { db } = ctx;
+ const userId = ctx.session.user.id;
+
+ await checkTrialAccess(db, userId, input.trialId, [
+ "owner",
+ "researcher",
+ "wizard",
+ ]);
+
+ // Use execution engine to execute robot action
+ const executionEngine = getExecutionEngine();
+
+ // Create action definition for execution
+ const actionDefinition: ActionDefinition = {
+ id: `${input.pluginName}.${input.actionId}`,
+ stepId: "manual", // Manual execution
+ name: input.actionId,
+ type: `${input.pluginName}.${input.actionId}`,
+ orderIndex: 0,
+ parameters: input.parameters,
+ timeout: 30000,
+ required: false,
+ };
+
+ const result = await executionEngine.executeAction(
+ input.trialId,
+ actionDefinition,
+ );
+
+ if (!result.success) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: result.error ?? "Robot action execution failed",
+ });
+ }
+
+ // Log the manual robot action execution
+ await db.insert(trialEvents).values({
+ trialId: input.trialId,
+ eventType: "manual_robot_action",
+ actionId: actionDefinition.id,
+ data: {
+ userId,
+ pluginName: input.pluginName,
+ actionId: input.actionId,
+ parameters: input.parameters,
+ result: result.data,
+ duration: result.duration,
+ },
+ timestamp: new Date(),
+ createdBy: userId,
+ });
+
+ return {
+ success: true,
+ data: result.data,
+ duration: result.duration,
+ };
+ }),
+
+ logRobotAction: protectedProcedure
+ .input(
+ z.object({
+ trialId: z.string(),
+ pluginName: z.string(),
+ actionId: z.string(),
+ parameters: z.record(z.string(), z.unknown()).optional().default({}),
+ duration: z.number().optional(),
+ result: z.any().optional(),
+ error: z.string().optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { db } = ctx;
+ const userId = ctx.session.user.id;
+
+ await checkTrialAccess(db, userId, input.trialId, [
+ "owner",
+ "researcher",
+ "wizard",
+ ]);
+
+ await db.insert(trialEvents).values({
+ trialId: input.trialId,
+ eventType: "manual_robot_action",
+ data: {
+ userId,
+ pluginName: input.pluginName,
+ actionId: input.actionId,
+ parameters: input.parameters,
+ result: input.result,
+ duration: input.duration,
+ error: input.error,
+ executionMode: "websocket_client",
+ },
+ timestamp: new Date(),
+ createdBy: userId,
+ });
+
return { success: true };
}),
});
diff --git a/src/server/api/routers/users.ts b/src/server/api/routers/users.ts
old mode 100644
new mode 100755
diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts
old mode 100644
new mode 100755
diff --git a/src/server/auth/config.ts b/src/server/auth/config.ts
old mode 100644
new mode 100755
diff --git a/src/server/auth/index.ts b/src/server/auth/index.ts
old mode 100644
new mode 100755
diff --git a/src/server/auth/utils.ts b/src/server/auth/utils.ts
old mode 100644
new mode 100755
diff --git a/src/server/db/index.ts b/src/server/db/index.ts
old mode 100644
new mode 100755
diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts
old mode 100644
new mode 100755
index 47c9fef..eaebf7b
--- a/src/server/db/schema.ts
+++ b/src/server/db/schema.ts
@@ -438,6 +438,29 @@ export const participants = createTable(
}),
);
+export const participantDocuments = createTable(
+ "participant_document",
+ {
+ id: uuid("id").notNull().primaryKey().defaultRandom(),
+ participantId: uuid("participant_id")
+ .notNull()
+ .references(() => participants.id, { onDelete: "cascade" }),
+ name: varchar("name", { length: 255 }).notNull(),
+ type: varchar("type", { length: 100 }), // MIME type or custom category
+ storagePath: text("storage_path").notNull(),
+ fileSize: integer("file_size"),
+ uploadedBy: uuid("uploaded_by").references(() => users.id),
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .default(sql`CURRENT_TIMESTAMP`)
+ .notNull(),
+ },
+ (table) => ({
+ participantDocIdx: index("participant_document_participant_idx").on(
+ table.participantId,
+ ),
+ }),
+);
+
export const trials = createTable("trial", {
id: uuid("id").notNull().primaryKey().defaultRandom(),
experimentId: uuid("experiment_id")
@@ -1207,6 +1230,11 @@ export const systemSettingsRelations = relations(systemSettings, ({ one }) => ({
}),
}));
+
export const auditLogsRelations = relations(auditLogs, ({ one }) => ({
user: one(users, { fields: [auditLogs.userId], references: [users.id] }),
}));
+
+export type InsertPlugin = typeof plugins.$inferInsert;
+export type InsertPluginRepository = typeof pluginRepositories.$inferInsert;
+export type InsertRobot = typeof robots.$inferInsert;
diff --git a/src/server/services/robot-communication.ts b/src/server/services/robot-communication.ts
new file mode 100755
index 0000000..6928c18
--- /dev/null
+++ b/src/server/services/robot-communication.ts
@@ -0,0 +1,472 @@
+/* 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 */
+
+import WebSocket from "ws";
+import { EventEmitter } from "events";
+
+export interface RobotCommunicationConfig {
+ rosBridgeUrl: string;
+ connectionTimeout: number;
+ reconnectInterval: number;
+ maxReconnectAttempts: number;
+}
+
+export interface RobotAction {
+ pluginName: string;
+ actionId: string;
+ parameters: Record;
+ implementation: {
+ topic: string;
+ messageType: string;
+ messageTemplate: Record;
+ };
+}
+
+export interface RobotActionResult {
+ success: boolean;
+ duration: number;
+ data?: Record;
+ error?: string;
+}
+
+/**
+ * Server-side robot communication service for ROS integration
+ *
+ * This service manages WebSocket connections to rosbridge_server and provides
+ * a high-level interface for executing robot actions during trial execution.
+ */
+export class RobotCommunicationService extends EventEmitter {
+ private ws: WebSocket | null = null;
+ private config: RobotCommunicationConfig;
+ private messageId = 0;
+ private pendingActions = new Map<
+ string,
+ {
+ resolve: (result: RobotActionResult) => void;
+ reject: (error: Error) => void;
+ timeout: NodeJS.Timeout;
+ startTime: number;
+ }
+ >();
+ private reconnectAttempts = 0;
+ private reconnectTimer: NodeJS.Timeout | null = null;
+ private isConnected = false;
+
+ constructor(config: Partial = {}) {
+ super();
+
+ this.config = {
+ rosBridgeUrl: process.env.ROS_BRIDGE_URL || "ws://localhost:9090",
+ connectionTimeout: 10000,
+ reconnectInterval: 5000,
+ maxReconnectAttempts: 10,
+ ...config,
+ };
+ }
+
+ /**
+ * Initialize connection to ROS bridge
+ */
+ async connect(): Promise {
+ if (this.isConnected) {
+ return;
+ }
+
+ return new Promise((resolve, reject) => {
+ console.log(
+ `[RobotComm] Connecting to ROS bridge: ${this.config.rosBridgeUrl}`,
+ );
+
+ try {
+ this.ws = new WebSocket(this.config.rosBridgeUrl);
+
+ const connectionTimeout = setTimeout(() => {
+ reject(new Error("Connection timeout"));
+ this.cleanup();
+ }, this.config.connectionTimeout);
+
+ this.ws.on("open", () => {
+ clearTimeout(connectionTimeout);
+ this.isConnected = true;
+ this.reconnectAttempts = 0;
+
+ console.log("[RobotComm] Connected to ROS bridge");
+ this.emit("connected");
+ resolve();
+ });
+
+ this.ws.on("message", (data: WebSocket.Data) => {
+ try {
+ const message = JSON.parse(data.toString());
+ this.handleMessage(message);
+ } catch (error) {
+ console.error("[RobotComm] Failed to parse message:", error);
+ }
+ });
+
+ this.ws.on("close", (code: number, reason: string) => {
+ this.isConnected = false;
+ console.log(`[RobotComm] Connection closed: ${code} - ${reason}`);
+
+ this.emit("disconnected");
+
+ // Reject all pending actions
+ this.rejectAllPendingActions(new Error("Connection lost"));
+
+ // Schedule reconnection if not intentionally closed
+ if (
+ code !== 1000 &&
+ this.reconnectAttempts < this.config.maxReconnectAttempts
+ ) {
+ this.scheduleReconnect();
+ }
+ });
+
+ this.ws.on("error", (error: Error) => {
+ console.error("[RobotComm] WebSocket error:", error);
+ clearTimeout(connectionTimeout);
+ this.emit("error", error);
+ reject(error);
+ });
+ } catch (error) {
+ reject(error);
+ }
+ });
+ }
+
+ /**
+ * Disconnect from ROS bridge
+ */
+ disconnect(): void {
+ if (this.reconnectTimer) {
+ clearTimeout(this.reconnectTimer);
+ this.reconnectTimer = null;
+ }
+
+ this.rejectAllPendingActions(new Error("Service disconnected"));
+
+ if (this.ws) {
+ this.ws.close(1000, "Normal closure");
+ this.ws = null;
+ }
+
+ this.isConnected = false;
+ this.emit("disconnected");
+ }
+
+ /**
+ * Execute a robot action
+ */
+ async executeAction(action: RobotAction): Promise {
+ if (!this.isConnected) {
+ throw new Error("Not connected to ROS bridge");
+ }
+
+ const startTime = Date.now();
+ const actionId = `action_${this.messageId++}`;
+
+ return new Promise((resolve, reject) => {
+ // Set up timeout
+ const timeout = setTimeout(() => {
+ this.pendingActions.delete(actionId);
+ reject(new Error(`Action timeout: ${action.actionId}`));
+ }, 30000); // 30 second timeout
+
+ // Store pending action
+ this.pendingActions.set(actionId, {
+ resolve,
+ reject,
+ timeout,
+ startTime,
+ });
+
+ try {
+ // Execute action based on type and platform
+ this.executeRobotActionInternal(action, actionId);
+ } catch (error) {
+ clearTimeout(timeout);
+ this.pendingActions.delete(actionId);
+ reject(error);
+ }
+ });
+ }
+
+ /**
+ * Check if service is connected
+ */
+ getConnectionStatus(): boolean {
+ return this.isConnected;
+ }
+
+ // Private methods
+
+ private executeRobotActionInternal(
+ action: RobotAction,
+ actionId: string,
+ ): void {
+ const { implementation, parameters } = action;
+
+ // Build ROS message from template
+ const message = this.buildRosMessage(
+ implementation.messageTemplate,
+ parameters,
+ );
+
+ // Publish to ROS topic
+ this.publishToTopic(
+ implementation.topic,
+ implementation.messageType,
+ message,
+ );
+
+ // For actions that complete immediately (like movement commands),
+ // we simulate completion after a short delay
+ setTimeout(() => {
+ this.completeAction(actionId, {
+ success: true,
+ duration:
+ Date.now() -
+ (this.pendingActions.get(actionId)?.startTime || Date.now()),
+ data: {
+ topic: implementation.topic,
+ messageType: implementation.messageType,
+ message,
+ },
+ });
+ }, 100);
+ }
+
+ private buildRosMessage(
+ template: Record,
+ parameters: Record,
+ ): Record {
+ const message: Record = {};
+
+ for (const [key, value] of Object.entries(template)) {
+ if (typeof value === "string" && value.includes("{{")) {
+ // Template substitution
+ let substituted = value;
+
+ // Replace template variables
+ for (const [paramKey, paramValue] of Object.entries(parameters)) {
+ const placeholder = `{{${paramKey}}}`;
+ if (substituted.includes(placeholder)) {
+ substituted = substituted.replace(
+ new RegExp(
+ placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
+ "g",
+ ),
+ String(paramValue ?? ""),
+ );
+ }
+ }
+
+ // Handle conditional templates
+ if (
+ substituted.includes("{{") &&
+ substituted.includes("?") &&
+ substituted.includes(":")
+ ) {
+ // Simple conditional: {{condition ? valueTrue : valueFalse}}
+ const match = substituted.match(
+ /\{\{(.+?)\s*\?\s*(.+?)\s*:\s*(.+?)\}\}/,
+ );
+ if (match && match.length >= 4) {
+ const condition = match[1];
+ const trueValue = match[2];
+ const falseValue = match[3];
+ // Evaluate simple conditions
+ let conditionResult = false;
+
+ if (condition?.includes("===")) {
+ const parts = condition
+ .split("===")
+ .map((s) => s.trim().replace(/['"]/g, ""));
+ if (parts.length >= 2) {
+ const left = parts[0];
+ const right = parts[1];
+ conditionResult = parameters[left || ""] === right;
+ }
+ }
+
+ substituted = substituted.replace(
+ match[0],
+ conditionResult ? (trueValue ?? "") : (falseValue ?? ""),
+ );
+ }
+ }
+
+ // Try to parse as number if it looks like one
+ if (!isNaN(Number(substituted))) {
+ message[key] = Number(substituted);
+ } else {
+ message[key] = substituted;
+ }
+ } else if (Array.isArray(value)) {
+ // Handle array templates
+ message[key] = value.map((item) =>
+ typeof item === "string" && item.includes("{{")
+ ? this.substituteTemplateString(item, parameters)
+ : item,
+ );
+ } else if (typeof value === "object" && value !== null) {
+ // Recursively handle nested objects
+ message[key] = this.buildRosMessage(
+ value as Record,
+ parameters,
+ );
+ } else {
+ message[key] = value;
+ }
+ }
+
+ return message;
+ }
+
+ private substituteTemplateString(
+ template: string,
+ parameters: Record,
+ ): unknown {
+ let result = template;
+
+ for (const [key, value] of Object.entries(parameters)) {
+ const placeholder = `{{${key}}}`;
+ if (result.includes(placeholder)) {
+ result = result.replace(
+ new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"),
+ String(value ?? ""),
+ );
+ }
+ }
+
+ // Try to parse as number if it looks like one
+ if (!isNaN(Number(result))) {
+ return Number(result);
+ }
+
+ return result;
+ }
+
+ private publishToTopic(
+ topic: string,
+ messageType: string,
+ message: Record,
+ ): void {
+ if (!this.ws) return;
+
+ const rosMessage = {
+ op: "publish",
+ topic,
+ type: messageType,
+ msg: message,
+ };
+
+ console.log(`[RobotComm] Publishing to ${topic}:`, message);
+ this.ws.send(JSON.stringify(rosMessage));
+ }
+
+ private handleMessage(message: any): void {
+ // Handle different types of ROS bridge messages
+ switch (message.op) {
+ case "publish":
+ this.emit("topic_message", message.topic, message.msg);
+ break;
+
+ case "service_response":
+ this.handleServiceResponse(message);
+ break;
+
+ case "status":
+ console.log("[RobotComm] Status:", message);
+ break;
+
+ default:
+ console.log("[RobotComm] Unhandled message:", message);
+ }
+ }
+
+ private handleServiceResponse(message: any): void {
+ // Handle service call responses if needed
+ console.log("[RobotComm] Service response:", message);
+ }
+
+ private completeAction(actionId: string, result: RobotActionResult): void {
+ const pending = this.pendingActions.get(actionId);
+ if (pending) {
+ clearTimeout(pending.timeout);
+ this.pendingActions.delete(actionId);
+ pending.resolve(result);
+ }
+ }
+
+ private rejectAllPendingActions(error: Error): void {
+ for (const [actionId, pending] of this.pendingActions.entries()) {
+ clearTimeout(pending.timeout);
+ pending.reject(error);
+ }
+ this.pendingActions.clear();
+ }
+
+ private scheduleReconnect(): void {
+ if (this.reconnectTimer) return;
+
+ this.reconnectAttempts++;
+ console.log(
+ `[RobotComm] Scheduling reconnect attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts} in ${this.config.reconnectInterval}ms`,
+ );
+
+ this.reconnectTimer = setTimeout(async () => {
+ this.reconnectTimer = null;
+
+ try {
+ await this.connect();
+ } catch (error) {
+ console.error("[RobotComm] Reconnect failed:", error);
+
+ if (this.reconnectAttempts < this.config.maxReconnectAttempts) {
+ this.scheduleReconnect();
+ } else {
+ console.error("[RobotComm] Max reconnect attempts reached");
+ this.emit("max_reconnects_reached");
+ }
+ }
+ }, this.config.reconnectInterval);
+ }
+
+ private cleanup(): void {
+ if (this.ws) {
+ this.ws.removeAllListeners();
+ this.ws = null;
+ }
+ this.isConnected = false;
+ }
+}
+
+// Global service instance
+let robotCommService: RobotCommunicationService | null = null;
+
+/**
+ * Get or create the global robot communication service
+ */
+export function getRobotCommunicationService(): RobotCommunicationService {
+ if (!robotCommService) {
+ robotCommService = new RobotCommunicationService();
+ }
+ return robotCommService;
+}
+
+/**
+ * Initialize robot communication service with connection
+ */
+export async function initRobotCommunicationService(): Promise {
+ const service = getRobotCommunicationService();
+
+ if (!service.getConnectionStatus()) {
+ await service.connect();
+ }
+
+ return service;
+}
diff --git a/src/server/services/trial-execution.ts b/src/server/services/trial-execution.ts
old mode 100644
new mode 100755
index 540ed78..adf007b
--- a/src/server/services/trial-execution.ts
+++ b/src/server/services/trial-execution.ts
@@ -9,8 +9,19 @@
/* eslint-disable @typescript-eslint/no-base-to-string */
import { type db } from "~/server/db";
-import { trials, steps, actions, trialEvents } from "~/server/db/schema";
+import {
+ trials,
+ steps,
+ actions,
+ trialEvents,
+ plugins,
+} from "~/server/db/schema";
import { eq, asc } from "drizzle-orm";
+import {
+ getRobotCommunicationService,
+ type RobotAction,
+ type RobotActionResult,
+} from "./robot-communication";
export type TrialStatus =
| "scheduled"
@@ -72,6 +83,8 @@ export class TrialExecutionEngine {
private db: typeof db;
private activeTrials = new Map();
private stepDefinitions = new Map();
+ private pluginCache = new Map();
+ private robotComm = getRobotCommunicationService();
constructor(database: typeof db) {
this.db = database;
@@ -377,7 +390,7 @@ export class TrialExecutionEngine {
/**
* Execute a single action
*/
- private async executeAction(
+ async executeAction(
trialId: string,
action: ActionDefinition,
): Promise {
@@ -488,41 +501,74 @@ export class TrialExecutionEngine {
trialId: string,
action: ActionDefinition,
): Promise {
+ const startTime = Date.now();
+
try {
// Parse plugin.action format
- const [pluginId, actionType] = action.type.split(".");
+ const [pluginName, actionId] = action.type.split(".");
- // TODO: Integrate with actual robot plugin system
- // For now, simulate robot action execution
+ console.log(`[TrialExecution] Parsed action: pluginName=${pluginName}, actionId=${actionId}`);
- const simulationDelay = Math.random() * 2000 + 500; // 500ms - 2.5s
+ if (!pluginName || !actionId) {
+ throw new Error(
+ `Invalid robot action format: ${action.type}. Expected format: plugin.action`,
+ );
+ }
- return new Promise((resolve) => {
- setTimeout(() => {
- // Simulate success/failure
- const success = Math.random() > 0.1; // 90% success rate
+ // Get plugin configuration from database
+ const plugin = await this.getPluginDefinition(pluginName);
+ if (!plugin) {
+ throw new Error(`Plugin '${pluginName}' not found`);
+ }
- 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);
- });
+ console.log(`[TrialExecution] Plugin loaded: ${plugin.name} (ID: ${plugin.id})`);
+ console.log(`[TrialExecution] Available actions: ${plugin.actions?.map((a: any) => a.id).join(", ")}`);
+
+ // Find action definition in plugin
+ const actionDefinition = plugin.actions?.find(
+ (a: any) => a.id === actionId,
+ );
+ if (!actionDefinition) {
+ throw new Error(
+ `Action '${actionId}' not found in plugin '${pluginName}'`,
+ );
+ }
+
+ // Validate parameters
+ const validatedParams = this.validateActionParameters(
+ actionDefinition,
+ action.parameters,
+ );
+
+ // Execute action through robot communication service
+ const result = await this.executeRobotActionWithComm(
+ plugin,
+ actionDefinition,
+ validatedParams,
+ trialId,
+ );
+
+ const duration = Date.now() - startTime;
+
+ return {
+ success: true,
+ completed: true,
+ duration,
+ data: {
+ pluginName,
+ actionId,
+ parameters: validatedParams,
+ robotResponse: result,
+ platform: plugin.platform,
+ },
+ };
} catch (error) {
+ const duration = Date.now() - startTime;
+
return {
success: false,
completed: false,
- duration: 0,
+ duration,
error:
error instanceof Error
? error.message
@@ -531,6 +577,242 @@ export class TrialExecutionEngine {
}
}
+ /**
+ * Get plugin definition from database with caching
+ */
+ private async getPluginDefinition(pluginName: string): Promise {
+ // Check cache first
+ if (this.pluginCache.has(pluginName)) {
+ return this.pluginCache.get(pluginName);
+ }
+
+ try {
+ // Check if pluginName is a UUID
+ const isUuid =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
+ pluginName,
+ );
+
+ const query = isUuid
+ ? eq(plugins.id, pluginName)
+ : eq(plugins.name, pluginName);
+
+ const [plugin] = await this.db
+ .select()
+ .from(plugins)
+ .where(query)
+ .limit(1);
+
+ if (plugin) {
+ // Cache the plugin definition
+ // Use the actual name for cache key if we looked up by ID
+ const cacheKey = isUuid ? plugin.name : pluginName;
+
+ const pluginData = {
+ ...plugin,
+ actions: plugin.actionDefinitions,
+ platform: (plugin.metadata as any)?.platform,
+ ros2Config: (plugin.metadata as any)?.ros2Config,
+ };
+
+ this.pluginCache.set(cacheKey, pluginData);
+ // Also cache by ID if accessible
+ if (plugin.id) {
+ this.pluginCache.set(plugin.id, pluginData);
+ }
+
+ return pluginData;
+ }
+
+ return null;
+ } catch (error) {
+ console.error(`Failed to load plugin ${pluginName}:`, error);
+ return null;
+ }
+ }
+
+ /**
+ * Validate action parameters against plugin schema
+ */
+ private validateActionParameters(
+ actionDefinition: any,
+ parameters: Record,
+ ): Record {
+ const validated: Record = {};
+
+ if (!actionDefinition.parameters) {
+ return parameters;
+ }
+
+ for (const paramDef of actionDefinition.parameters) {
+ const paramName = paramDef.name;
+ const paramValue = parameters[paramName];
+
+ // Required parameter check
+ if (
+ paramDef.required &&
+ (paramValue === undefined || paramValue === null)
+ ) {
+ throw new Error(`Required parameter '${paramName}' is missing`);
+ }
+
+ // Use default value if parameter not provided
+ if (paramValue === undefined && paramDef.default !== undefined) {
+ validated[paramName] = paramDef.default;
+ continue;
+ }
+
+ if (paramValue !== undefined) {
+ // Type validation
+ switch (paramDef.type) {
+ case "number":
+ const numValue = Number(paramValue);
+ if (isNaN(numValue)) {
+ throw new Error(`Parameter '${paramName}' must be a number`);
+ }
+ if (paramDef.min !== undefined && numValue < paramDef.min) {
+ throw new Error(
+ `Parameter '${paramName}' must be >= ${paramDef.min}`,
+ );
+ }
+ if (paramDef.max !== undefined && numValue > paramDef.max) {
+ throw new Error(
+ `Parameter '${paramName}' must be <= ${paramDef.max}`,
+ );
+ }
+ validated[paramName] = numValue;
+ break;
+
+ case "boolean":
+ validated[paramName] = Boolean(paramValue);
+ break;
+
+ case "select":
+ if (paramDef.options) {
+ const validOptions = paramDef.options.map(
+ (opt: any) => opt.value,
+ );
+ if (!validOptions.includes(paramValue)) {
+ throw new Error(
+ `Parameter '${paramName}' must be one of: ${validOptions.join(", ")}`,
+ );
+ }
+ }
+ validated[paramName] = paramValue;
+ break;
+
+ default:
+ validated[paramName] = paramValue;
+ }
+ }
+ }
+
+ return validated;
+ }
+
+ /**
+ * Execute robot action through robot communication service
+ */
+ private async executeRobotActionWithComm(
+ plugin: any,
+ actionDefinition: any,
+ parameters: Record,
+ trialId: string,
+ ): Promise {
+ // Ensure robot communication service is available
+ if (!this.robotComm.getConnectionStatus()) {
+ try {
+ await this.robotComm.connect();
+ } catch (error) {
+ throw new Error(
+ `Failed to connect to robot: ${error instanceof Error ? error.message : "Unknown error"}`,
+ );
+ }
+ }
+
+ // Prepare robot action
+ const robotAction: RobotAction = {
+ pluginName: plugin.name,
+ actionId: actionDefinition.id,
+ parameters,
+ implementation: actionDefinition.implementation,
+ };
+
+ // Execute action through robot communication service
+ const result: RobotActionResult =
+ await this.robotComm.executeAction(robotAction);
+
+ if (!result.success) {
+ throw new Error(result.error || "Robot action failed");
+ }
+
+ // Log the successful action execution
+ await this.logTrialEvent(trialId, "robot_action_executed", {
+ actionId: actionDefinition.id,
+ parameters,
+ platform: plugin.platform,
+ topic: actionDefinition.implementation?.topic,
+ messageType: actionDefinition.implementation?.messageType,
+ duration: result.duration,
+ robotResponse: result.data,
+ });
+
+ // Return human-readable result
+ return this.formatRobotActionResult(
+ plugin,
+ actionDefinition,
+ parameters,
+ result,
+ );
+ }
+
+ /**
+ * Format robot action result for human readability
+ */
+ private formatRobotActionResult(
+ plugin: any,
+ actionDefinition: any,
+ parameters: Record,
+ result: RobotActionResult,
+ ): string {
+ const actionType = actionDefinition.id;
+ const platform = plugin.platform || "Robot";
+
+ switch (actionType) {
+ case "say_text":
+ return `${platform} said: "${parameters.text}"`;
+
+ case "walk_forward":
+ return `${platform} walked forward at speed ${parameters.speed} for ${parameters.duration || "indefinite"} seconds`;
+
+ case "walk_backward":
+ return `${platform} walked backward at speed ${parameters.speed} for ${parameters.duration || "indefinite"} seconds`;
+
+ case "turn_left":
+ case "turn_right":
+ const direction = actionType.split("_")[1];
+ return `${platform} turned ${direction} at speed ${parameters.speed}`;
+
+ case "move_head":
+ return `${platform} moved head to yaw=${parameters.yaw}, pitch=${parameters.pitch}`;
+
+ case "move_arm":
+ return `${platform} moved ${parameters.arm} arm to specified position`;
+
+ case "stop_movement":
+ return `${platform} stopped all movement`;
+
+ case "set_volume":
+ return `${platform} set volume to ${parameters.volume}`;
+
+ case "set_language":
+ return `${platform} set language to ${parameters.language}`;
+
+ default:
+ return `${platform} executed action: ${actionType} (${result.duration}ms)`;
+ }
+ }
+
/**
* Advance to the next step
*/
diff --git a/src/server/storage.ts b/src/server/storage.ts
new file mode 100644
index 0000000..12a94da
--- /dev/null
+++ b/src/server/storage.ts
@@ -0,0 +1,20 @@
+import { S3Client } from "@aws-sdk/client-s3";
+import { env } from "~/env";
+
+const globalForS3 = globalThis as unknown as {
+ s3Client: S3Client | undefined;
+};
+
+export const s3Client =
+ globalForS3.s3Client ??
+ new S3Client({
+ region: env.MINIO_REGION ?? "us-east-1",
+ endpoint: env.MINIO_ENDPOINT,
+ credentials: {
+ accessKeyId: env.MINIO_ACCESS_KEY ?? "minioadmin",
+ secretAccessKey: env.MINIO_SECRET_KEY ?? "minioadmin",
+ },
+ forcePathStyle: true, // Needed for MinIO
+ });
+
+if (env.NODE_ENV !== "production") globalForS3.s3Client = s3Client;
diff --git a/src/styles/globals.css b/src/styles/globals.css
old mode 100644
new mode 100755
index 8bd26d3..7efc970
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -4,8 +4,7 @@
@custom-variant dark (&:is(.dark *));
@theme {
- --font-sans:
- var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
+ --font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
@@ -45,9 +44,7 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
- --font-sans: Geist Mono, monospace;
- --font-mono: Geist Mono, monospace;
- --font-serif: Geist Mono, monospace;
+
--radius: 0rem;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
@@ -108,9 +105,7 @@
--sidebar-border: oklch(0.85 0.03 245);
--sidebar-ring: oklch(0.6 0.05 240);
--destructive-foreground: oklch(0.9702 0 0);
- --font-sans: Geist Mono, monospace;
- --font-serif: Geist Mono, monospace;
- --font-mono: Geist Mono, monospace;
+
--shadow-color: hsl(0 0% 0%);
--shadow-opacity: 0;
--shadow-blur: 0px;
@@ -171,6 +166,43 @@
}
}
+@layer base {
+ .dark {
+ --background: oklch(0.12 0.008 250);
+ --foreground: oklch(0.95 0.005 250);
+ --card: oklch(0.18 0.008 250);
+ --card-foreground: oklch(0.95 0.005 250);
+ --popover: oklch(0.2 0.01 250);
+ --popover-foreground: oklch(0.95 0.005 250);
+ --primary: oklch(0.65 0.1 240);
+ --primary-foreground: oklch(0.08 0.02 250);
+ --secondary: oklch(0.25 0.015 245);
+ --secondary-foreground: oklch(0.92 0.008 250);
+ --muted: oklch(0.22 0.01 250);
+ --muted-foreground: oklch(0.65 0.02 245);
+ --accent: oklch(0.35 0.025 245);
+ --accent-foreground: oklch(0.92 0.008 250);
+ --destructive: oklch(0.7022 0.1892 22.2279);
+ --border: oklch(0.3 0.015 250);
+ --input: oklch(0.28 0.015 250);
+ --ring: oklch(0.65 0.1 240);
+ --chart-1: oklch(0.65 0.1 240);
+ --chart-2: oklch(0.7 0.12 200);
+ --chart-3: oklch(0.75 0.15 160);
+ --chart-4: oklch(0.8 0.12 120);
+ --chart-5: oklch(0.7 0.18 80);
+ --sidebar: oklch(0.14 0.025 250);
+ --sidebar-foreground: oklch(0.88 0.02 250);
+ --sidebar-primary: oklch(0.8 0.06 240);
+ --sidebar-primary-foreground: oklch(0.12 0.025 250);
+ --sidebar-accent: oklch(0.22 0.04 245);
+ --sidebar-accent-foreground: oklch(0.88 0.02 250);
+ --sidebar-border: oklch(0.32 0.035 250);
+ --sidebar-ring: oklch(0.55 0.08 240);
+ --destructive-foreground: oklch(0.95 0.01 250);
+ }
+}
+
@layer base {
* {
@apply border-border outline-ring/50;
diff --git a/src/trpc/query-client.ts b/src/trpc/query-client.ts
old mode 100644
new mode 100755
diff --git a/src/trpc/react.tsx b/src/trpc/react.tsx
old mode 100644
new mode 100755
diff --git a/src/trpc/server.ts b/src/trpc/server.ts
old mode 100644
new mode 100755
diff --git a/src/types/edge-websocket.d.ts b/src/types/edge-websocket.d.ts
old mode 100644
new mode 100755
diff --git a/src/types/participant.ts b/src/types/participant.ts
old mode 100644
new mode 100755
diff --git a/tsconfig.json b/tsconfig.json
old mode 100644
new mode 100755
index 6136f2b..e325c9e
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -9,28 +9,34 @@
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,
-
/* Strictness */
"strict": true,
"noUncheckedIndexedAccess": true,
"checkJs": true,
-
/* Bundled projects */
- "lib": ["dom", "dom.iterable", "ES2022"],
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "ES2022"
+ ],
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
- "jsx": "preserve",
- "plugins": [{ "name": "next" }],
+ "jsx": "react-jsx",
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
"incremental": true,
-
/* Path Aliases */
"baseUrl": ".",
"paths": {
- "~/*": ["./src/*"]
+ "~/*": [
+ "./src/*"
+ ]
}
},
-
"include": [
// FlowWorkspace (flow/FlowWorkspace.tsx) and new designer modules are included via recursive globs
"next-env.d.ts",
@@ -40,7 +46,11 @@
"**/*.js",
".next/types/**/*.ts",
"src/components/experiments/designer/**/*.ts",
- "src/components/experiments/designer/**/*.tsx"
+ "src/components/experiments/designer/**/*.tsx",
+ ".next/dev/types/**/*.ts"
],
- "exclude": ["node_modules", "robot-plugins"]
+ "exclude": [
+ "node_modules",
+ "robot-plugins"
+ ]
}