chore: commit full workspace changes (designer modularization, diagnostics fixes, docs updates, seed script cleanup)

This commit is contained in:
2025-08-08 00:37:35 -04:00
parent c071d33624
commit 1ac8296ab7
37 changed files with 5378 additions and 5758 deletions

View File

@@ -13,6 +13,7 @@ import {
AlertCircle,
CheckCircle2,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { Button } from "~/components/ui/button";
import {
@@ -28,55 +29,20 @@ import { api } from "~/trpc/react";
// Dashboard Overview Cards
function OverviewCards() {
const utils = api.useUtils();
// Auto-refresh overview data when component mounts to catch external changes
React.useEffect(() => {
const interval = setInterval(() => {
void utils.studies.list.invalidate();
void utils.experiments.getUserExperiments.invalidate();
void utils.trials.getUserTrials.invalidate();
}, 60000); // Refresh every minute
return () => clearInterval(interval);
}, [utils]);
const { data: studiesData } = api.studies.list.useQuery(
{ page: 1, limit: 1 },
{
staleTime: 1000 * 60 * 2, // 2 minutes
refetchOnWindowFocus: true,
},
);
const { data: experimentsData } = api.experiments.getUserExperiments.useQuery(
{ page: 1, limit: 1 },
{
staleTime: 1000 * 60 * 2, // 2 minutes
refetchOnWindowFocus: true,
},
);
const { data: trialsData } = api.trials.getUserTrials.useQuery(
{ page: 1, limit: 1 },
{
staleTime: 1000 * 60 * 2, // 2 minutes
refetchOnWindowFocus: true,
},
);
// TODO: Fix participants API call - needs actual study ID
const participantsData = { pagination: { total: 0 } };
const { data: stats, isLoading } = api.dashboard.getStats.useQuery();
const cards = [
{
title: "Active Studies",
value: studiesData?.pagination?.total ?? 0,
description: "Research studies in progress",
value: stats?.totalStudies ?? 0,
description: "Research studies you have access to",
icon: Building,
color: "text-blue-600",
bg: "bg-blue-50",
},
{
title: "Experiments",
value: experimentsData?.pagination?.total ?? 0,
value: stats?.totalExperiments ?? 0,
description: "Experiment protocols designed",
icon: FlaskConical,
color: "text-green-600",
@@ -84,7 +50,7 @@ function OverviewCards() {
},
{
title: "Participants",
value: participantsData?.pagination?.total ?? 0,
value: stats?.totalParticipants ?? 0,
description: "Enrolled participants",
icon: Users,
color: "text-purple-600",
@@ -92,14 +58,33 @@ function OverviewCards() {
},
{
title: "Trials",
value: trialsData?.pagination?.total ?? 0,
description: "Completed trials",
value: stats?.totalTrials ?? 0,
description: "Total trials conducted",
icon: TestTube,
color: "text-orange-600",
bg: "bg-orange-50",
},
];
if (isLoading) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="bg-muted h-4 w-20 animate-pulse rounded" />
<div className="bg-muted h-8 w-8 animate-pulse rounded" />
</CardHeader>
<CardContent>
<div className="bg-muted h-8 w-12 animate-pulse rounded" />
<div className="bg-muted mt-2 h-3 w-24 animate-pulse rounded" />
</CardContent>
</Card>
))}
</div>
);
}
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{cards.map((card) => (
@@ -122,41 +107,10 @@ function OverviewCards() {
// Recent Activity Component
function RecentActivity() {
// Mock data - replace with actual API calls
const activities = [
{
id: "1",
type: "trial_completed",
title: "Trial #142 completed",
description: "Memory retention study - Participant P001",
time: "2 hours ago",
status: "success",
},
{
id: "2",
type: "experiment_created",
title: "New experiment protocol",
description: "Social interaction study v2.1",
time: "4 hours ago",
status: "info",
},
{
id: "3",
type: "participant_enrolled",
title: "New participant enrolled",
description: "P045 added to cognitive study",
time: "6 hours ago",
status: "success",
},
{
id: "4",
type: "trial_started",
title: "Trial #143 started",
description: "Attention span experiment",
time: "8 hours ago",
status: "pending",
},
];
const { data: activities = [], isLoading } =
api.dashboard.getRecentActivity.useQuery({
limit: 8,
});
const getStatusIcon = (status: string) => {
switch (status) {
@@ -180,24 +134,46 @@ function RecentActivity() {
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{activities.map((activity) => (
<div key={activity.id} className="flex items-center space-x-4">
{getStatusIcon(activity.status)}
<div className="flex-1 space-y-1">
<p className="text-sm leading-none font-medium">
{activity.title}
</p>
<p className="text-muted-foreground text-sm">
{activity.description}
</p>
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center space-x-4">
<div className="bg-muted h-4 w-4 animate-pulse rounded-full" />
<div className="flex-1 space-y-2">
<div className="bg-muted h-4 w-3/4 animate-pulse rounded" />
<div className="bg-muted h-3 w-1/2 animate-pulse rounded" />
</div>
<div className="bg-muted h-3 w-16 animate-pulse rounded" />
</div>
<div className="text-muted-foreground text-sm">
{activity.time}
))}
</div>
) : activities.length === 0 ? (
<div className="py-8 text-center">
<AlertCircle className="text-muted-foreground mx-auto h-8 w-8" />
<p className="text-muted-foreground mt-2 text-sm">
No recent activity
</p>
</div>
) : (
<div className="space-y-4">
{activities.map((activity) => (
<div key={activity.id} className="flex items-center space-x-4">
{getStatusIcon(activity.status)}
<div className="flex-1 space-y-1">
<p className="text-sm leading-none font-medium">
{activity.title}
</p>
<p className="text-muted-foreground text-sm">
{activity.description}
</p>
</div>
<div className="text-muted-foreground text-sm">
{formatDistanceToNow(activity.time, { addSuffix: true })}
</div>
</div>
</div>
))}
</div>
))}
</div>
)}
</CardContent>
</Card>
);
@@ -262,33 +238,10 @@ function QuickActions() {
// Study Progress Component
function StudyProgress() {
// Mock data - replace with actual API calls
const studies = [
{
id: "1",
name: "Cognitive Load Study",
progress: 75,
participants: 24,
totalParticipants: 30,
status: "active",
},
{
id: "2",
name: "Social Interaction Research",
progress: 45,
participants: 18,
totalParticipants: 40,
status: "active",
},
{
id: "3",
name: "Memory Retention Analysis",
progress: 90,
participants: 45,
totalParticipants: 50,
status: "completing",
},
];
const { data: studies = [], isLoading } =
api.dashboard.getStudyProgress.useQuery({
limit: 5,
});
return (
<Card className="col-span-3">
@@ -299,31 +252,62 @@ function StudyProgress() {
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{studies.map((study) => (
<div key={study.id} className="space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm leading-none font-medium">
{study.name}
</p>
<p className="text-muted-foreground text-sm">
{study.participants}/{study.totalParticipants} participants
</p>
{isLoading ? (
<div className="space-y-6">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="bg-muted h-4 w-32 animate-pulse rounded" />
<div className="bg-muted h-3 w-24 animate-pulse rounded" />
</div>
<div className="bg-muted h-5 w-16 animate-pulse rounded" />
</div>
<Badge
variant={study.status === "active" ? "default" : "secondary"}
>
{study.status}
</Badge>
<div className="bg-muted h-2 w-full animate-pulse rounded" />
<div className="bg-muted h-3 w-16 animate-pulse rounded" />
</div>
<Progress value={study.progress} className="h-2" />
<p className="text-muted-foreground text-xs">
{study.progress}% complete
</p>
</div>
))}
</div>
))}
</div>
) : studies.length === 0 ? (
<div className="py-8 text-center">
<Building className="text-muted-foreground mx-auto h-8 w-8" />
<p className="text-muted-foreground mt-2 text-sm">
No active studies found
</p>
<p className="text-muted-foreground text-xs">
Create a study to get started
</p>
</div>
) : (
<div className="space-y-6">
{studies.map((study) => (
<div key={study.id} className="space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm leading-none font-medium">
{study.name}
</p>
<p className="text-muted-foreground text-sm">
{study.participants}/{study.totalParticipants} completed
trials
</p>
</div>
<Badge
variant={
study.status === "active" ? "default" : "secondary"
}
>
{study.status}
</Badge>
</div>
<Progress value={study.progress} className="h-2" />
<p className="text-muted-foreground text-xs">
{study.progress}% complete
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
);

View File

@@ -1,6 +1,6 @@
import { notFound } from "next/navigation";
import { EnhancedBlockDesigner } from "~/components/experiments/designer/EnhancedBlockDesigner";
import type { ExperimentBlock } from "~/components/experiments/designer/EnhancedBlockDesigner";
import { BlockDesigner } from "~/components/experiments/designer/BlockDesigner";
import type { ExperimentStep } from "~/lib/experiment-designer/types";
import { api } from "~/trpc/server";
interface ExperimentDesignerPageProps {
@@ -22,19 +22,19 @@ export default async function ExperimentDesignerPage({
// Parse existing visual design if available
const existingDesign = experiment.visualDesign as {
blocks?: unknown[];
steps?: unknown[];
version?: number;
lastSaved?: string;
} | null;
// Only pass initialDesign if there's existing visual design data
const initialDesign =
existingDesign?.blocks && existingDesign.blocks.length > 0
existingDesign?.steps && existingDesign.steps.length > 0
? {
id: experiment.id,
name: experiment.name,
description: experiment.description ?? "",
blocks: existingDesign.blocks as ExperimentBlock[],
steps: existingDesign.steps as ExperimentStep[],
version: existingDesign.version ?? 1,
lastSaved:
typeof existingDesign.lastSaved === "string"
@@ -44,7 +44,7 @@ export default async function ExperimentDesignerPage({
: undefined;
return (
<EnhancedBlockDesigner
<BlockDesigner
experimentId={experiment.id}
initialDesign={initialDesign}
/>
@@ -66,13 +66,13 @@ export async function generateMetadata({
const experiment = await api.experiments.get({ id: resolvedParams.id });
return {
title: `${experiment?.name} - Flow Designer | HRIStudio`,
description: `Design experiment protocol for ${experiment?.name} using visual flow editor`,
title: `${experiment?.name} - Designer | HRIStudio`,
description: `Design experiment protocol for ${experiment?.name} using step-based editor`,
};
} catch {
return {
title: "Experiment Flow Designer | HRIStudio",
description: "Immersive visual experiment protocol designer",
title: "Experiment Designer | HRIStudio",
description: "Step-based experiment protocol designer",
};
}
}

View File

@@ -12,7 +12,7 @@ interface DashboardContentProps {
completedToday: number;
canControl: boolean;
canManage: boolean;
recentTrials: any[];
_recentTrials: unknown[];
}
export function DashboardContent({
@@ -24,7 +24,7 @@ export function DashboardContent({
completedToday,
canControl,
canManage,
recentTrials,
_recentTrials,
}: DashboardContentProps) {
const getWelcomeMessage = () => {
switch (userRole) {
@@ -105,7 +105,7 @@ export function DashboardContent({
},
];
const alerts: any[] = [];
const alerts: never[] = [];
const recentActivity = null;

View File

@@ -19,6 +19,8 @@ import {
TestTube,
} from "lucide-react";
import { useSidebar } from "~/components/ui/sidebar";
import {
DropdownMenu,
DropdownMenuContent,
@@ -27,6 +29,12 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
import {
Sidebar,
SidebarContent,
@@ -44,6 +52,8 @@ import { Avatar, AvatarImage, AvatarFallback } from "~/components/ui/avatar";
import { Logo } from "~/components/ui/logo";
import { useStudyManagement } from "~/hooks/useStudyManagement";
import { handleAuthError, isAuthError } from "~/lib/auth-error-handler";
import { api } from "~/trpc/react";
// Navigation items
const navigationItems = [
@@ -103,9 +113,17 @@ export function AppSidebar({
const { data: session } = useSession();
const pathname = usePathname();
const isAdmin = userRole === "administrator";
const { state: sidebarState } = useSidebar();
const isCollapsed = sidebarState === "collapsed";
const { selectedStudyId, userStudies, selectStudy, refreshStudyData } =
useStudyManagement();
// Debug API call
const { data: debugData } = api.dashboard.debug.useQuery(undefined, {
enabled: process.env.NODE_ENV === "development",
staleTime: 1000 * 30, // 30 seconds
});
type Study = {
id: string;
name: string;
@@ -130,6 +148,11 @@ export function AppSidebar({
await selectStudy(studyId);
} catch (error) {
console.error("Failed to select study:", error);
// Handle auth errors first
if (isAuthError(error)) {
await handleAuthError(error, "Session expired while selecting study");
return;
}
// If study selection fails (e.g., study not found), clear the selection
await selectStudy(null);
}
@@ -139,6 +162,18 @@ export function AppSidebar({
(study: Study) => study.id === selectedStudyId,
);
// Debug logging for study data
React.useEffect(() => {
console.log("Sidebar debug - User studies:", {
count: userStudies.length,
studies: userStudies.map((s) => ({ id: s.id, name: s.name })),
selectedStudyId,
selectedStudy: selectedStudy
? { id: selectedStudy.id, name: selectedStudy.name }
: null,
});
}, [userStudies, selectedStudyId, selectedStudy]);
// If we have a selectedStudyId but can't find the study, clear the selection
React.useEffect(() => {
if (selectedStudyId && userStudies.length > 0 && !selectedStudy) {
@@ -152,12 +187,24 @@ export function AppSidebar({
// Auto-refresh studies list when component mounts to catch external changes
useEffect(() => {
const interval = setInterval(() => {
void refreshStudyData();
void (async () => {
try {
await refreshStudyData();
} catch (error) {
console.error("Failed to refresh study data:", error);
if (isAuthError(error)) {
void handleAuthError(error, "Session expired during data refresh");
}
}
})();
}, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}, [refreshStudyData]);
// Show debug info in development
const showDebug = process.env.NODE_ENV === "development";
return (
<Sidebar collapsible="icon" variant="sidebar" {...props}>
<SidebarHeader>
@@ -165,7 +212,7 @@ export function AppSidebar({
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<Link href="/dashboard">
<Logo iconSize="md" showText={true} />
<Logo iconSize="md" showText={!isCollapsed} />
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -179,52 +226,110 @@ export function AppSidebar({
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton className="w-full">
<Building className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
{isCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton className="w-full">
<Building className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
{selectedStudy?.name ?? "Select Study"}
</span>
<ChevronDown className="ml-auto h-4 w-4 flex-shrink-0" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-popper-anchor-width]"
align="start"
>
<DropdownMenuLabel>Studies</DropdownMenuLabel>
{userStudies.map((study: Study) => (
<DropdownMenuItem
key={study.id}
onClick={() => handleStudySelect(study.id)}
className="cursor-pointer"
>
<Building className="mr-2 h-4 w-4 flex-shrink-0" />
<span className="truncate" title={study.name}>
{study.name}
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{selectedStudyId && (
<DropdownMenuItem
onClick={async () => {
await selectStudy(null);
}}
>
<Building className="mr-2 h-4 w-4 opacity-50" />
Clear selection
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link href="/studies/new">
<Building className="mr-2 h-4 w-4" />
Create study
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TooltipTrigger>
<TooltipContent side="right" className="text-sm">
{selectedStudy?.name ?? "Select Study"}
</span>
<ChevronDown className="ml-auto h-4 w-4 flex-shrink-0" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-popper-anchor-width]"
align="start"
>
<DropdownMenuLabel>Studies</DropdownMenuLabel>
{userStudies.map((study: Study) => (
<DropdownMenuItem
key={study.id}
onClick={() => handleStudySelect(study.id)}
className="cursor-pointer"
>
<Building className="mr-2 h-4 w-4 flex-shrink-0" />
<span className="truncate" title={study.name}>
{study.name}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton className="w-full">
<Building className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
{selectedStudy?.name ?? "Select Study"}
</span>
<ChevronDown className="ml-auto h-4 w-4 flex-shrink-0" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-popper-anchor-width]"
align="start"
>
<DropdownMenuLabel>Studies</DropdownMenuLabel>
{userStudies.map((study: Study) => (
<DropdownMenuItem
key={study.id}
onClick={() => handleStudySelect(study.id)}
className="cursor-pointer"
>
<Building className="mr-2 h-4 w-4 flex-shrink-0" />
<span className="truncate" title={study.name}>
{study.name}
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{selectedStudyId && (
<DropdownMenuItem
onClick={async () => {
await selectStudy(null);
}}
>
<Building className="mr-2 h-4 w-4 opacity-50" />
Clear selection
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link href="/studies/new">
<Building className="mr-2 h-4 w-4" />
Create study
</Link>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{selectedStudyId && (
<DropdownMenuItem
onClick={async () => {
await selectStudy(null);
}}
>
<Building className="mr-2 h-4 w-4 opacity-50" />
Clear selection
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link href="/studies/new">
<Building className="mr-2 h-4 w-4" />
Create study
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
@@ -240,14 +345,29 @@ export function AppSidebar({
pathname === item.url ||
(item.url !== "/dashboard" && pathname.startsWith(item.url));
const menuButton = (
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.url}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
);
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.url}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
{isCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{menuButton}</TooltipTrigger>
<TooltipContent side="right" className="text-sm">
{item.title}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
menuButton
)}
</SidebarMenuItem>
);
})}
@@ -256,7 +376,7 @@ export function AppSidebar({
</SidebarGroup>
{/* Study-specific items hint */}
{!selectedStudyId && (
{!selectedStudyId && !isCollapsed && (
<SidebarGroup>
<SidebarGroupContent>
<div className="text-muted-foreground px-3 py-2 text-xs">
@@ -276,14 +396,31 @@ export function AppSidebar({
{adminItems.map((item) => {
const isActive = pathname.startsWith(item.url);
const menuButton = (
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.url}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
);
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.url}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
{isCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{menuButton}
</TooltipTrigger>
<TooltipContent side="right" className="text-sm">
{item.title}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
menuButton
)}
</SidebarMenuItem>
);
})}
@@ -293,46 +430,135 @@ export function AppSidebar({
)}
</SidebarContent>
{/* Debug Info */}
{showDebug && (
<SidebarGroup>
<SidebarGroupLabel>Debug Info</SidebarGroupLabel>
<SidebarGroupContent>
<div className="text-muted-foreground space-y-1 px-3 py-2 text-xs">
<div>Session: {session?.user?.email ?? "No session"}</div>
<div>Role: {userRole ?? "No role"}</div>
<div>Studies: {userStudies.length}</div>
<div>Selected: {selectedStudy?.name ?? "None"}</div>
<div>Auth: {session ? "✓" : "✗"}</div>
{debugData && (
<>
<div>DB User: {debugData.user?.email ?? "None"}</div>
<div>
System Roles: {debugData.systemRoles.join(", ") || "None"}
</div>
<div>Memberships: {debugData.studyMemberships.length}</div>
<div>All Studies: {debugData.allStudies.length}</div>
<div>
Session ID: {debugData.session.userId.slice(0, 8)}...
</div>
</>
)}
</div>
</SidebarGroupContent>
</SidebarGroup>
)}
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:h-8 group-data-[collapsible=icon]:w-8 group-data-[collapsible=icon]:justify-center"
>
<Avatar className="h-6 w-6 border-2 border-slate-300 group-data-[collapsible=icon]:h-6 group-data-[collapsible=icon]:w-6">
<AvatarImage
src={session?.user?.image ?? undefined}
alt={session?.user?.name ?? "User"}
/>
<AvatarFallback className="bg-slate-600 text-xs text-white">
{(session?.user?.name ?? session?.user?.email ?? "U")
.charAt(0)
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate font-semibold">
{session?.user?.name ?? "User"}
</span>
<span className="truncate text-xs">
{session?.user?.email ?? ""}
</span>
</div>
<MoreHorizontal className="ml-auto size-4 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-popper-anchor-width] min-w-56 rounded-lg"
side="bottom"
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 border-2 border-slate-300">
{isCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:h-8 group-data-[collapsible=icon]:w-8 group-data-[collapsible=icon]:justify-center"
>
<Avatar className="h-6 w-6 border-2 border-slate-300 group-data-[collapsible=icon]:h-6 group-data-[collapsible=icon]:w-6">
<AvatarImage
src={session?.user?.image ?? undefined}
alt={session?.user?.name ?? "User"}
/>
<AvatarFallback className="bg-slate-600 text-xs text-white">
{(
session?.user?.name ??
session?.user?.email ??
"U"
)
.charAt(0)
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate font-semibold">
{session?.user?.name ?? "User"}
</span>
<span className="truncate text-xs">
{session?.user?.email ?? ""}
</span>
</div>
<MoreHorizontal className="ml-auto size-4 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-popper-anchor-width] min-w-56 rounded-lg"
side="bottom"
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 border-2 border-slate-300">
<AvatarImage
src={session?.user?.image ?? undefined}
alt={session?.user?.name ?? "User"}
/>
<AvatarFallback className="bg-slate-600 text-xs text-white">
{(
session?.user?.name ??
session?.user?.email ??
"U"
)
.charAt(0)
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{session?.user?.name ?? "User"}
</span>
<span className="truncate text-xs">
{session?.user?.email ?? ""}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/profile">
<Settings className="mr-2 h-4 w-4" />
Profile & Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSignOut}>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TooltipTrigger>
<TooltipContent side="right" className="text-sm">
{session?.user?.name ?? "User Menu"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:h-8 group-data-[collapsible=icon]:w-8 group-data-[collapsible=icon]:justify-center"
>
<Avatar className="h-6 w-6 border-2 border-slate-300 group-data-[collapsible=icon]:h-6 group-data-[collapsible=icon]:w-6">
<AvatarImage
src={session?.user?.image ?? undefined}
alt={session?.user?.name ?? "User"}
@@ -343,7 +569,7 @@ export function AppSidebar({
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate font-semibold">
{session?.user?.name ?? "User"}
</span>
@@ -351,22 +577,53 @@ export function AppSidebar({
{session?.user?.email ?? ""}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/profile">
<Settings className="mr-2 h-4 w-4" />
Profile & Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSignOut}>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<MoreHorizontal className="ml-auto size-4 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-popper-anchor-width] min-w-56 rounded-lg"
side="bottom"
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 border-2 border-slate-300">
<AvatarImage
src={session?.user?.image ?? undefined}
alt={session?.user?.name ?? "User"}
/>
<AvatarFallback className="bg-slate-600 text-xs text-white">
{(session?.user?.name ?? session?.user?.email ?? "U")
.charAt(0)
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{session?.user?.name ?? "User"}
</span>
<span className="truncate text-xs">
{session?.user?.email ?? ""}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/profile">
<Settings className="mr-2 h-4 w-4" />
Profile & Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSignOut}>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>

View File

@@ -14,12 +14,12 @@ import { Card, CardContent } from "~/components/ui/card";
import { Checkbox } from "~/components/ui/checkbox";
import { DataTable } from "~/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { useActiveStudy } from "~/hooks/useActiveStudy";
import { api } from "~/trpc/react";
@@ -228,7 +228,9 @@ export const columns: ColumnDef<Experiment>[] = [
const date = row.getValue("createdAt");
return (
<div className="text-muted-foreground text-sm">
{formatDistanceToNow(new Date(date as string | number | Date), { addSuffix: true })}
{formatDistanceToNow(new Date(date as string | number | Date), {
addSuffix: true,
})}
</div>
);
},
@@ -306,20 +308,37 @@ export function ExperimentsTable() {
const data: Experiment[] = React.useMemo(() => {
if (!experimentsData) return [];
return experimentsData.map((exp: any) => ({
interface RawExperiment {
id: string;
name: string;
description?: string | null;
status: Experiment["status"];
version: number;
estimatedDuration?: number | null;
createdAt: string | Date;
studyId: string;
createdBy?: { name?: string | null; email?: string | null } | null;
trialCount?: number | null;
stepCount?: number | null;
}
const adapt = (exp: RawExperiment): Experiment => ({
id: exp.id,
name: exp.name,
description: exp.description,
description: exp.description ?? "",
status: exp.status,
version: exp.version,
estimatedDuration: exp.estimatedDuration,
createdAt: exp.createdAt,
estimatedDuration: exp.estimatedDuration ?? 0,
createdAt:
exp.createdAt instanceof Date ? exp.createdAt : new Date(exp.createdAt),
studyId: exp.studyId,
studyName: activeStudy?.title || "Unknown Study",
createdByName: exp.createdBy?.name || exp.createdBy?.email || "Unknown",
trialCount: exp.trialCount || 0,
stepCount: exp.stepCount || 0,
}));
studyName: activeStudy?.title ?? "Unknown Study",
createdByName: exp.createdBy?.name ?? exp.createdBy?.email ?? "Unknown",
trialCount: exp.trialCount ?? 0,
stepCount: exp.stepCount ?? 0,
});
return experimentsData.map((e) => adapt(e as unknown as RawExperiment));
}, [experimentsData, activeStudy]);
if (!activeStudy) {

View File

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

View File

@@ -0,0 +1,450 @@
"use client";
import type { ActionDefinition } from "~/lib/experiment-designer/types";
/**
* ActionRegistry
*
* Central singleton for loading and serving action definitions from:
* - Core system action JSON manifests (served from /hristudio-core/plugins/*.json)
* - Study-installed plugin action definitions (ROS2 / REST / internal transports)
*
* Responsibilities:
* - Lazy, idempotent loading of core and plugin actions
* - Provenance retention (core vs plugin, plugin id/version, robot id)
* - Parameter schema → UI parameter mapping (primitive only for now)
* - Fallback action population if core load fails (ensures minimal functionality)
*
* Notes:
* - The registry is client-side only (designer runtime); server performs its own
* validation & compilation using persisted action instances (never trusts client).
* - Action IDs for plugins are namespaced: `${plugin.id}.${action.id}`.
* - Core actions retain their base IDs (e.g., wait, wizard_speak) for clarity.
*/
export class ActionRegistry {
private static instance: ActionRegistry;
private actions = new Map<string, ActionDefinition>();
private coreActionsLoaded = false;
private pluginActionsLoaded = false;
private loadedStudyId: string | null = null;
static getInstance(): ActionRegistry {
if (!ActionRegistry.instance) {
ActionRegistry.instance = new ActionRegistry();
}
return ActionRegistry.instance;
}
/* ---------------- Core Actions ---------------- */
async loadCoreActions(): Promise<void> {
if (this.coreActionsLoaded) return;
interface CoreBlockParam {
id: string;
name: string;
type: string;
placeholder?: string;
options?: string[];
min?: number;
max?: number;
value?: string | number | boolean;
required?: boolean;
description?: string;
step?: number;
}
interface CoreBlock {
id: string;
name: string;
description?: string;
category: string;
icon?: string;
color?: string;
parameters?: CoreBlockParam[];
timeoutMs?: number;
retryable?: boolean;
}
try {
const coreActionSets = ["wizard-actions", "control-flow", "observation"];
for (const actionSetId of coreActionSets) {
try {
const response = await fetch(
`/hristudio-core/plugins/${actionSetId}.json`,
);
// Non-blocking skip if not found
if (!response.ok) continue;
const rawActionSet = (await response.json()) as unknown;
const actionSet = rawActionSet as { blocks?: CoreBlock[] };
if (!actionSet.blocks || !Array.isArray(actionSet.blocks)) continue;
// Register each block as an ActionDefinition
actionSet.blocks.forEach((block) => {
if (!block.id || !block.name) return;
const actionDef: ActionDefinition = {
id: block.id,
type: block.id,
name: block.name,
description: block.description ?? "",
category: this.mapBlockCategoryToActionCategory(block.category),
icon: block.icon ?? "Zap",
color: block.color ?? "#6b7280",
parameters: (block.parameters ?? []).map((param) => ({
id: param.id,
name: param.name,
type:
(param.type as "text" | "number" | "select" | "boolean") ||
"text",
placeholder: param.placeholder,
options: param.options,
min: param.min,
max: param.max,
value: param.value,
required: param.required !== false,
description: param.description,
step: param.step,
})),
source: {
kind: "core",
baseActionId: block.id,
},
execution: {
transport: "internal",
timeoutMs: block.timeoutMs,
retryable: block.retryable,
},
parameterSchemaRaw: {
parameters: block.parameters ?? [],
},
};
this.actions.set(actionDef.id, actionDef);
});
} catch (error) {
// Non-fatal: we will fallback later
console.warn(`Failed to load core action set ${actionSetId}:`, error);
}
}
this.coreActionsLoaded = true;
} catch (error) {
console.error("Failed to load core actions:", error);
this.loadFallbackActions();
}
}
private mapBlockCategoryToActionCategory(
category: string,
): ActionDefinition["category"] {
switch (category) {
case "wizard":
case "event":
return "wizard";
case "robot":
return "robot";
case "control":
return "control";
case "sensor":
case "observation":
return "observation";
default:
return "wizard";
}
}
private loadFallbackActions(): void {
const fallbackActions: ActionDefinition[] = [
{
id: "wizard_speak",
type: "wizard_speak",
name: "Wizard Says",
description: "Wizard speaks to participant",
category: "wizard",
icon: "MessageSquare",
color: "#3b82f6",
parameters: [
{
id: "text",
name: "Text to say",
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" },
},
required: ["text"],
},
},
{
id: "wait",
type: "wait",
name: "Wait",
description: "Wait for specified time",
category: "control",
icon: "Clock",
color: "#f59e0b",
parameters: [
{
id: "duration",
name: "Duration (seconds)",
type: "number",
min: 0.1,
max: 300,
value: 2,
required: true,
},
],
source: { kind: "core", baseActionId: "wait" },
execution: { transport: "internal", timeoutMs: 60000 },
parameterSchemaRaw: {
type: "object",
properties: {
duration: {
type: "number",
minimum: 0.1,
maximum: 300,
default: 2,
},
},
required: ["duration"],
},
},
{
id: "observe",
type: "observe",
name: "Observe",
description: "Record participant behavior",
category: "observation",
icon: "Eye",
color: "#8b5cf6",
parameters: [
{
id: "behavior",
name: "Behavior to observe",
type: "select",
options: ["facial_expression", "body_language", "verbal_response"],
required: true,
},
],
source: { kind: "core", baseActionId: "observe" },
execution: { transport: "internal", timeoutMs: 120000 },
parameterSchemaRaw: {
type: "object",
properties: {
behavior: {
type: "string",
enum: ["facial_expression", "body_language", "verbal_response"],
},
},
required: ["behavior"],
},
},
];
fallbackActions.forEach((action) => this.actions.set(action.id, action));
}
/* ---------------- Plugin Actions ---------------- */
loadPluginActions(
studyId: string,
studyPlugins: Array<{
plugin: {
id: string;
robotId: string | null;
version: string | null;
actionDefinitions?: Array<{
id: string;
name: string;
description?: string;
category?: string;
icon?: string;
timeout?: number;
retryable?: boolean;
parameterSchema?: unknown;
ros2?: {
topic?: string;
messageType?: string;
service?: string;
action?: string;
payloadMapping?: unknown;
qos?: {
reliability?: string;
durability?: string;
history?: string;
depth?: number;
};
};
rest?: {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
path: string;
headers?: Record<string, string>;
};
}>;
};
}>,
): void {
if (this.pluginActionsLoaded && this.loadedStudyId === studyId) return;
if (this.loadedStudyId !== studyId) {
this.resetPluginActions();
}
(studyPlugins ?? []).forEach((studyPlugin) => {
const { plugin } = studyPlugin;
const actionDefs = Array.isArray(plugin.actionDefinitions)
? plugin.actionDefinitions
: undefined;
if (!actionDefs) return;
actionDefs.forEach((action) => {
const category =
(action.category as ActionDefinition["category"]) || "robot";
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,
},
}
: 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: "internal" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
};
const actionDef: ActionDefinition = {
id: `${plugin.id}.${action.id}`,
type: `${plugin.id}.${action.id}`,
name: action.name,
description: action.description ?? "",
category,
icon: action.icon ?? "Bot",
color: "#10b981",
parameters: this.convertParameterSchemaToParameters(
action.parameterSchema,
),
source: {
kind: "plugin",
pluginId: plugin.id,
robotId: plugin.robotId,
pluginVersion: plugin.version ?? undefined,
baseActionId: action.id,
},
execution,
parameterSchemaRaw: action.parameterSchema ?? undefined,
};
this.actions.set(actionDef.id, actionDef);
});
});
this.pluginActionsLoaded = true;
this.loadedStudyId = studyId;
}
private convertParameterSchemaToParameters(
parameterSchema: unknown,
): ActionDefinition["parameters"] {
interface JsonSchemaProperty {
type?: string;
title?: string;
description?: string;
enum?: string[];
default?: string | number | boolean;
minimum?: number;
maximum?: number;
}
interface JsonSchema {
properties?: Record<string, JsonSchemaProperty>;
required?: string[];
}
const schema = parameterSchema as JsonSchema | undefined;
if (!schema?.properties) return [];
return Object.entries(schema.properties).map(([key, paramDef]) => {
let type: "text" | "number" | "select" | "boolean" = "text";
if (paramDef.type === "number") {
type = "number";
} else if (paramDef.type === "boolean") {
type = "boolean";
} else if (paramDef.enum && Array.isArray(paramDef.enum)) {
type = "select";
}
return {
id: key,
name: paramDef.title ?? key.charAt(0).toUpperCase() + key.slice(1),
type,
value: paramDef.default,
placeholder: paramDef.description,
options: paramDef.enum,
min: paramDef.minimum,
max: paramDef.maximum,
required: true,
};
});
}
private resetPluginActions(): void {
this.pluginActionsLoaded = false;
this.loadedStudyId = null;
// Remove existing plugin actions (retain known core ids + fallback ids)
const pluginActionIds = Array.from(this.actions.keys()).filter(
(id) =>
!id.startsWith("wizard_") &&
!id.startsWith("wait") &&
!id.startsWith("observe"),
);
pluginActionIds.forEach((id) => this.actions.delete(id));
}
/* ---------------- Query Helpers ---------------- */
getActionsByCategory(
category: ActionDefinition["category"],
): ActionDefinition[] {
return Array.from(this.actions.values()).filter(
(action) => action.category === category,
);
}
getAllActions(): ActionDefinition[] {
return Array.from(this.actions.values());
}
getAction(id: string): ActionDefinition | undefined {
return this.actions.get(id);
}
}
export const actionRegistry = ActionRegistry.getInstance();

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,461 @@
"use client";
import React from "react";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "~/components/ui/select";
import { Switch } from "~/components/ui/switch";
import { Slider } from "~/components/ui/slider";
import { Badge } from "~/components/ui/badge";
import { cn } from "~/lib/utils";
import {
TRIGGER_OPTIONS,
type ExperimentAction,
type ExperimentStep,
type StepType,
type TriggerType,
type ExperimentDesign,
} from "~/lib/experiment-designer/types";
import { actionRegistry } from "./ActionRegistry";
import {
Settings,
Zap,
MessageSquare,
Hand,
Navigation,
Volume2,
Clock,
Eye,
Bot,
User,
Timer,
MousePointer,
Mic,
Activity,
Play,
} from "lucide-react";
/**
* PropertiesPanel
*
* Extracted modular panel for editing either:
* - Action properties (when an action is selected)
* - Step properties (when a step is selected and no action selected)
* - Empty instructional state otherwise
*
* Enhancements:
* - Boolean parameters render as Switch
* - Number parameters with min/max render as Slider (with live value)
* - Number parameters without bounds fall back to numeric input
* - Select and text remain standard controls
* - Provenance + category badges retained
*/
export interface PropertiesPanelProps {
design: ExperimentDesign;
selectedStep?: ExperimentStep;
selectedAction?: ExperimentAction;
onActionUpdate: (
stepId: string,
actionId: string,
updates: Partial<ExperimentAction>,
) => void;
onStepUpdate: (stepId: string, updates: Partial<ExperimentStep>) => void;
className?: string;
}
export function PropertiesPanel({
design,
selectedStep,
selectedAction,
onActionUpdate,
onStepUpdate,
className,
}: PropertiesPanelProps) {
const registry = actionRegistry;
// Find containing step for selected action (if any)
const containingStep =
selectedAction &&
design.steps.find((s) => s.actions.some((a) => a.id === selectedAction.id));
/* -------------------------- Action Properties View -------------------------- */
if (selectedAction && containingStep) {
const def = registry.getAction(selectedAction.type);
const categoryColors = {
wizard: "bg-blue-500",
robot: "bg-emerald-500",
control: "bg-amber-500",
observation: "bg-purple-500",
} as const;
// Icon resolution uses statically imported lucide icons (no dynamic require)
// Icon resolution uses statically imported lucide icons (no dynamic require)
const iconComponents: Record<
string,
React.ComponentType<{ className?: string }>
> = {
Zap,
MessageSquare,
Hand,
Navigation,
Volume2,
Clock,
Eye,
Bot,
User,
Timer,
MousePointer,
Mic,
Activity,
Play,
};
const ResolvedIcon: React.ComponentType<{ className?: string }> =
def?.icon && iconComponents[def.icon]
? (iconComponents[def.icon] as React.ComponentType<{
className?: string;
}>)
: Zap;
return (
<div className={cn("space-y-3", className)}>
{/* Header / Metadata */}
<div className="border-b pb-3">
<div className="mb-2 flex items-center gap-2">
{def && (
<div
className={cn(
"flex h-6 w-6 items-center justify-center rounded text-white",
categoryColors[def.category],
)}
>
<ResolvedIcon className="h-3 w-3" />
</div>
)}
<div className="min-w-0">
<h3 className="truncate text-sm font-medium">
{selectedAction.name}
</h3>
<p className="text-muted-foreground text-xs">
{def?.category} {selectedAction.type}
</p>
</div>
</div>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="h-4 text-[10px]">
{selectedAction.source.kind === "plugin" ? "Plugin" : "Core"}
</Badge>
{selectedAction.source.pluginId && (
<Badge variant="secondary" className="h-4 text-[10px]">
{selectedAction.source.pluginId}
{selectedAction.source.pluginVersion
? `@${selectedAction.source.pluginVersion}`
: ""}
</Badge>
)}
<Badge variant="outline" className="h-4 text-[10px]">
{selectedAction.execution.transport}
</Badge>
{selectedAction.execution.retryable && (
<Badge variant="outline" className="h-4 text-[10px]">
retryable
</Badge>
)}
</div>
{def?.description && (
<p className="text-muted-foreground mt-2 text-xs leading-relaxed">
{def.description}
</p>
)}
</div>
{/* General Action Fields */}
<div className="space-y-2">
<div>
<Label className="text-xs">Display Name</Label>
<Input
value={selectedAction.name}
onChange={(e) =>
onActionUpdate(containingStep.id, selectedAction.id, {
name: e.target.value,
})
}
className="mt-1 h-7 text-xs"
/>
</div>
</div>
{/* Parameters */}
{def?.parameters.length ? (
<div className="space-y-3">
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
Parameters
</div>
<div className="space-y-3">
{def.parameters.map((param) => {
const rawValue = selectedAction.parameters[param.id];
const commonLabel = (
<Label className="flex items-center gap-2 text-xs">
{param.name}
<span className="text-muted-foreground font-normal">
{param.type === "number" &&
(param.min !== undefined || param.max !== undefined) &&
typeof rawValue === "number" &&
`( ${rawValue} )`}
</span>
</Label>
);
/* ---- 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 = (
<Input
value={(rawValue as string) ?? ""}
placeholder={param.placeholder}
onChange={(e) => updateParamValue(e.target.value)}
className="mt-1 h-7 text-xs"
/>
);
} else if (param.type === "select") {
control = (
<Select
value={(rawValue as string) ?? ""}
onValueChange={(val) => updateParamValue(val)}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent>
{param.options?.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
);
} else if (param.type === "boolean") {
control = (
<div className="mt-1 flex h-7 items-center">
<Switch
checked={Boolean(rawValue)}
onCheckedChange={(val) => updateParamValue(val)}
aria-label={param.name}
/>
<span className="text-muted-foreground ml-2 text-[11px]">
{Boolean(rawValue) ? "Enabled" : "Disabled"}
</span>
</div>
);
} 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 = (
<div className="mt-1">
<div className="flex items-center gap-2">
<Slider
min={min}
max={max}
step={step}
value={[Number(numericVal)]}
onValueChange={(vals: number[]) =>
updateParamValue(vals[0])
}
/>
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
{step < 1
? Number(numericVal).toFixed(2)
: Number(numericVal).toString()}
</span>
</div>
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
<span>{min}</span>
<span>{max}</span>
</div>
</div>
);
} else {
control = (
<Input
type="number"
value={numericVal}
onChange={(e) =>
updateParamValue(parseFloat(e.target.value) || 0)
}
className="mt-1 h-7 text-xs"
/>
);
}
}
return (
<div key={param.id} className="space-y-1">
{commonLabel}
{param.description && (
<div className="text-muted-foreground text-[10px]">
{param.description}
</div>
)}
{control}
</div>
);
})}
</div>
</div>
) : (
<div className="text-muted-foreground text-xs">
No parameters for this action.
</div>
)}
</div>
);
}
/* --------------------------- Step Properties View --------------------------- */
if (selectedStep) {
return (
<div className={cn("space-y-3", className)}>
<div className="border-b pb-2">
<h3 className="flex items-center gap-2 text-sm font-medium">
<div
className={cn("h-3 w-3 rounded", {
"bg-blue-500": selectedStep.type === "sequential",
"bg-emerald-500": selectedStep.type === "parallel",
"bg-amber-500": selectedStep.type === "conditional",
"bg-purple-500": selectedStep.type === "loop",
})}
/>
Step Settings
</h3>
</div>
<div className="space-y-2">
<div>
<Label className="text-xs">Name</Label>
<Input
value={selectedStep.name}
onChange={(e) =>
onStepUpdate(selectedStep.id, { name: e.target.value })
}
className="mt-1 h-7 text-xs"
/>
</div>
<div>
<Label className="text-xs">Description</Label>
<Input
value={selectedStep.description ?? ""}
placeholder="Optional step description"
onChange={(e) =>
onStepUpdate(selectedStep.id, {
description: e.target.value,
})
}
className="mt-1 h-7 text-xs"
/>
</div>
<div>
<Label className="text-xs">Type</Label>
<Select
value={selectedStep.type}
onValueChange={(val) =>
onStepUpdate(selectedStep.id, { type: val as StepType })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sequential">Sequential</SelectItem>
<SelectItem value="parallel">Parallel</SelectItem>
<SelectItem value="conditional">Conditional</SelectItem>
<SelectItem value="loop">Loop</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Trigger</Label>
<Select
value={selectedStep.trigger.type}
onValueChange={(val) =>
onStepUpdate(selectedStep.id, {
trigger: {
...selectedStep.trigger,
type: val as TriggerType,
},
})
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRIGGER_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
}
/* ------------------------------- Empty State ------------------------------- */
return (
<div
className={cn(
"flex h-24 items-center justify-center text-center",
className,
)}
>
<div>
<Settings className="text-muted-foreground/50 mx-auto mb-2 h-6 w-6" />
<h3 className="mb-1 text-sm font-medium">Select Step or Action</h3>
<p className="text-muted-foreground text-xs">
Click in the flow to edit properties
</p>
</div>
</div>
);
}

View File

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

View File

@@ -15,12 +15,12 @@ import { Card, CardContent } from "~/components/ui/card";
import { Checkbox } from "~/components/ui/checkbox";
import { DataTable } from "~/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { useActiveStudy } from "~/hooks/useActiveStudy";
import { api } from "~/trpc/react";
@@ -164,7 +164,9 @@ export const columns: ColumnDef<Participant>[] = [
const date = row.getValue("createdAt");
return (
<div className="text-muted-foreground text-sm">
{formatDistanceToNow(new Date(date as string | number | Date), { addSuffix: true })}
{formatDistanceToNow(new Date(date as string | number | Date), {
addSuffix: true,
})}
</div>
);
},
@@ -238,25 +240,27 @@ export function ParticipantsTable({ studyId }: ParticipantsTableProps = {}) {
// Refetch when active study changes
useEffect(() => {
if (activeStudy?.id || studyId) {
refetch();
void refetch();
}
}, [activeStudy?.id, studyId, refetch]);
const data: Participant[] = React.useMemo(() => {
if (!participantsData?.participants) return [];
return participantsData.participants.map((p) => ({
id: p.id,
participantCode: p.participantCode,
email: p.email,
name: p.name,
consentGiven: p.hasConsent,
consentDate: p.latestConsent?.signedAt
? new Date(p.latestConsent.signedAt as unknown as string)
: null,
createdAt: p.createdAt,
trialCount: p.trialCount,
}));
return participantsData.participants.map(
(p): Participant => ({
id: p.id,
participantCode: p.participantCode,
email: p.email,
name: p.name,
consentGiven: p.hasConsent,
consentDate: p.latestConsent?.signedAt
? new Date(p.latestConsent.signedAt as unknown as string)
: null,
createdAt: p.createdAt,
trialCount: p.trialCount,
}),
);
}, [participantsData]);
if (!studyId && !activeStudy) {

View File

@@ -212,7 +212,6 @@ export function PluginStoreBrowse() {
data: availablePlugins,
isLoading,
error,
refetch,
} = api.robots.plugins.list.useQuery(
{
status:
@@ -227,10 +226,14 @@ export function PluginStoreBrowse() {
},
);
const utils = api.useUtils();
const installPluginMutation = api.robots.plugins.install.useMutation({
onSuccess: () => {
toast.success("Plugin installed successfully!");
void refetch();
// Invalidate both plugin queries to refresh the UI
void utils.robots.plugins.list.invalidate();
void utils.robots.plugins.getStudyPlugins.invalidate();
},
onError: (error) => {
toast.error(error.message || "Failed to install plugin");
@@ -426,7 +429,10 @@ export function PluginStoreBrowse() {
{error.message ||
"An error occurred while loading the plugin store."}
</p>
<Button onClick={() => refetch()} variant="outline">
<Button
onClick={() => void utils.robots.plugins.list.refetch()}
variant="outline"
>
Try Again
</Button>
</div>

View File

@@ -25,6 +25,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { useStudyContext } from "~/lib/study-context";
import { api } from "~/trpc/react";
export type Plugin = {
plugin: {
@@ -85,18 +87,45 @@ const statusConfig = {
};
function PluginActionsCell({ plugin }: { plugin: Plugin }) {
const { selectedStudyId } = useStudyContext();
const utils = api.useUtils();
const uninstallMutation = api.robots.plugins.uninstall.useMutation({
onSuccess: () => {
toast.success("Plugin uninstalled successfully");
// Invalidate plugin queries to refresh the UI
void utils.robots.plugins.getStudyPlugins.invalidate();
void utils.robots.plugins.list.invalidate();
},
onError: (error) => {
toast.error(error.message || "Failed to uninstall plugin");
},
});
const isCorePlugin = plugin.plugin.name === "HRIStudio Core System";
const handleUninstall = async () => {
if (isCorePlugin) {
toast.error(
"Cannot uninstall the core system plugin - it's required for experiment design",
);
return;
}
if (
window.confirm(
`Are you sure you want to uninstall "${plugin.plugin.name}"?`,
`Are you sure you want to uninstall "${plugin.plugin.name}"? This will remove all plugin blocks from experiments in this study.`,
)
) {
try {
// TODO: Implement uninstall mutation
toast.success("Plugin uninstalled successfully");
} catch {
toast.error("Failed to uninstall plugin");
if (!selectedStudyId) {
toast.error("No study selected");
return;
}
uninstallMutation.mutate({
studyId: selectedStudyId,
pluginId: plugin.plugin.id,
});
}
};
@@ -145,10 +174,17 @@ function PluginActionsCell({ plugin }: { plugin: Plugin }) {
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleUninstall}
className="text-red-600 focus:text-red-600"
disabled={uninstallMutation.isPending || isCorePlugin}
className={
isCorePlugin ? "text-gray-400" : "text-red-600 focus:text-red-600"
}
>
<Trash2 className="mr-2 h-4 w-4" />
Uninstall
{isCorePlugin
? "Core Plugin"
: uninstallMutation.isPending
? "Uninstalling..."
: "Uninstall"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -0,0 +1,63 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "~/lib/utils";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };

View File

@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { useStudyContext } from "~/lib/study-context";
import { api } from "~/trpc/react";
import { handleAuthError, isAuthError } from "~/lib/auth-error-handler";
/**
* Custom hook for centralized study management across the platform.
@@ -52,6 +53,15 @@ export function useStudyManagement() {
return newStudy;
} catch (error) {
console.error("Failed to create study:", error);
// Handle auth errors
if (isAuthError(error)) {
await handleAuthError(
error,
"Authentication failed while creating study",
);
return;
}
const message =
error instanceof Error ? error.message : "Failed to create study";
toast.error(message);
@@ -90,6 +100,15 @@ export function useStudyManagement() {
return updatedStudy;
} catch (error) {
console.error("Failed to update study:", error);
// Handle auth errors
if (isAuthError(error)) {
await handleAuthError(
error,
"Authentication failed while updating study",
);
return;
}
const message =
error instanceof Error ? error.message : "Failed to update study";
toast.error(message);
@@ -121,6 +140,15 @@ export function useStudyManagement() {
// Navigate to studies list
router.push("/studies");
} catch (error) {
console.error("Failed to delete study:", error);
// Handle auth errors
if (isAuthError(error)) {
await handleAuthError(
error,
"Authentication failed while deleting study",
);
return;
}
const message =
error instanceof Error ? error.message : "Failed to delete study";
toast.error(message);
@@ -253,6 +281,12 @@ export function useStudyManagement() {
enabled: !!selectedStudyId,
staleTime: 1000 * 60 * 2, // 2 minutes
retry: (failureCount, error) => {
console.log("Selected study query error:", error);
// Handle auth errors first
if (isAuthError(error)) {
void handleAuthError(error, "Session expired while loading study");
return false;
}
// Don't retry if study not found (404-like errors)
if (
error.message?.includes("not found") ||
@@ -290,6 +324,15 @@ export function useStudyManagement() {
{
staleTime: 1000 * 60 * 2, // 2 minutes
refetchOnWindowFocus: true,
retry: (failureCount, error) => {
console.log("Studies query error:", error);
// Handle auth errors
if (isAuthError(error)) {
void handleAuthError(error, "Session expired while loading studies");
return false;
}
return failureCount < 2;
},
},
);

View File

@@ -0,0 +1,181 @@
"use client";
import { signOut } from "next-auth/react";
import { toast } from "sonner";
import { TRPCClientError } from "@trpc/client";
/**
* Auth error codes that should trigger automatic logout
*/
const AUTH_ERROR_CODES = [
"UNAUTHORIZED",
"FORBIDDEN",
"UNAUTHENTICATED",
] as const;
/**
* Auth error messages that should trigger automatic logout
*/
const AUTH_ERROR_MESSAGES = [
"unauthorized",
"unauthenticated",
"forbidden",
"invalid token",
"token expired",
"session expired",
"authentication failed",
"access denied",
] as const;
/**
* Checks if an error is an authentication/authorization error that should trigger logout
*/
export function isAuthError(error: unknown): boolean {
if (!error) return false;
// Check TRPC errors
if (error instanceof TRPCClientError) {
// Check error code
const trpcErrorData = error.data as
| { code?: string; httpStatus?: number }
| undefined;
const errorCode = trpcErrorData?.code;
if (
errorCode &&
AUTH_ERROR_CODES.includes(errorCode as (typeof AUTH_ERROR_CODES)[number])
) {
return true;
}
// Check HTTP status codes
const httpStatus = trpcErrorData?.httpStatus;
if (httpStatus === 401 || httpStatus === 403) {
return true;
}
// Check error message
const message = error.message?.toLowerCase() ?? "";
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
}
// Check generic errors
if (error instanceof Error) {
const message = error.message?.toLowerCase() || "";
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
}
// Check error objects with message property
if (typeof error === "object" && error !== null) {
if ("message" in error) {
const errorObj = error as { message: unknown };
const message = String(errorObj.message).toLowerCase();
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
}
// Check for status codes in error objects
if ("status" in error) {
const statusObj = error as { status: unknown };
const status = statusObj.status as number;
return status === 401 || status === 403;
}
}
return false;
}
/**
* Handles authentication errors by logging out the user
*/
export async function handleAuthError(
error: unknown,
customMessage?: string,
): Promise<void> {
if (!isAuthError(error)) {
return;
}
console.warn("Authentication error detected, logging out user:", error);
// Show user-friendly message
const message = customMessage ?? "Session expired. Please log in again.";
toast.error(message);
// Small delay to let the toast show
setTimeout(() => {
void (async () => {
try {
await signOut({
callbackUrl: "/",
redirect: true,
});
} catch (signOutError) {
console.error("Error during sign out:", signOutError);
// Force redirect if signOut fails
window.location.href = "/";
}
})();
}, 1000);
}
/**
* React Query error handler that automatically handles auth errors
*/
export function createAuthErrorHandler(customMessage?: string) {
return (error: unknown) => {
void handleAuthError(error, customMessage);
};
}
/**
* tRPC error handler that automatically handles auth errors
*/
export function handleTRPCError(error: unknown, customMessage?: string): void {
void handleAuthError(error, customMessage);
}
/**
* Generic error handler for any error type
*/
export function handleGenericError(
error: unknown,
customMessage?: string,
): void {
void handleAuthError(error, customMessage);
}
/**
* Hook-style error handler for use in React components
*/
export function useAuthErrorHandler() {
return {
handleAuthError: (error: unknown, customMessage?: string) => {
void handleAuthError(error, customMessage);
},
isAuthError,
createErrorHandler: createAuthErrorHandler,
};
}
/**
* Higher-order function to wrap API calls with automatic auth error handling
*/
export function withAuthErrorHandling<
T extends (...args: unknown[]) => Promise<unknown>,
>(fn: T, customMessage?: string): T {
return (async (...args: Parameters<T>): Promise<ReturnType<T>> => {
try {
return (await fn(...args)) as ReturnType<T>;
} catch (error) {
await handleAuthError(error, customMessage);
throw error; // Re-throw so calling code can handle it too
}
}) as T;
}
/**
* Utility to check if current error should show a generic error message
* (i.e., it's not an auth error that will auto-logout)
*/
export function shouldShowGenericError(error: unknown): boolean {
return !isAuthError(error);
}

View File

@@ -0,0 +1,160 @@
import type {
ExperimentStep,
ExperimentAction,
ExecutionDescriptor,
} from "./types";
// Convert step-based design to database records
export function convertStepsToDatabase(
steps: ExperimentStep[],
): ConvertedStep[] {
return steps.map((step, index) => ({
name: step.name,
description: step.description,
type: mapStepTypeToDatabase(step.type),
orderIndex: index,
durationEstimate: calculateStepDuration(step.actions),
required: true,
conditions: step.trigger.conditions,
actions: step.actions.map((action, actionIndex) =>
convertActionToDatabase(action, actionIndex),
),
}));
}
// Map designer step types to database step types
function mapStepTypeToDatabase(
stepType: ExperimentStep["type"],
): "wizard" | "robot" | "parallel" | "conditional" {
switch (stepType) {
case "sequential":
return "wizard"; // Default to wizard for sequential
case "parallel":
return "parallel";
case "conditional":
case "loop":
return "conditional";
default:
return "wizard";
}
}
// Calculate step duration from actions
function calculateStepDuration(actions: ExperimentAction[]): number {
let total = 0;
for (const action of actions) {
switch (action.type) {
case "wizard_speak":
case "robot_speak":
// Estimate based on text length if available
const text = action.parameters.text as string;
if (text) {
total += Math.max(2, text.length / 10); // ~10 chars per second
} else {
total += 3;
}
break;
case "wait":
total += (action.parameters.duration as number) || 2;
break;
case "robot_move":
total += 5; // Movement takes longer
break;
case "wizard_gesture":
total += 2;
break;
case "observe":
total += (action.parameters.duration as number) || 5;
break;
default:
total += 2; // Default duration
}
}
return Math.max(1, Math.round(total));
}
// Estimate action timeout
function estimateActionTimeout(action: ExperimentAction): number {
switch (action.type) {
case "wizard_speak":
case "robot_speak":
return 30;
case "robot_move":
return 60;
case "wait": {
const duration = action.parameters.duration as number | undefined;
return (duration ?? 2) + 10; // Add buffer
}
case "observe":
return 120; // Observation can take longer
default:
return 30;
}
}
// Database conversion types (same as before)
export interface ConvertedStep {
name: string;
description?: string;
type: "wizard" | "robot" | "parallel" | "conditional";
orderIndex: number;
durationEstimate?: number;
required: boolean;
conditions: Record<string, unknown>;
actions: ConvertedAction[];
}
export interface ConvertedAction {
name: string;
description?: string;
type: string;
orderIndex: number;
parameters: Record<string, unknown>;
timeout?: number;
// Provenance & execution metadata (flattened for now)
pluginId?: string;
pluginVersion?: string;
robotId?: string | null;
baseActionId?: string;
transport?: ExecutionDescriptor["transport"];
ros2?: ExecutionDescriptor["ros2"];
rest?: ExecutionDescriptor["rest"];
retryable?: boolean;
parameterSchemaRaw?: unknown;
sourceKind?: "core" | "plugin";
category?: string;
}
// Deprecated legacy compatibility function removed to eliminate unsafe any usage.
// If needed, implement a proper migration path elsewhere.
/**
* Convert a single experiment action into a ConvertedAction (DB shape),
* preserving provenance and execution metadata for reproducibility.
*/
export function convertActionToDatabase(
action: ExperimentAction,
orderIndex: number,
): ConvertedAction {
return {
name: action.name,
description: `${action.type} action`,
type: action.type,
orderIndex,
parameters: action.parameters,
timeout: estimateActionTimeout(action),
pluginId: action.source.pluginId,
pluginVersion: action.source.pluginVersion,
robotId: action.source.robotId,
baseActionId: action.source.baseActionId,
transport: action.execution.transport,
ros2: action.execution.ros2,
rest: action.execution.rest,
retryable: action.execution.retryable,
parameterSchemaRaw: action.parameterSchemaRaw,
sourceKind: action.source.kind,
category: action.category,
};
}

View File

@@ -0,0 +1,314 @@
/**
* Execution Compiler Utilities
*
* Purpose:
* - Produce a deterministic execution graph snapshot from the visual design
* - Generate an integrity hash capturing provenance & structural identity
* - Extract normalized plugin dependency list (pluginId@version)
*
* These utilities are used on the server prior to saving an experiment so that
* trial execution can rely on an immutable compiled artifact. This helps ensure
* reproducibility by decoupling future plugin updates from already designed
* experiment protocols.
*
* NOTE:
* - This module intentionally performs only pure / synchronous operations.
* - Any plugin resolution or database queries should happen in a higher layer
* before invoking these functions.
*/
import type {
ExperimentDesign,
ExperimentStep,
ExperimentAction,
ExecutionDescriptor,
} from "./types";
/* ---------- Public Types ---------- */
export interface CompiledExecutionGraph {
version: number;
generatedAt: string; // ISO timestamp
steps: CompiledExecutionStep[];
pluginDependencies: string[];
hash: string;
}
export interface CompiledExecutionStep {
id: string;
name: string;
order: number;
type: ExperimentStep["type"];
trigger: {
type: string;
conditions: Record<string, unknown>;
};
actions: CompiledExecutionAction[];
estimatedDuration?: number;
}
export interface CompiledExecutionAction {
id: string;
name: string;
type: string;
category: string;
provenance: {
sourceKind: "core" | "plugin";
pluginId?: string;
pluginVersion?: string;
robotId?: string | null;
baseActionId?: string;
};
execution: ExecutionDescriptor;
parameters: Record<string, unknown>;
parameterSchemaRaw?: unknown;
timeout?: number;
retryable?: boolean;
}
/* ---------- Compile Entry Point ---------- */
/**
* Compile an ExperimentDesign into a reproducible execution graph + hash.
*/
export function compileExecutionDesign(
design: ExperimentDesign,
opts: { hashAlgorithm?: "sha256" | "sha1" } = {},
): CompiledExecutionGraph {
const pluginDependencies = collectPluginDependencies(design);
const compiledSteps: CompiledExecutionStep[] = design.steps
.slice()
.sort((a, b) => a.order - b.order)
.map((step) => compileStep(step));
const structuralSignature = buildStructuralSignature(
design,
compiledSteps,
pluginDependencies,
);
const hash = stableHash(structuralSignature, opts.hashAlgorithm ?? "sha256");
return {
version: 1,
generatedAt: new Date().toISOString(),
steps: compiledSteps,
pluginDependencies,
hash,
};
}
/* ---------- Step / Action Compilation ---------- */
function compileStep(step: ExperimentStep): CompiledExecutionStep {
const compiledActions: CompiledExecutionAction[] = step.actions.map(
(action, index) => compileAction(action, index),
);
return {
id: step.id,
name: step.name,
order: step.order,
type: step.type,
trigger: {
type: step.trigger.type,
conditions: step.trigger.conditions ?? {},
},
actions: compiledActions,
estimatedDuration: step.estimatedDuration,
};
}
function compileAction(
action: ExperimentAction,
_index: number, // index currently unused (reserved for future ordering diagnostics)
): CompiledExecutionAction {
return {
id: action.id,
name: action.name,
type: action.type,
category: action.category,
provenance: {
sourceKind: action.source.kind,
pluginId: action.source.pluginId,
pluginVersion: action.source.pluginVersion,
robotId: action.source.robotId,
baseActionId: action.source.baseActionId,
},
execution: action.execution,
parameters: action.parameters,
parameterSchemaRaw: action.parameterSchemaRaw,
timeout: action.execution.timeoutMs,
retryable: action.execution.retryable,
};
}
/* ---------- Plugin Dependency Extraction ---------- */
export function collectPluginDependencies(design: ExperimentDesign): string[] {
const set = new Set<string>();
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}`);
}
}
}
return Array.from(set).sort();
}
/* ---------- Integrity Hash Generation ---------- */
/**
* Build a minimal, deterministic JSON-serializable representation capturing:
* - Step ordering, ids, types, triggers
* - Action ordering, ids, types, provenance, execution transport, parameters (keys only for hash)
* - Plugin dependency list
*
* Parameter values are not fully included (only key presence) to avoid hash churn
* on mutable text fields while preserving structural identity. If full parameter
* value hashing is desired, adjust `summarizeParametersForHash`.
*/
function buildStructuralSignature(
design: ExperimentDesign,
steps: CompiledExecutionStep[],
pluginDependencies: string[],
): unknown {
return {
experimentId: design.id,
version: design.version,
steps: steps.map((s) => ({
id: s.id,
order: s.order,
type: s.type,
trigger: {
type: s.trigger.type,
// Include condition keys only for stability
conditionKeys: Object.keys(s.trigger.conditions).sort(),
},
actions: s.actions.map((a) => ({
id: a.id,
type: a.type,
category: a.category,
provenance: a.provenance,
transport: a.execution.transport,
timeout: a.timeout,
retryable: a.retryable ?? false,
parameterKeys: summarizeParametersForHash(a.parameters),
})),
})),
pluginDependencies,
};
}
function summarizeParametersForHash(params: Record<string, unknown>): string[] {
return Object.keys(params).sort();
}
/* ---------- Stable Hash Implementation ---------- */
/**
* Simple stable hash using built-in Web Crypto if available; falls back
* to a lightweight JS implementation (FNV-1a) for environments without
* crypto.subtle (e.g. some test runners).
*
* This is synchronous; if crypto.subtle is present it still uses
* a synchronous wrapper by blocking on the Promise with deasync style
* simulation (not implemented) so we default to FNV-1a here for portability.
*/
function stableHash(value: unknown, algorithm: "sha256" | "sha1"): string {
// Use a deterministic JSON stringify
const json = JSON.stringify(value);
// FNV-1a 64-bit (represented as hex)
let hashHigh = 0xcbf29ce4;
let hashLow = 0x84222325; // Split 64-bit for simple JS accumulation
for (let i = 0; i < json.length; i++) {
const c = json.charCodeAt(i);
// XOR low part
hashLow ^= c;
// 64-bit FNV prime: 1099511628211 -> split multiply
// (hash * prime) mod 2^64
// Multiply low
let low =
(hashLow & 0xffff) * 0x1b3 +
(((hashLow >>> 16) * 0x1b3) & 0xffff) * 0x10000;
// Include high
low +=
((hashHigh & 0xffff) * 0x1b3 +
(((hashHigh >>> 16) * 0x1b3) & 0xffff) * 0x10000) &
0xffffffff;
// Rotate values (approximate 64-bit handling)
hashHigh ^= low >>> 13;
hashHigh &= 0xffffffff;
hashLow = low & 0xffffffff;
}
// Combine into hex; algorithm param reserved for future (differing strategies)
const highHex = (hashHigh >>> 0).toString(16).padStart(8, "0");
const lowHex = (hashLow >>> 0).toString(16).padStart(8, "0");
return `${algorithm}-${highHex}${lowHex}`;
}
/* ---------- Validation Helpers (Optional Use) ---------- */
/**
* Lightweight structural sanity checks prior to compilation.
* Returns array of issues; empty array means pass.
*/
export function validateDesignStructure(design: ExperimentDesign): string[] {
const issues: string[] = [];
if (!design.steps.length) {
issues.push("No steps defined");
}
const seenStepIds = new Set<string>();
for (const step of design.steps) {
if (seenStepIds.has(step.id)) {
issues.push(`Duplicate step id: ${step.id}`);
} else {
seenStepIds.add(step.id);
}
if (!step.actions.length) {
issues.push(`Step "${step.name}" has no actions`);
}
const seenActionIds = new Set<string>();
for (const action of step.actions) {
if (seenActionIds.has(action.id)) {
issues.push(`Duplicate action id in step "${step.name}": ${action.id}`);
} else {
seenActionIds.add(action.id);
}
if (!action.type) {
issues.push(`Action "${action.id}" missing type`);
}
if (!action.source?.kind) {
issues.push(`Action "${action.id}" missing provenance`);
}
if (!action.execution?.transport) {
issues.push(`Action "${action.id}" missing execution transport`);
}
}
}
return issues;
}
/**
* High-level convenience wrapper: validate + compile; throws on issues.
*/
export function validateAndCompile(
design: ExperimentDesign,
): CompiledExecutionGraph {
const issues = validateDesignStructure(design);
if (issues.length) {
const error = new Error(
`Design validation failed:\n- ${issues.join("\n- ")}`,
);
(error as { issues?: string[] }).issues = issues;
throw error;
}
return compileExecutionDesign(design);
}

View File

@@ -0,0 +1,166 @@
// Core experiment designer types
export type StepType = "sequential" | "parallel" | "conditional" | "loop";
export type ActionCategory = "wizard" | "robot" | "observation" | "control";
export type ActionType =
| "wizard_speak"
| "wizard_gesture"
| "robot_move"
| "robot_speak"
| "wait"
| "observe"
| "collect_data"
// Namespaced plugin action types will use pattern: pluginId.actionId
| (string & {});
export type TriggerType =
| "trial_start"
| "participant_action"
| "timer"
| "previous_step";
export interface ActionParameter {
id: string;
name: string;
type: "text" | "number" | "select" | "boolean";
placeholder?: string;
options?: string[];
min?: number;
max?: number;
value?: string | number | boolean;
required?: boolean;
description?: string;
step?: number; // numeric increment if relevant
}
export interface ActionDefinition {
id: string;
type: ActionType;
name: string;
description: string;
category: ActionCategory;
icon: string;
color: string;
parameters: ActionParameter[];
source: {
kind: "core" | "plugin";
pluginId?: string;
pluginVersion?: string;
robotId?: string | null;
baseActionId?: string; // original internal action id inside plugin repo
};
execution?: ExecutionDescriptor;
parameterSchemaRaw?: unknown; // snapshot of original schema for validation/audit
}
export interface ExperimentAction {
id: string;
type: ActionType;
name: string;
parameters: Record<string, unknown>;
duration?: number;
category: ActionCategory;
source: {
kind: "core" | "plugin";
pluginId?: string;
pluginVersion?: string;
robotId?: string | null;
baseActionId?: string;
};
execution: ExecutionDescriptor;
parameterSchemaRaw?: unknown;
}
export interface StepTrigger {
type: TriggerType;
conditions: Record<string, unknown>;
}
export interface ExperimentStep {
id: string;
name: string;
description?: string;
type: StepType;
order: number;
trigger: StepTrigger;
actions: ExperimentAction[];
estimatedDuration?: number;
expanded: boolean;
}
export interface ExperimentDesign {
id: string;
name: string;
description: string;
steps: ExperimentStep[];
version: number;
lastSaved: Date;
compiledAt?: Date; // when an execution plan was compiled
integrityHash?: string; // hash of action definitions for reproducibility
}
// Trigger options for UI
export const TRIGGER_OPTIONS = [
{ value: "trial_start" as const, label: "Trial starts" },
{ value: "participant_action" as const, label: "Participant acts" },
{ value: "timer" as const, label: "After timer" },
{ value: "previous_step" as const, label: "Previous step completes" },
];
// Step type options for UI
export const STEP_TYPE_OPTIONS = [
{
value: "sequential" as const,
label: "Sequential",
description: "Actions run one after another",
},
{
value: "parallel" as const,
label: "Parallel",
description: "Actions run at the same time",
},
{
value: "conditional" as const,
label: "Conditional",
description: "Actions run if condition is met",
},
{
value: "loop" as const,
label: "Loop",
description: "Actions repeat multiple times",
},
];
// Execution descriptors (appended)
export interface ExecutionDescriptor {
transport: "ros2" | "rest" | "internal";
timeoutMs?: number;
retryable?: boolean;
ros2?: Ros2Execution;
rest?: RestExecution;
}
export interface Ros2Execution {
topic?: string;
messageType?: string;
service?: string;
action?: string;
qos?: {
reliability?: string;
durability?: string;
history?: string;
depth?: number;
};
payloadMapping?: unknown; // mapping definition retained for transform at runtime
}
export interface RestExecution {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
path: string;
headers?: Record<string, string>;
query?: Record<string, string | number | boolean>;
bodyTemplate?: unknown;
}

View File

@@ -0,0 +1,326 @@
import { z } from "zod";
import type {
ExperimentStep,
ExperimentAction,
StepType,
TriggerType,
ActionCategory,
ExecutionDescriptor,
} from "./types";
/**
* Visual Design Guard
*
* Provides a robust Zod-based parsing/normalization pipeline that:
* - Accepts a loosely-typed visualDesign.steps payload coming from the client
* - Normalizes and validates it into strongly typed arrays for internal processing
* - Strips unknown fields (preserves only what we rely on)
* - Ensures provenance + execution descriptors are structurally sound
*
* This replaces ad-hoc runtime filtering in the experiments update mutation.
*
* Usage:
* const { steps, issues } = parseVisualDesignSteps(rawSteps);
* if (issues.length) -> reject request
* else -> steps (ExperimentStep[]) is now safe for conversion & compilation
*/
// Enumerations (reuse domain model semantics without hard binding to future expansions)
const stepTypeEnum = z.enum(["sequential", "parallel", "conditional", "loop"]);
const triggerTypeEnum = z.enum([
"trial_start",
"participant_action",
"timer",
"previous_step",
]);
const actionCategoryEnum = z.enum([
"wizard",
"robot",
"observation",
"control",
]);
// Provenance
const actionSourceSchema = z
.object({
kind: z.enum(["core", "plugin"]),
pluginId: z.string().min(1).optional(),
pluginVersion: z.string().min(1).optional(),
robotId: z.string().min(1).nullable().optional(),
baseActionId: z.string().min(1).optional(),
})
.strict();
// Execution descriptor
const executionDescriptorSchema = z
.object({
transport: z.enum(["ros2", "rest", "internal"]),
timeoutMs: z.number().int().positive().optional(),
retryable: z.boolean().optional(),
ros2: z
.object({
topic: z.string().min(1).optional(),
messageType: z.string().min(1).optional(),
service: z.string().min(1).optional(),
action: z.string().min(1).optional(),
qos: z
.object({
reliability: z.string().optional(),
durability: z.string().optional(),
history: z.string().optional(),
depth: z.number().int().optional(),
})
.strict()
.optional(),
payloadMapping: z.unknown().optional(),
})
.strict()
.optional(),
rest: z
.object({
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
path: z.string().min(1),
headers: z.record(z.string(), z.string()).optional(),
})
.strict()
.optional(),
})
.strict();
// 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,
})
.strict();
// Trigger schema
const triggerSchema = z
.object({
type: triggerTypeEnum,
conditions: z.record(z.string(), z.unknown()).default({}),
})
.strict();
// Step schema
const visualStepInputSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
type: stepTypeEnum,
order: z.number().int().nonnegative().optional(),
trigger: triggerSchema.optional(),
actions: z.array(visualActionInputSchema),
expanded: z.boolean().optional(),
})
.strict();
// Array schema root
const visualDesignStepsSchema = z.array(visualStepInputSchema);
/**
* Parse & normalize raw steps payload.
*/
export function parseVisualDesignSteps(raw: unknown): {
steps: ExperimentStep[];
issues: string[];
} {
const issues: string[] = [];
const parsed = visualDesignStepsSchema.safeParse(raw);
if (!parsed.success) {
const zodErr = parsed.error;
issues.push(
...zodErr.issues.map(
(issue) =>
`steps${
issue.path.length ? "." + issue.path.join(".") : ""
}: ${issue.message} (code=${issue.code})`,
),
);
return { steps: [], issues };
}
// 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
? {
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,
}
: 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,
};
});
// Construct step
return {
id: s.id,
name: s.name,
description: s.description,
type: s.type as StepType,
order: typeof s.order === "number" ? s.order : idx,
trigger: {
type: (s.trigger?.type ?? "previous_step") as TriggerType,
conditions: s.trigger?.conditions ?? {},
},
actions,
estimatedDuration: undefined,
expanded: s.expanded ?? true,
};
});
// Basic structural checks
const seenStepIds = new Set<string>();
for (const st of normalized) {
if (seenStepIds.has(st.id)) {
issues.push(`Duplicate step id: ${st.id}`);
}
seenStepIds.add(st.id);
if (!st.actions.length) {
issues.push(`Step "${st.name}" has no actions`);
}
const seenActionIds = new Set<string>();
for (const act of st.actions) {
if (seenActionIds.has(act.id)) {
issues.push(`Duplicate action id in step "${st.name}": ${act.id}`);
}
seenActionIds.add(act.id);
if (!act.source.kind) {
issues.push(`Action "${act.id}" missing source.kind`);
}
if (!act.execution.transport) {
issues.push(`Action "${act.id}" missing execution transport`);
}
}
}
return { steps: normalized, issues };
}
/**
* Estimate aggregate duration (in seconds) from normalized steps.
* Uses simple additive heuristic: sum each step's summed action durations
* if present; falls back to rough defaults for certain action patterns.
*/
export function estimateDesignDurationSeconds(steps: ExperimentStep[]): number {
let total = 0;
for (const step of steps) {
let stepSum = 0;
for (const action of step.actions) {
const t = classifyDuration(action);
stepSum += t;
}
total += stepSum;
}
return Math.max(1, Math.round(total));
}
function classifyDuration(action: ExperimentAction): number {
// Heuristic mapping (could be evolved to plugin-provided estimates)
switch (true) {
case action.type.startsWith("wizard_speak"):
case action.type.startsWith("robot_speak"): {
const text = action.parameters.text as string | undefined;
if (text && text.length > 0) {
return Math.max(2, Math.round(text.length / 10));
}
return 3;
}
case action.type.startsWith("wait"): {
const d = action.parameters.duration as number | undefined;
return d && d > 0 ? d : 2;
}
case action.type.startsWith("robot_move"):
return 5;
case action.type.startsWith("wizard_gesture"):
return 2;
case action.type.startsWith("observe"): {
const d = action.parameters.duration as number | undefined;
return d && d > 0 ? d : 5;
}
default:
return 2;
}
}
/**
* Convenience wrapper: validates, returns steps or throws with issues attached.
*/
export function assertVisualDesignSteps(raw: unknown): ExperimentStep[] {
const { steps, issues } = parseVisualDesignSteps(raw);
if (issues.length) {
const err = new Error(
`Visual design validation failed:\n- ${issues.join("\n- ")}`,
);
(err as { issues?: string[] }).issues = issues;
throw err;
}
return steps;
}

View File

@@ -2,6 +2,7 @@ import { adminRouter } from "~/server/api/routers/admin";
import { analyticsRouter } from "~/server/api/routers/analytics";
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 { mediaRouter } from "~/server/api/routers/media";
import { participantsRouter } from "~/server/api/routers/participants";
@@ -28,6 +29,7 @@ export const appRouter = createTRPCRouter({
analytics: analyticsRouter,
collaboration: collaborationRouter,
admin: adminRouter,
dashboard: dashboardRouter,
});
// export type definition of API

View File

@@ -0,0 +1,312 @@
import { and, count, desc, eq, gte, inArray, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import {
activityLogs,
experiments,
participants,
studies,
studyMembers,
trials,
users,
userSystemRoles,
} from "~/server/db/schema";
export const dashboardRouter = createTRPCRouter({
getRecentActivity: protectedProcedure
.input(
z.object({
limit: z.number().min(1).max(20).default(10),
studyId: z.string().uuid().optional(),
}),
)
.query(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
// Get studies the user has access to
const accessibleStudies = await ctx.db
.select({ studyId: studyMembers.studyId })
.from(studyMembers)
.where(eq(studyMembers.userId, userId));
const studyIds = accessibleStudies.map((s) => s.studyId);
// If no accessible studies, return empty
if (studyIds.length === 0) {
return [];
}
// Build where conditions
const whereConditions = input.studyId
? eq(activityLogs.studyId, input.studyId)
: inArray(activityLogs.studyId, studyIds);
// Get recent activity logs
const activities = await ctx.db
.select({
id: activityLogs.id,
action: activityLogs.action,
description: activityLogs.description,
createdAt: activityLogs.createdAt,
user: {
name: users.name,
email: users.email,
},
study: {
name: studies.name,
},
})
.from(activityLogs)
.innerJoin(users, eq(activityLogs.userId, users.id))
.innerJoin(studies, eq(activityLogs.studyId, studies.id))
.where(whereConditions)
.orderBy(desc(activityLogs.createdAt))
.limit(input.limit);
return activities.map((activity) => ({
id: activity.id,
type: activity.action,
title: activity.description,
description: `${activity.study.name} - ${activity.user.name}`,
time: activity.createdAt,
status: "info" as const,
}));
}),
getStudyProgress: protectedProcedure
.input(
z.object({
limit: z.number().min(1).max(10).default(5),
}),
)
.query(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
// Get studies the user has access to with participant counts
const studyProgress = await ctx.db
.select({
id: studies.id,
name: studies.name,
status: studies.status,
createdAt: studies.createdAt,
totalParticipants: count(participants.id),
})
.from(studies)
.innerJoin(studyMembers, eq(studies.id, studyMembers.studyId))
.leftJoin(participants, eq(studies.id, participants.studyId))
.where(
and(eq(studyMembers.userId, userId), eq(studies.status, "active")),
)
.groupBy(studies.id, studies.name, studies.status, studies.createdAt)
.orderBy(desc(studies.createdAt))
.limit(input.limit);
// Get trial completion counts for each study
const studyIds = studyProgress.map((s) => s.id);
const trialCounts =
studyIds.length > 0
? await ctx.db
.select({
studyId: experiments.studyId,
completedTrials: count(trials.id),
})
.from(experiments)
.innerJoin(trials, eq(experiments.id, trials.experimentId))
.where(
and(
inArray(experiments.studyId, studyIds),
eq(trials.status, "completed"),
),
)
.groupBy(experiments.studyId)
: [];
const trialCountMap = new Map(
trialCounts.map((tc) => [tc.studyId, tc.completedTrials]),
);
return studyProgress.map((study) => {
const completedTrials = trialCountMap.get(study.id) ?? 0;
const totalParticipants = study.totalParticipants;
// Calculate progress based on completed trials vs participants
// If no participants, progress is 0; if trials >= participants, progress is 100%
const progress =
totalParticipants > 0
? Math.min(
100,
Math.round((completedTrials / totalParticipants) * 100),
)
: 0;
return {
id: study.id,
name: study.name,
progress,
participants: completedTrials, // Using completed trials as active participants
totalParticipants,
status: study.status,
};
});
}),
getStats: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;
// Get studies the user has access to
const accessibleStudies = await ctx.db
.select({ studyId: studyMembers.studyId })
.from(studyMembers)
.where(eq(studyMembers.userId, userId));
const studyIds = accessibleStudies.map((s) => s.studyId);
if (studyIds.length === 0) {
return {
totalStudies: 0,
totalExperiments: 0,
totalParticipants: 0,
totalTrials: 0,
activeTrials: 0,
scheduledTrials: 0,
completedToday: 0,
};
}
// Get total counts
const [studyCount] = await ctx.db
.select({ count: count() })
.from(studies)
.where(inArray(studies.id, studyIds));
const [experimentCount] = await ctx.db
.select({ count: count() })
.from(experiments)
.where(inArray(experiments.studyId, studyIds));
const [participantCount] = await ctx.db
.select({ count: count() })
.from(participants)
.where(inArray(participants.studyId, studyIds));
const [trialCount] = await ctx.db
.select({ count: count() })
.from(trials)
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
.where(inArray(experiments.studyId, studyIds));
// Get active trials count
const [activeTrialsCount] = await ctx.db
.select({ count: count() })
.from(trials)
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
.where(
and(
inArray(experiments.studyId, studyIds),
eq(trials.status, "in_progress"),
),
);
// Get scheduled trials count
const [scheduledTrialsCount] = await ctx.db
.select({ count: count() })
.from(trials)
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
.where(
and(
inArray(experiments.studyId, studyIds),
eq(trials.status, "scheduled"),
),
);
// Get today's completed trials
const today = new Date();
today.setHours(0, 0, 0, 0);
const [completedTodayCount] = await ctx.db
.select({ count: count() })
.from(trials)
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
.where(
and(
inArray(experiments.studyId, studyIds),
eq(trials.status, "completed"),
gte(trials.completedAt, today),
),
);
return {
totalStudies: studyCount?.count ?? 0,
totalExperiments: experimentCount?.count ?? 0,
totalParticipants: participantCount?.count ?? 0,
totalTrials: trialCount?.count ?? 0,
activeTrials: activeTrialsCount?.count ?? 0,
scheduledTrials: scheduledTrialsCount?.count ?? 0,
completedToday: completedTodayCount?.count ?? 0,
};
}),
debug: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;
// Get user info
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, userId),
});
// Get user system roles
const systemRoles = await ctx.db.query.userSystemRoles.findMany({
where: eq(userSystemRoles.userId, userId),
});
// Get user study memberships
const studyMemberships = await ctx.db.query.studyMembers.findMany({
where: eq(studyMembers.userId, userId),
with: {
study: {
columns: {
id: true,
name: true,
status: true,
},
},
},
});
// Get all studies (admin view)
const allStudies = await ctx.db.query.studies.findMany({
columns: {
id: true,
name: true,
status: true,
createdBy: true,
},
where: sql`deleted_at IS NULL`,
limit: 10,
});
return {
user: user
? {
id: user.id,
email: user.email,
name: user.name,
}
: null,
systemRoles: systemRoles.map((r) => r.role),
studyMemberships: studyMemberships.map((m) => ({
studyId: m.studyId,
role: m.role,
study: m.study,
})),
allStudies,
session: {
userId: ctx.session.user.id,
userEmail: ctx.session.user.email,
userRole: ctx.session.user.roles?.[0]?.role ?? null,
},
};
}),
});

View File

@@ -468,6 +468,7 @@ export const robotsRouter = createTRPCRouter({
repositoryUrl: plugins.repositoryUrl,
trustLevel: plugins.trustLevel,
status: plugins.status,
actionDefinitions: plugins.actionDefinitions,
createdAt: plugins.createdAt,
updatedAt: plugins.updatedAt,
},

View File

@@ -4,8 +4,15 @@ import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import {
activityLogs, studies, studyMemberRoleEnum, studyMembers,
studyStatusEnum, users, userSystemRoles
activityLogs,
plugins,
studies,
studyMemberRoleEnum,
studyMembers,
studyPlugins,
studyStatusEnum,
users,
userSystemRoles,
} from "~/server/db/schema";
export const studiesRouter = createTRPCRouter({
@@ -274,6 +281,20 @@ export const studiesRouter = createTRPCRouter({
role: "owner",
});
// Auto-install core plugin in new study
const corePlugin = await ctx.db.query.plugins.findFirst({
where: eq(plugins.name, "HRIStudio Core System"),
});
if (corePlugin) {
await ctx.db.insert(studyPlugins).values({
studyId: newStudy.id,
pluginId: corePlugin.id,
configuration: {},
installedBy: userId,
});
}
// Log activity
await ctx.db.insert(activityLogs).values({
studyId: newStudy.id,
@@ -534,7 +555,7 @@ export const studiesRouter = createTRPCRouter({
studyId,
userId,
action: "member_removed",
description: `Removed ${memberToRemove.user?.name ?? memberToRemove.user?.email ?? 'Unknown user'}`,
description: `Removed ${memberToRemove.user?.name ?? memberToRemove.user?.email ?? "Unknown user"}`,
});
return { success: true };

View File

@@ -393,6 +393,7 @@ export const experiments = createTable(
visualDesign: jsonb("visual_design"),
executionGraph: jsonb("execution_graph"),
pluginDependencies: text("plugin_dependencies").array(),
integrityHash: varchar("integrity_hash", { length: 128 }),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
},
(table) => ({
@@ -496,12 +497,24 @@ export const actions = createTable(
.references(() => steps.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
type: varchar("type", { length: 100 }).notNull(), // e.g., 'speak', 'move', 'wait', 'collect_data'
type: varchar("type", { length: 100 }).notNull(), // e.g., 'speak', 'move', 'wait', 'collect_data' or pluginId.actionId
orderIndex: integer("order_index").notNull(),
parameters: jsonb("parameters").default({}),
validationSchema: jsonb("validation_schema"),
timeout: integer("timeout"), // in seconds
retryCount: integer("retry_count").default(0).notNull(),
// Provenance & execution metadata
sourceKind: varchar("source_kind", { length: 20 }), // 'core' | 'plugin'
pluginId: varchar("plugin_id", { length: 255 }),
pluginVersion: varchar("plugin_version", { length: 50 }),
robotId: varchar("robot_id", { length: 255 }),
baseActionId: varchar("base_action_id", { length: 255 }),
category: varchar("category", { length: 50 }),
transport: varchar("transport", { length: 20 }), // 'ros2' | 'rest' | 'internal'
ros2: jsonb("ros2_config"),
rest: jsonb("rest_config"),
retryable: boolean("retryable"),
parameterSchemaRaw: jsonb("parameter_schema_raw"),
createdAt: timestamp("created_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),