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

@@ -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 };