mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-02-05 07:56:30 -05:00
Pre-conf work 2025
This commit is contained in:
@@ -16,7 +16,10 @@ interface AdminContentProps {
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
export function AdminContent({ userName, userEmail }: AdminContentProps) {
|
||||
export function AdminContent({
|
||||
userName,
|
||||
userEmail: _userEmail,
|
||||
}: AdminContentProps) {
|
||||
const quickActions = [
|
||||
{
|
||||
title: "Manage Users",
|
||||
@@ -27,9 +30,17 @@ export function AdminContent({ userName, userEmail }: AdminContentProps) {
|
||||
},
|
||||
];
|
||||
|
||||
const stats: any[] = [];
|
||||
const stats: Array<{
|
||||
title: string;
|
||||
value: string | number;
|
||||
description?: string;
|
||||
}> = [];
|
||||
|
||||
const alerts: any[] = [];
|
||||
const alerts: Array<{
|
||||
type: "info" | "warning" | "error";
|
||||
title: string;
|
||||
message: string;
|
||||
}> = [];
|
||||
|
||||
const recentActivity = (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Calendar, FlaskConical, Plus, Settings, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { api } from "~/trpc/react";
|
||||
@@ -173,8 +173,6 @@ function ExperimentCard({ experiment }: ExperimentCardProps) {
|
||||
}
|
||||
|
||||
export function ExperimentsGrid() {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const {
|
||||
data: experimentsData,
|
||||
isLoading,
|
||||
@@ -189,11 +187,6 @@ export function ExperimentsGrid() {
|
||||
|
||||
const experiments = experimentsData?.experiments ?? [];
|
||||
|
||||
const handleExperimentCreated = () => {
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
void refetch();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -295,10 +288,10 @@ export function ExperimentsGrid() {
|
||||
Failed to Load Experiments
|
||||
</h3>
|
||||
<p className="mb-4 text-slate-600">
|
||||
{error.message ||
|
||||
{error?.message ??
|
||||
"An error occurred while loading your experiments."}
|
||||
</p>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
<Button onClick={() => void refetch()} variant="outline">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
@@ -320,52 +313,54 @@ export function ExperimentsGrid() {
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Create New Experiment Card */}
|
||||
<Card className="border-2 border-dashed border-slate-300 transition-colors hover:border-slate-400">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-blue-100">
|
||||
<Plus className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
<CardTitle>Create New Experiment</CardTitle>
|
||||
<CardDescription>Design a new experimental protocol</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/experiments/new">Create Experiment</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Experiments */}
|
||||
{experiments.map((experiment) => (
|
||||
<ExperimentCard key={experiment.id} experiment={experiment} />
|
||||
))}
|
||||
|
||||
{/* Empty State */}
|
||||
{experiments.length === 0 && (
|
||||
<Card className="md:col-span-2 lg:col-span-2">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-lg bg-slate-100">
|
||||
<FlaskConical className="h-12 w-12 text-slate-400" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-slate-900">
|
||||
No Experiments Yet
|
||||
</h3>
|
||||
<p className="mb-4 text-slate-600">
|
||||
Create your first experiment to start designing HRI protocols.
|
||||
Experiments define the structure and flow of your research
|
||||
trials.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/experiments/new">
|
||||
Create Your First Experiment
|
||||
</Link>
|
||||
</Button>
|
||||
{/* Create New Experiment Card */}
|
||||
<Card className="border-2 border-dashed border-slate-300 transition-colors hover:border-slate-400">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-blue-100">
|
||||
<Plus className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
<CardTitle>Create New Experiment</CardTitle>
|
||||
<CardDescription>
|
||||
Design a new experimental protocol
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/experiments/new">Create Experiment</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Experiments */}
|
||||
{experiments.map((experiment) => (
|
||||
<ExperimentCard key={experiment.id} experiment={experiment} />
|
||||
))}
|
||||
|
||||
{/* Empty State */}
|
||||
{experiments.length === 0 && (
|
||||
<Card className="md:col-span-2 lg:col-span-2">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-lg bg-slate-100">
|
||||
<FlaskConical className="h-12 w-12 text-slate-400" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-slate-900">
|
||||
No Experiments Yet
|
||||
</h3>
|
||||
<p className="mb-4 text-slate-600">
|
||||
Create your first experiment to start designing HRI protocols.
|
||||
Experiments define the structure and flow of your research
|
||||
trials.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/experiments/new">
|
||||
Create Your First Experiment
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useActionRegistry } from "./ActionRegistry";
|
||||
import type { ActionDefinition } from "~/lib/experiment-designer/types";
|
||||
import {
|
||||
Plus,
|
||||
User,
|
||||
Bot,
|
||||
GitBranch,
|
||||
Eye,
|
||||
GripVertical,
|
||||
Zap,
|
||||
MessageSquare,
|
||||
Hand,
|
||||
Navigation,
|
||||
Volume2,
|
||||
Clock,
|
||||
Timer,
|
||||
MousePointer,
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
} from "lucide-react";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
|
||||
// Local icon map (duplicated minimal map for isolation to avoid circular imports)
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
MessageSquare,
|
||||
Hand,
|
||||
Navigation,
|
||||
Volume2,
|
||||
Clock,
|
||||
Eye,
|
||||
Bot,
|
||||
User,
|
||||
Zap,
|
||||
Timer,
|
||||
MousePointer,
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
};
|
||||
|
||||
interface DraggableActionProps {
|
||||
action: ActionDefinition;
|
||||
}
|
||||
|
||||
function DraggableAction({ action }: DraggableActionProps) {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useDraggable({
|
||||
id: `action-${action.id}`,
|
||||
data: { action },
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: transform
|
||||
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const IconComponent = iconMap[action.icon] ?? Zap;
|
||||
|
||||
const categoryColors: Record<ActionDefinition["category"], string> = {
|
||||
wizard: "bg-blue-500",
|
||||
robot: "bg-emerald-500",
|
||||
control: "bg-amber-500",
|
||||
observation: "bg-purple-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={cn(
|
||||
"group hover:bg-accent/50 relative flex cursor-grab items-center gap-2 rounded-md border p-2 text-xs transition-colors",
|
||||
isDragging && "opacity-50",
|
||||
)}
|
||||
onMouseEnter={() => setShowTooltip(true)}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
draggable={false}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-white",
|
||||
categoryColors[action.category],
|
||||
)}
|
||||
>
|
||||
<IconComponent className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1 truncate font-medium">
|
||||
{action.source.kind === "plugin" ? (
|
||||
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-emerald-600 text-[8px] font-bold text-white">
|
||||
P
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-slate-500 text-[8px] font-bold text-white">
|
||||
C
|
||||
</span>
|
||||
)}
|
||||
{action.name}
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate text-xs">
|
||||
{action.description ?? ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</div>
|
||||
|
||||
{showTooltip && (
|
||||
<div className="bg-popover absolute top-0 left-full z-50 ml-2 max-w-xs rounded-md border p-2 text-xs shadow-md">
|
||||
<div className="font-medium">{action.name}</div>
|
||||
<div className="text-muted-foreground">{action.description}</div>
|
||||
<div className="mt-1 text-xs opacity-75">
|
||||
Category: {action.category} • ID: {action.id}
|
||||
</div>
|
||||
{action.parameters.length > 0 && (
|
||||
<div className="mt-1 text-xs opacity-75">
|
||||
Parameters: {action.parameters.map((p) => p.name).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ActionLibraryProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ActionLibrary({ className }: ActionLibraryProps) {
|
||||
const registry = useActionRegistry();
|
||||
const [activeCategory, setActiveCategory] =
|
||||
useState<ActionDefinition["category"]>("wizard");
|
||||
|
||||
const categories: Array<{
|
||||
key: ActionDefinition["category"];
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
}> = [
|
||||
{
|
||||
key: "wizard",
|
||||
label: "Wizard",
|
||||
icon: User,
|
||||
color: "bg-blue-500",
|
||||
},
|
||||
{
|
||||
key: "robot",
|
||||
label: "Robot",
|
||||
icon: Bot,
|
||||
color: "bg-emerald-500",
|
||||
},
|
||||
{
|
||||
key: "control",
|
||||
label: "Control",
|
||||
icon: GitBranch,
|
||||
color: "bg-amber-500",
|
||||
},
|
||||
{
|
||||
key: "observation",
|
||||
label: "Observe",
|
||||
icon: Eye,
|
||||
color: "bg-purple-500",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
{/* Category tabs */}
|
||||
<div className="border-b p-2">
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{categories.map((category) => {
|
||||
const IconComponent = category.icon;
|
||||
const isActive = activeCategory === category.key;
|
||||
return (
|
||||
<Button
|
||||
key={category.key}
|
||||
variant={isActive ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 justify-start text-xs",
|
||||
isActive && `${category.color} text-white hover:opacity-90`,
|
||||
)}
|
||||
onClick={() => setActiveCategory(category.key)}
|
||||
>
|
||||
<IconComponent className="mr-1 h-3 w-3" />
|
||||
{category.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions list */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-1 p-2">
|
||||
{registry.getActionsByCategory(activeCategory).length === 0 ? (
|
||||
<div className="text-muted-foreground py-8 text-center">
|
||||
<div className="bg-muted mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full">
|
||||
<Plus className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-sm">No actions available</p>
|
||||
<p className="text-xs">Check plugin configuration</p>
|
||||
</div>
|
||||
) : (
|
||||
registry
|
||||
.getActionsByCategory(activeCategory)
|
||||
.map((action) => (
|
||||
<DraggableAction key={action.id} action={action} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="border-t p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{registry.getAllActions().length} total
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{registry.getActionsByCategory(activeCategory).length} in view
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Debug info */}
|
||||
<div className="text-muted-foreground mt-1 text-[9px]">
|
||||
W:{registry.getActionsByCategory("wizard").length} R:
|
||||
{registry.getActionsByCategory("robot").length} C:
|
||||
{registry.getActionsByCategory("control").length} O:
|
||||
{registry.getActionsByCategory("observation").length}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-[9px]">
|
||||
Core loaded: {registry.getDebugInfo().coreActionsLoaded ? "✓" : "✗"}
|
||||
Plugins loaded:{" "}
|
||||
{registry.getDebugInfo().pluginActionsLoaded ? "✓" : "✗"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,677 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* BlockDesigner is being phased out in favor of DesignerShell (see DesignerShell.tsx).
|
||||
* TODO: Remove this file after full migration of add/update/delete handlers, hashing,
|
||||
* validation, drift detection, and export logic to the new architecture.
|
||||
*/
|
||||
|
||||
/**
|
||||
* BlockDesigner (Modular Refactor)
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Own overall experiment design state (steps + actions)
|
||||
* - Coordinate drag & drop between ActionLibrary (source) and StepFlow (targets)
|
||||
* - Persist design via experiments.update mutation (optionally compiling execution graph)
|
||||
* - Trigger server-side validation (experiments.validateDesign) to obtain integrity hash
|
||||
* - Track & surface "hash drift" (design changed since last validation or mismatch with stored integrityHash)
|
||||
*
|
||||
* Extracted Modules:
|
||||
* - ActionRegistry -> ./ActionRegistry.ts
|
||||
* - ActionLibrary -> ./ActionLibrary.tsx
|
||||
* - StepFlow -> ./StepFlow.tsx
|
||||
* - PropertiesPanel -> ./PropertiesPanel.tsx
|
||||
*
|
||||
* Enhancements Added Here:
|
||||
* - Hash drift indicator logic (Validated / Drift / Unvalidated)
|
||||
* - Modular wiring replacing previous monolithic file
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { Save, Download, Play, Plus } from "lucide-react";
|
||||
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { PageHeader, ActionButton } from "~/components/ui/page-header";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
|
||||
import {
|
||||
type ExperimentDesign,
|
||||
type ExperimentStep,
|
||||
type ExperimentAction,
|
||||
type ActionDefinition,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { ActionLibrary } from "./ActionLibrary";
|
||||
import { StepFlow } from "./StepFlow";
|
||||
import { PropertiesPanel } from "./PropertiesPanel";
|
||||
import { actionRegistry } from "./ActionRegistry";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Utilities */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Build a lightweight JSON string representing the current design for drift checks.
|
||||
* We include full steps & actions; param value churn will intentionally flag drift
|
||||
* (acceptable trade-off for now; can switch to structural signature if too noisy).
|
||||
*/
|
||||
function serializeDesignSteps(steps: ExperimentStep[]): string {
|
||||
return JSON.stringify(
|
||||
steps.map((s) => ({
|
||||
id: s.id,
|
||||
order: s.order,
|
||||
type: s.type,
|
||||
trigger: {
|
||||
type: s.trigger.type,
|
||||
conditionKeys: Object.keys(s.trigger.conditions).sort(),
|
||||
},
|
||||
actions: s.actions.map((a) => ({
|
||||
id: a.id,
|
||||
type: a.type,
|
||||
sourceKind: a.source.kind,
|
||||
pluginId: a.source.pluginId,
|
||||
pluginVersion: a.source.pluginVersion,
|
||||
transport: a.execution.transport,
|
||||
parameterKeys: Object.keys(a.parameters).sort(),
|
||||
})),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Props */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface BlockDesignerProps {
|
||||
experimentId: string;
|
||||
initialDesign?: ExperimentDesign;
|
||||
onSave?: (design: ExperimentDesign) => void;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function BlockDesigner({
|
||||
experimentId,
|
||||
initialDesign,
|
||||
onSave,
|
||||
}: BlockDesignerProps) {
|
||||
/* ---------------------------- Experiment Query ---------------------------- */
|
||||
const { data: experiment } = api.experiments.get.useQuery({
|
||||
id: experimentId,
|
||||
});
|
||||
|
||||
/* ------------------------------ Local Design ------------------------------ */
|
||||
const [design, setDesign] = useState<ExperimentDesign>(() => {
|
||||
const defaultDesign: ExperimentDesign = {
|
||||
id: experimentId,
|
||||
name: "New Experiment",
|
||||
description: "",
|
||||
steps: [],
|
||||
version: 1,
|
||||
lastSaved: new Date(),
|
||||
};
|
||||
return initialDesign ?? defaultDesign;
|
||||
});
|
||||
|
||||
const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
|
||||
const [selectedActionId, setSelectedActionId] = useState<string | null>(null);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
/* ------------------------- Validation / Drift Tracking -------------------- */
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [lastValidatedHash, setLastValidatedHash] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [lastValidatedDesignJson, setLastValidatedDesignJson] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
// Recompute drift conditions
|
||||
const currentDesignJson = useMemo(
|
||||
() => serializeDesignSteps(design.steps),
|
||||
[design.steps],
|
||||
);
|
||||
|
||||
const hasIntegrityHash = !!experiment?.integrityHash;
|
||||
const hashMismatch =
|
||||
hasIntegrityHash &&
|
||||
lastValidatedHash &&
|
||||
experiment?.integrityHash !== lastValidatedHash;
|
||||
const designChangedSinceValidation =
|
||||
!!lastValidatedDesignJson && lastValidatedDesignJson !== currentDesignJson;
|
||||
|
||||
const drift =
|
||||
hasIntegrityHash && (hashMismatch ? true : designChangedSinceValidation);
|
||||
|
||||
/* ---------------------------- Active Drag State --------------------------- */
|
||||
// Removed unused activeId state (drag overlay removed in modular refactor)
|
||||
|
||||
/* ------------------------------- tRPC Mutations --------------------------- */
|
||||
const updateExperiment = api.experiments.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Experiment saved");
|
||||
setHasUnsavedChanges(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(`Failed to save: ${err.message}`);
|
||||
},
|
||||
});
|
||||
const trpcUtils = api.useUtils();
|
||||
|
||||
/* ------------------------------- Plugins Load ----------------------------- */
|
||||
const { data: studyPlugins } = api.robots.plugins.getStudyPlugins.useQuery(
|
||||
{ studyId: experiment?.studyId ?? "" },
|
||||
{ enabled: !!experiment?.studyId },
|
||||
);
|
||||
|
||||
/* ---------------------------- Registry Loading ---------------------------- */
|
||||
useEffect(() => {
|
||||
actionRegistry.loadCoreActions().catch((err) => {
|
||||
console.error("Core actions load failed:", err);
|
||||
toast.error("Failed to load core action library");
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (experiment?.studyId && (studyPlugins?.length ?? 0) > 0) {
|
||||
actionRegistry.loadPluginActions(
|
||||
experiment.studyId,
|
||||
(studyPlugins ?? []).map((sp) => ({
|
||||
plugin: {
|
||||
id: sp.plugin.id,
|
||||
robotId: sp.plugin.robotId,
|
||||
version: sp.plugin.version,
|
||||
actionDefinitions: Array.isArray(sp.plugin.actionDefinitions)
|
||||
? sp.plugin.actionDefinitions
|
||||
: undefined,
|
||||
},
|
||||
})) ?? [],
|
||||
);
|
||||
}
|
||||
}, [experiment?.studyId, studyPlugins]);
|
||||
|
||||
/* ------------------------------ Breadcrumbs ------------------------------- */
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
{
|
||||
label: experiment?.study?.name ?? "Study",
|
||||
href: `/studies/${experiment?.studyId}`,
|
||||
},
|
||||
{ label: "Experiments", href: `/studies/${experiment?.studyId}` },
|
||||
{ label: design.name, href: `/experiments/${experimentId}` },
|
||||
{ label: "Designer" },
|
||||
]);
|
||||
|
||||
/* ------------------------------ DnD Sensors ------------------------------- */
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback((_event: DragStartEvent) => {
|
||||
// activeId tracking removed (drag overlay no longer used)
|
||||
}, []);
|
||||
|
||||
/* ------------------------------ Helpers ----------------------------------- */
|
||||
|
||||
const addActionToStep = useCallback(
|
||||
(stepId: string, def: ActionDefinition) => {
|
||||
const newAction: ExperimentAction = {
|
||||
id: `action_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
type: def.type,
|
||||
name: def.name,
|
||||
parameters: {},
|
||||
category: def.category,
|
||||
source: def.source,
|
||||
execution: def.execution ?? { transport: "internal" },
|
||||
parameterSchemaRaw: def.parameterSchemaRaw,
|
||||
};
|
||||
// Default param values
|
||||
def.parameters.forEach((p) => {
|
||||
if (p.value !== undefined) {
|
||||
newAction.parameters[p.id] = p.value;
|
||||
}
|
||||
});
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s) =>
|
||||
s.id === stepId ? { ...s, actions: [...s.actions, newAction] } : s,
|
||||
),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
toast.success(`Added ${def.name}`);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
// activeId reset removed (no longer tracked)
|
||||
if (!over) return;
|
||||
|
||||
const activeIdStr = active.id.toString();
|
||||
const overIdStr = over.id.toString();
|
||||
|
||||
// From library to step droppable
|
||||
if (activeIdStr.startsWith("action-") && overIdStr.startsWith("step-")) {
|
||||
const actionId = activeIdStr.replace("action-", "");
|
||||
const stepId = overIdStr.replace("step-", "");
|
||||
const def = actionRegistry.getAction(actionId);
|
||||
if (def) {
|
||||
addActionToStep(stepId, def);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Step reorder (both plain ids of steps)
|
||||
if (
|
||||
!activeIdStr.startsWith("action-") &&
|
||||
!overIdStr.startsWith("step-") &&
|
||||
!overIdStr.startsWith("action-")
|
||||
) {
|
||||
const oldIndex = design.steps.findIndex((s) => s.id === activeIdStr);
|
||||
const newIndex = design.steps.findIndex((s) => s.id === overIdStr);
|
||||
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: arrayMove(prev.steps, oldIndex, newIndex).map(
|
||||
(s, index) => ({ ...s, order: index }),
|
||||
),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Action reorder (within same step)
|
||||
if (
|
||||
!activeIdStr.startsWith("action-") &&
|
||||
!overIdStr.startsWith("step-") &&
|
||||
activeIdStr !== overIdStr
|
||||
) {
|
||||
// Identify which step these actions belong to
|
||||
const containingStep = design.steps.find((s) =>
|
||||
s.actions.some((a) => a.id === activeIdStr),
|
||||
);
|
||||
const targetStep = design.steps.find((s) =>
|
||||
s.actions.some((a) => a.id === overIdStr),
|
||||
);
|
||||
if (
|
||||
containingStep &&
|
||||
targetStep &&
|
||||
containingStep.id === targetStep.id
|
||||
) {
|
||||
const oldActionIndex = containingStep.actions.findIndex(
|
||||
(a) => a.id === activeIdStr,
|
||||
);
|
||||
const newActionIndex = containingStep.actions.findIndex(
|
||||
(a) => a.id === overIdStr,
|
||||
);
|
||||
if (
|
||||
oldActionIndex !== -1 &&
|
||||
newActionIndex !== -1 &&
|
||||
oldActionIndex !== newActionIndex
|
||||
) {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s) =>
|
||||
s.id === containingStep.id
|
||||
? {
|
||||
...s,
|
||||
actions: arrayMove(
|
||||
s.actions,
|
||||
oldActionIndex,
|
||||
newActionIndex,
|
||||
),
|
||||
}
|
||||
: s,
|
||||
),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[design.steps, addActionToStep],
|
||||
);
|
||||
|
||||
const addStep = useCallback(() => {
|
||||
const newStep: ExperimentStep = {
|
||||
id: `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
name: `Step ${design.steps.length + 1}`,
|
||||
description: "",
|
||||
type: "sequential",
|
||||
order: design.steps.length,
|
||||
trigger: {
|
||||
type: design.steps.length === 0 ? "trial_start" : "previous_step",
|
||||
conditions: {},
|
||||
},
|
||||
actions: [],
|
||||
expanded: true,
|
||||
};
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: [...prev.steps, newStep],
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
}, [design.steps.length]);
|
||||
|
||||
const updateStep = useCallback(
|
||||
(stepId: string, updates: Partial<ExperimentStep>) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s) =>
|
||||
s.id === stepId ? { ...s, ...updates } : s,
|
||||
),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteStep = useCallback(
|
||||
(stepId: string) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.filter((s) => s.id !== stepId),
|
||||
}));
|
||||
if (selectedStepId === stepId) setSelectedStepId(null);
|
||||
setHasUnsavedChanges(true);
|
||||
},
|
||||
[selectedStepId],
|
||||
);
|
||||
|
||||
const updateAction = useCallback(
|
||||
(stepId: string, actionId: string, updates: Partial<ExperimentAction>) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s) =>
|
||||
s.id === stepId
|
||||
? {
|
||||
...s,
|
||||
actions: s.actions.map((a) =>
|
||||
a.id === actionId ? { ...a, ...updates } : a,
|
||||
),
|
||||
}
|
||||
: s,
|
||||
),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteAction = useCallback(
|
||||
(stepId: string, actionId: string) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s) =>
|
||||
s.id === stepId
|
||||
? {
|
||||
...s,
|
||||
actions: s.actions.filter((a) => a.id !== actionId),
|
||||
}
|
||||
: s,
|
||||
),
|
||||
}));
|
||||
if (selectedActionId === actionId) setSelectedActionId(null);
|
||||
setHasUnsavedChanges(true);
|
||||
},
|
||||
[selectedActionId],
|
||||
);
|
||||
|
||||
/* ------------------------------- Validation ------------------------------- */
|
||||
const runValidation = useCallback(async () => {
|
||||
setIsValidating(true);
|
||||
try {
|
||||
const result = await trpcUtils.experiments.validateDesign.fetch({
|
||||
experimentId,
|
||||
visualDesign: { steps: design.steps },
|
||||
});
|
||||
|
||||
if (!result.valid) {
|
||||
toast.error(
|
||||
`Validation failed: ${result.issues.slice(0, 3).join(", ")}${
|
||||
result.issues.length > 3 ? "…" : ""
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.integrityHash) {
|
||||
setLastValidatedHash(result.integrityHash);
|
||||
setLastValidatedDesignJson(currentDesignJson);
|
||||
toast.success(
|
||||
`Validated • Hash: ${result.integrityHash.slice(0, 10)}…`,
|
||||
);
|
||||
} else {
|
||||
toast.success("Validated (no hash produced)");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Validation error: ${
|
||||
err instanceof Error ? err.message : "Unknown error"
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [experimentId, design.steps, trpcUtils, currentDesignJson]);
|
||||
|
||||
/* --------------------------------- Saving --------------------------------- */
|
||||
const saveDesign = useCallback(() => {
|
||||
const visualDesign = {
|
||||
steps: design.steps,
|
||||
version: design.version,
|
||||
lastSaved: new Date().toISOString(),
|
||||
};
|
||||
updateExperiment.mutate({
|
||||
id: experimentId,
|
||||
visualDesign,
|
||||
createSteps: true,
|
||||
compileExecution: true,
|
||||
});
|
||||
const updatedDesign = { ...design, lastSaved: new Date() };
|
||||
setDesign(updatedDesign);
|
||||
onSave?.(updatedDesign);
|
||||
}, [design, experimentId, onSave, updateExperiment]);
|
||||
|
||||
/* --------------------------- Selection Resolution ------------------------- */
|
||||
const selectedStep = design.steps.find((s) => s.id === selectedStepId);
|
||||
const selectedAction = selectedStep?.actions.find(
|
||||
(a) => a.id === selectedActionId,
|
||||
);
|
||||
|
||||
/* ------------------------------- Header Badges ---------------------------- */
|
||||
const validationBadge = drift ? (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="text-xs"
|
||||
title="Design has drifted since last validation or differs from stored hash"
|
||||
>
|
||||
Drift
|
||||
</Badge>
|
||||
) : lastValidatedHash ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-green-400 text-xs text-green-700 dark:text-green-400"
|
||||
title="Design matches last validated structure"
|
||||
>
|
||||
Validated
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs" title="Not yet validated">
|
||||
Unvalidated
|
||||
</Badge>
|
||||
);
|
||||
|
||||
/* ---------------------------------- Render -------------------------------- */
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title={design.name}
|
||||
description="Design your experiment using steps and categorized actions"
|
||||
icon={Play}
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{validationBadge}
|
||||
{experiment?.integrityHash && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Hash: {experiment.integrityHash.slice(0, 10)}…
|
||||
</Badge>
|
||||
)}
|
||||
{experiment?.executionGraphSummary && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Exec: {experiment.executionGraphSummary.steps ?? 0}s /
|
||||
{experiment.executionGraphSummary.actions ?? 0}a
|
||||
</Badge>
|
||||
)}
|
||||
{Array.isArray(experiment?.pluginDependencies) &&
|
||||
experiment.pluginDependencies.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{experiment.pluginDependencies.length} plugins
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{design.steps.length} steps
|
||||
</Badge>
|
||||
{hasUnsavedChanges && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-orange-300 text-orange-600"
|
||||
>
|
||||
Unsaved
|
||||
</Badge>
|
||||
)}
|
||||
<ActionButton
|
||||
onClick={saveDesign}
|
||||
disabled={!hasUnsavedChanges || updateExperiment.isPending}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{updateExperiment.isPending ? "Saving…" : "Save"}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setHasUnsavedChanges(false); // immediate feedback
|
||||
void runValidation();
|
||||
}}
|
||||
disabled={isValidating}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{isValidating ? "Validating…" : "Revalidate"}
|
||||
</ActionButton>
|
||||
<ActionButton variant="outline">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</ActionButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{/* Action Library */}
|
||||
<div className="col-span-3">
|
||||
<Card className="h-[calc(100vh-12rem)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
Action Library
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ActionLibrary />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Flow */}
|
||||
<div className="col-span-6">
|
||||
<StepFlow
|
||||
steps={design.steps}
|
||||
selectedStepId={selectedStepId}
|
||||
selectedActionId={selectedActionId}
|
||||
onStepSelect={(id) => {
|
||||
setSelectedStepId(id);
|
||||
setSelectedActionId(null);
|
||||
}}
|
||||
onStepDelete={deleteStep}
|
||||
onStepUpdate={updateStep}
|
||||
onActionSelect={(actionId) => setSelectedActionId(actionId)}
|
||||
onActionDelete={deleteAction}
|
||||
emptyState={
|
||||
<div className="py-8 text-center">
|
||||
<Play className="text-muted-foreground/50 mx-auto h-8 w-8" />
|
||||
<h3 className="mt-2 text-sm font-medium">No steps yet</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Add your first step to begin designing
|
||||
</p>
|
||||
<Button className="mt-2" size="sm" onClick={addStep}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add First Step
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
headerRight={
|
||||
<Button size="sm" onClick={addStep} className="h-6 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add Step
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Properties */}
|
||||
<div className="col-span-3">
|
||||
<Card className="h-[calc(100vh-12rem)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
Properties
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<ScrollArea className="h-full pr-1">
|
||||
<PropertiesPanel
|
||||
design={design}
|
||||
selectedStep={selectedStep}
|
||||
selectedAction={selectedAction}
|
||||
onActionUpdate={updateAction}
|
||||
onStepUpdate={updateStep}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -420,7 +420,7 @@ export function DependencyInspector({
|
||||
dependencies.some((d) => d.status !== "available") || drifts.length > 0;
|
||||
|
||||
return (
|
||||
<Card className={cn("h-[calc(100vh-12rem)]", className)}>
|
||||
<Card className={cn("h-full", className)}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Play } from "lucide-react";
|
||||
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
@@ -176,7 +170,7 @@ export function DesignerRoot({
|
||||
const recomputeHash = useDesignerStore((s) => s.recomputeHash);
|
||||
const lastPersistedHash = useDesignerStore((s) => s.lastPersistedHash);
|
||||
const currentDesignHash = useDesignerStore((s) => s.currentDesignHash);
|
||||
const lastValidatedHash = useDesignerStore((s) => s.lastValidatedHash);
|
||||
|
||||
const setPersistedHash = useDesignerStore((s) => s.setPersistedHash);
|
||||
const setValidatedHash = useDesignerStore((s) => s.setValidatedHash);
|
||||
const upsertStep = useDesignerStore((s) => s.upsertStep);
|
||||
@@ -236,6 +230,7 @@ export function DesignerRoot({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const [lastSavedAt, setLastSavedAt] = useState<Date | undefined>(undefined);
|
||||
const [inspectorTab, setInspectorTab] = useState<
|
||||
"properties" | "issues" | "dependencies"
|
||||
@@ -324,12 +319,6 @@ export function DesignerRoot({
|
||||
const hasUnsavedChanges =
|
||||
!!currentDesignHash && lastPersistedHash !== currentDesignHash;
|
||||
|
||||
const driftStatus = useMemo<"unvalidated" | "drift" | "validated">(() => {
|
||||
if (!currentDesignHash || !lastValidatedHash) return "unvalidated";
|
||||
if (currentDesignHash !== lastValidatedHash) return "drift";
|
||||
return "validated";
|
||||
}, [currentDesignHash, lastValidatedHash]);
|
||||
|
||||
/* ------------------------------- Step Ops -------------------------------- */
|
||||
const createNewStep = useCallback(() => {
|
||||
const newStep: ExperimentStep = {
|
||||
@@ -364,7 +353,7 @@ export function DesignerRoot({
|
||||
actionDefinitions: actionRegistry.getAllActions(),
|
||||
});
|
||||
// Debug: log validation results for troubleshooting
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.debug("[DesignerRoot] validation", {
|
||||
valid: result.valid,
|
||||
errors: result.errorCount,
|
||||
@@ -689,7 +678,7 @@ export function DesignerRoot({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-6rem)] flex-col gap-3">
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title={designMeta.name}
|
||||
description="Compose ordered steps with provenance-aware actions."
|
||||
@@ -718,7 +707,7 @@ export function DesignerRoot({
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border">
|
||||
<div className="flex h-[calc(100vh-12rem)] w-full max-w-full flex-col overflow-hidden rounded-md border">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={pointerWithin}
|
||||
@@ -727,23 +716,22 @@ export function DesignerRoot({
|
||||
onDragCancel={() => toggleLibraryScrollLock(false)}
|
||||
>
|
||||
<PanelsContainer
|
||||
showDividers
|
||||
className="min-h-0 flex-1"
|
||||
left={
|
||||
<div ref={libraryRootRef} data-library-root>
|
||||
<div ref={libraryRootRef} data-library-root className="h-full">
|
||||
<ActionLibraryPanel />
|
||||
</div>
|
||||
}
|
||||
center={<FlowWorkspace />}
|
||||
right={
|
||||
<InspectorPanel
|
||||
activeTab={inspectorTab}
|
||||
onTabChange={setInspectorTab}
|
||||
/>
|
||||
<div className="h-full">
|
||||
<InspectorPanel
|
||||
activeTab={inspectorTab}
|
||||
onTabChange={setInspectorTab}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
initialLeftWidth={260}
|
||||
initialRightWidth={260}
|
||||
minRightWidth={240}
|
||||
maxRightWidth={300}
|
||||
className="flex-1"
|
||||
/>
|
||||
<DragOverlay>
|
||||
{dragOverlayAction ? (
|
||||
@@ -753,15 +741,17 @@ export function DesignerRoot({
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<BottomStatusBar
|
||||
onSave={() => persist()}
|
||||
onValidate={() => validateDesign()}
|
||||
onExport={() => handleExport()}
|
||||
lastSavedAt={lastSavedAt}
|
||||
saving={isSaving}
|
||||
validating={isValidating}
|
||||
exporting={isExporting}
|
||||
/>
|
||||
<div className="flex-shrink-0 border-t">
|
||||
<BottomStatusBar
|
||||
onSave={() => persist()}
|
||||
onValidate={() => validateDesign()}
|
||||
onExport={() => handleExport()}
|
||||
lastSavedAt={lastSavedAt}
|
||||
saving={isSaving}
|
||||
validating={isValidating}
|
||||
exporting={isExporting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,734 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DesignerShell
|
||||
*
|
||||
* High-level orchestration component for the Experiment Designer redesign.
|
||||
* Replaces prior monolithic `BlockDesigner` responsibilities and delegates:
|
||||
* - Data loading (experiment + study plugins)
|
||||
* - Store initialization (steps, persisted/validated hashes)
|
||||
* - Hash & drift status display
|
||||
* - Save / validate / export actions (callback props)
|
||||
* - Layout composition (Action Library | Step Flow | Properties Panel)
|
||||
*
|
||||
* This file intentionally does NOT contain:
|
||||
* - Raw drag & drop logic (belongs to StepFlow & related internal modules)
|
||||
* - Parameter field rendering logic (PropertiesPanel / ParameterFieldFactory)
|
||||
* - Action registry loading internals (ActionRegistry singleton)
|
||||
*
|
||||
* Future Extensions:
|
||||
* - Conflict modal
|
||||
* - Bulk drift reconciliation
|
||||
* - Command palette (action insertion)
|
||||
* - Auto-save throttle controls
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Play, Save, Download, RefreshCw } from "lucide-react";
|
||||
import { DndContext, closestCenter } from "@dnd-kit/core";
|
||||
import type { DragEndEvent, DragOverEvent } from "@dnd-kit/core";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { PageHeader, ActionButton } from "~/components/ui/page-header";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import type {
|
||||
ExperimentDesign,
|
||||
ExperimentStep,
|
||||
ExperimentAction,
|
||||
ActionDefinition,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
|
||||
import { useDesignerStore } from "./state/store";
|
||||
import { computeDesignHash } from "./state/hashing";
|
||||
|
||||
import { actionRegistry } from "./ActionRegistry";
|
||||
import { ActionLibrary } from "./ActionLibrary";
|
||||
import { StepFlow } from "./StepFlow";
|
||||
import { PropertiesPanel } from "./PropertiesPanel";
|
||||
import { ValidationPanel } from "./ValidationPanel";
|
||||
import { DependencyInspector } from "./DependencyInspector";
|
||||
import { validateExperimentDesign } from "./state/validators";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface DesignerShellProps {
|
||||
experimentId: string;
|
||||
initialDesign?: ExperimentDesign;
|
||||
/**
|
||||
* Called after a successful persisted save (server acknowledged).
|
||||
*/
|
||||
onPersist?: (design: ExperimentDesign) => void;
|
||||
/**
|
||||
* Whether to auto-run compilation on save.
|
||||
*/
|
||||
autoCompile?: boolean;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Utility */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function buildEmptyDesign(
|
||||
experimentId: string,
|
||||
name?: string,
|
||||
description?: string | null,
|
||||
): ExperimentDesign {
|
||||
return {
|
||||
id: experimentId,
|
||||
name: name?.trim().length ? name : "Untitled Experiment",
|
||||
description: description ?? "",
|
||||
version: 1,
|
||||
steps: [],
|
||||
lastSaved: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
function adaptExistingDesign(experiment: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
visualDesign: unknown;
|
||||
}): ExperimentDesign | undefined {
|
||||
if (
|
||||
!experiment?.visualDesign ||
|
||||
typeof experiment.visualDesign !== "object" ||
|
||||
!("steps" in (experiment.visualDesign as Record<string, unknown>))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const vd = experiment.visualDesign as {
|
||||
steps?: ExperimentStep[];
|
||||
version?: number;
|
||||
lastSaved?: string;
|
||||
};
|
||||
if (!vd.steps || !Array.isArray(vd.steps)) return undefined;
|
||||
return {
|
||||
id: experiment.id,
|
||||
name: experiment.name,
|
||||
description: experiment.description ?? "",
|
||||
steps: vd.steps,
|
||||
version: vd.version ?? 1,
|
||||
lastSaved:
|
||||
vd.lastSaved && typeof vd.lastSaved === "string"
|
||||
? new Date(vd.lastSaved)
|
||||
: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* DesignerShell */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function DesignerShell({
|
||||
experimentId,
|
||||
initialDesign,
|
||||
onPersist,
|
||||
autoCompile = true,
|
||||
}: DesignerShellProps) {
|
||||
/* ---------------------------- Remote Experiment --------------------------- */
|
||||
const {
|
||||
data: experiment,
|
||||
isLoading: loadingExperiment,
|
||||
refetch: refetchExperiment,
|
||||
} = api.experiments.get.useQuery({ id: experimentId });
|
||||
|
||||
/* ------------------------------ Store Access ------------------------------ */
|
||||
const steps = useDesignerStore((s) => s.steps);
|
||||
const setSteps = useDesignerStore((s) => s.setSteps);
|
||||
const recomputeHash = useDesignerStore((s) => s.recomputeHash);
|
||||
const currentDesignHash = useDesignerStore((s) => s.currentDesignHash);
|
||||
const lastPersistedHash = useDesignerStore((s) => s.lastPersistedHash);
|
||||
const lastValidatedHash = useDesignerStore((s) => s.lastValidatedHash);
|
||||
const setPersistedHash = useDesignerStore((s) => s.setPersistedHash);
|
||||
const setValidatedHash = useDesignerStore((s) => s.setValidatedHash);
|
||||
const selectedActionId = useDesignerStore((s) => s.selectedActionId);
|
||||
const selectedStepId = useDesignerStore((s) => s.selectedStepId);
|
||||
const selectStep = useDesignerStore((s) => s.selectStep);
|
||||
const selectAction = useDesignerStore((s) => s.selectAction);
|
||||
const validationIssues = useDesignerStore((s) => s.validationIssues);
|
||||
const actionSignatureDrift = useDesignerStore((s) => s.actionSignatureDrift);
|
||||
const upsertStep = useDesignerStore((s) => s.upsertStep);
|
||||
const removeStep = useDesignerStore((s) => s.removeStep);
|
||||
const upsertAction = useDesignerStore((s) => s.upsertAction);
|
||||
const removeAction = useDesignerStore((s) => s.removeAction);
|
||||
|
||||
/* ------------------------------ Step Creation ------------------------------ */
|
||||
const createNewStep = useCallback(() => {
|
||||
const newStep: ExperimentStep = {
|
||||
id: `step-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: `Step ${steps.length + 1}`,
|
||||
description: "",
|
||||
type: "sequential",
|
||||
order: steps.length,
|
||||
trigger: {
|
||||
type: "trial_start",
|
||||
conditions: {},
|
||||
},
|
||||
actions: [],
|
||||
expanded: true,
|
||||
};
|
||||
upsertStep(newStep);
|
||||
selectStep(newStep.id);
|
||||
toast.success(`Created ${newStep.name}`);
|
||||
}, [steps.length, upsertStep, selectStep]);
|
||||
|
||||
/* ------------------------------ DnD Handlers ------------------------------ */
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over) return;
|
||||
|
||||
// Handle action drag to step
|
||||
if (
|
||||
active.id.toString().startsWith("action-") &&
|
||||
over.id.toString().startsWith("step-")
|
||||
) {
|
||||
const actionData = active.data.current?.action as ActionDefinition;
|
||||
const stepId = over.id.toString().replace("step-", "");
|
||||
|
||||
if (!actionData) return;
|
||||
|
||||
const step = steps.find((s) => s.id === stepId);
|
||||
if (!step) return;
|
||||
|
||||
// Create new action instance
|
||||
const newAction: ExperimentAction = {
|
||||
id: `action-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: actionData.type,
|
||||
name: actionData.name,
|
||||
category: actionData.category,
|
||||
parameters: {},
|
||||
source: actionData.source,
|
||||
execution: actionData.execution ?? {
|
||||
transport: "internal",
|
||||
retryable: false,
|
||||
},
|
||||
};
|
||||
|
||||
upsertAction(stepId, newAction);
|
||||
selectStep(stepId);
|
||||
selectAction(stepId, newAction.id);
|
||||
toast.success(`Added ${actionData.name} to ${step.name}`);
|
||||
}
|
||||
},
|
||||
[steps, upsertAction, selectStep, selectAction],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((_event: DragOverEvent) => {
|
||||
// This could be used for visual feedback during drag
|
||||
}, []);
|
||||
|
||||
/* ------------------------------- Local State ------------------------------ */
|
||||
const [designMeta, setDesignMeta] = useState<{
|
||||
name: string;
|
||||
description: string;
|
||||
version: number;
|
||||
}>(() => {
|
||||
const init =
|
||||
initialDesign ??
|
||||
(experiment ? adaptExistingDesign(experiment) : undefined) ??
|
||||
buildEmptyDesign(experimentId, experiment?.name, experiment?.description);
|
||||
return {
|
||||
name: init.name,
|
||||
description: init.description,
|
||||
version: init.version,
|
||||
};
|
||||
});
|
||||
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
/* ----------------------------- Experiment Update -------------------------- */
|
||||
const updateExperiment = api.experiments.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast.success("Experiment saved");
|
||||
await refetchExperiment();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(`Save failed: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
/* ------------------------------ Plugin Loading ---------------------------- */
|
||||
const { data: studyPlugins } = api.robots.plugins.getStudyPlugins.useQuery(
|
||||
{ studyId: experiment?.studyId ?? "" },
|
||||
{ enabled: !!experiment?.studyId },
|
||||
);
|
||||
|
||||
// Load core actions once
|
||||
useEffect(() => {
|
||||
actionRegistry
|
||||
.loadCoreActions()
|
||||
.catch((err) => console.error("Core action load failed:", err));
|
||||
}, []);
|
||||
|
||||
// Load study plugin actions when available
|
||||
useEffect(() => {
|
||||
if (!experiment?.studyId) return;
|
||||
if (!studyPlugins || studyPlugins.length === 0) return;
|
||||
actionRegistry.loadPluginActions(
|
||||
experiment.studyId,
|
||||
studyPlugins.map((sp) => ({
|
||||
plugin: {
|
||||
id: sp.plugin.id,
|
||||
robotId: sp.plugin.robotId,
|
||||
version: sp.plugin.version,
|
||||
actionDefinitions: Array.isArray(sp.plugin.actionDefinitions)
|
||||
? sp.plugin.actionDefinitions
|
||||
: undefined,
|
||||
},
|
||||
})),
|
||||
);
|
||||
}, [experiment?.studyId, studyPlugins]);
|
||||
|
||||
/* ------------------------- Initialize Store Steps ------------------------- */
|
||||
useEffect(() => {
|
||||
if (initialized) return;
|
||||
if (loadingExperiment) return;
|
||||
const resolvedInitial =
|
||||
initialDesign ??
|
||||
(experiment ? adaptExistingDesign(experiment) : undefined) ??
|
||||
buildEmptyDesign(experimentId, experiment?.name, experiment?.description);
|
||||
setDesignMeta({
|
||||
name: resolvedInitial.name,
|
||||
description: resolvedInitial.description,
|
||||
version: resolvedInitial.version,
|
||||
});
|
||||
setSteps(resolvedInitial.steps);
|
||||
// Set persisted hash if experiment already has integrityHash
|
||||
if (experiment?.integrityHash) {
|
||||
setPersistedHash(experiment.integrityHash);
|
||||
setValidatedHash(experiment.integrityHash);
|
||||
}
|
||||
setInitialized(true);
|
||||
// Kick off first hash compute
|
||||
void recomputeHash();
|
||||
}, [
|
||||
initialized,
|
||||
loadingExperiment,
|
||||
experiment,
|
||||
initialDesign,
|
||||
experimentId,
|
||||
setSteps,
|
||||
setPersistedHash,
|
||||
setValidatedHash,
|
||||
recomputeHash,
|
||||
]);
|
||||
|
||||
/* ----------------------------- Drift Computation -------------------------- */
|
||||
const driftState = useMemo(() => {
|
||||
if (!lastValidatedHash || !currentDesignHash) {
|
||||
return {
|
||||
status: "unvalidated" as const,
|
||||
drift: false,
|
||||
};
|
||||
}
|
||||
if (currentDesignHash !== lastValidatedHash) {
|
||||
return { status: "drift" as const, drift: true };
|
||||
}
|
||||
return { status: "validated" as const, drift: false };
|
||||
}, [lastValidatedHash, currentDesignHash]);
|
||||
|
||||
/* ------------------------------ Derived Flags ----------------------------- */
|
||||
const hasUnsavedChanges =
|
||||
!!currentDesignHash && lastPersistedHash !== currentDesignHash;
|
||||
|
||||
const totalActions = steps.reduce((sum, s) => sum + s.actions.length, 0);
|
||||
|
||||
/* ------------------------------- Validation ------------------------------- */
|
||||
const validateDesign = useCallback(async () => {
|
||||
if (!experimentId) return;
|
||||
setIsValidating(true);
|
||||
try {
|
||||
// Run local validation
|
||||
const validationResult = validateExperimentDesign(steps, {
|
||||
steps,
|
||||
actionDefinitions: actionRegistry.getAllActions(),
|
||||
});
|
||||
|
||||
// Compute hash for integrity
|
||||
const hash = await computeDesignHash(steps);
|
||||
setValidatedHash(hash);
|
||||
|
||||
if (validationResult.valid) {
|
||||
toast.success(`Validated • ${hash.slice(0, 10)}… • No issues found`);
|
||||
} else {
|
||||
toast.warning(
|
||||
`Validated with ${validationResult.errorCount} errors, ${validationResult.warningCount} warnings`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Validation error: ${
|
||||
err instanceof Error ? err.message : "Unknown error"
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [experimentId, steps, setValidatedHash]);
|
||||
|
||||
/* ---------------------------------- Save ---------------------------------- */
|
||||
const persist = useCallback(async () => {
|
||||
if (!experimentId) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const visualDesign = {
|
||||
steps,
|
||||
version: designMeta.version,
|
||||
lastSaved: new Date().toISOString(),
|
||||
};
|
||||
updateExperiment.mutate({
|
||||
id: experimentId,
|
||||
visualDesign,
|
||||
createSteps: true,
|
||||
compileExecution: autoCompile,
|
||||
});
|
||||
// Optimistic hash recompute to reflect state
|
||||
await recomputeHash();
|
||||
onPersist?.({
|
||||
id: experimentId,
|
||||
name: designMeta.name,
|
||||
description: designMeta.description,
|
||||
version: designMeta.version,
|
||||
steps,
|
||||
lastSaved: new Date(),
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
experimentId,
|
||||
steps,
|
||||
designMeta,
|
||||
recomputeHash,
|
||||
updateExperiment,
|
||||
onPersist,
|
||||
autoCompile,
|
||||
]);
|
||||
|
||||
/* -------------------------------- Export ---------------------------------- */
|
||||
const handleExport = useCallback(async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const designHash = currentDesignHash ?? (await computeDesignHash(steps));
|
||||
const bundle = {
|
||||
format: "hristudio.design.v1",
|
||||
exportedAt: new Date().toISOString(),
|
||||
experiment: {
|
||||
id: experimentId,
|
||||
name: designMeta.name,
|
||||
version: designMeta.version,
|
||||
integrityHash: designHash,
|
||||
steps,
|
||||
pluginDependencies:
|
||||
experiment?.pluginDependencies?.slice().sort() ?? [],
|
||||
},
|
||||
compiled: null, // Will be implemented when execution graph is available
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(bundle, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${designMeta.name
|
||||
.replace(/[^a-z0-9-_]+/gi, "_")
|
||||
.toLowerCase()}_design.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Exported design bundle");
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Export failed: ${
|
||||
err instanceof Error ? err.message : "Unknown error"
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [
|
||||
currentDesignHash,
|
||||
steps,
|
||||
experimentId,
|
||||
designMeta,
|
||||
experiment?.pluginDependencies,
|
||||
]);
|
||||
|
||||
/* ---------------------------- Incremental Hashing ------------------------- */
|
||||
// Optionally re-hash after step mutations (basic heuristic)
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
void recomputeHash();
|
||||
}, [steps.length, initialized, recomputeHash]);
|
||||
|
||||
/* ------------------------------- Header Badges ---------------------------- */
|
||||
const hashBadge =
|
||||
driftState.status === "drift" ? (
|
||||
<Badge variant="destructive" title="Design drift detected">
|
||||
Drift
|
||||
</Badge>
|
||||
) : driftState.status === "validated" ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-green-400 text-green-700 dark:text-green-400"
|
||||
title="Design validated"
|
||||
>
|
||||
Validated
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" title="Not validated">
|
||||
Unvalidated
|
||||
</Badge>
|
||||
);
|
||||
|
||||
/* ------------------------------- Render ----------------------------------- */
|
||||
if (loadingExperiment && !initialized) {
|
||||
return (
|
||||
<div className="py-24 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Loading experiment design…
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title={designMeta.name}
|
||||
description="Design your experiment by composing ordered steps with provenance-aware actions."
|
||||
icon={Play}
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{hashBadge}
|
||||
{experiment?.integrityHash && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Hash: {experiment.integrityHash.slice(0, 10)}…
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{steps.length} steps
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{totalActions} actions
|
||||
</Badge>
|
||||
{hasUnsavedChanges && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-orange-300 text-orange-600"
|
||||
>
|
||||
Unsaved
|
||||
</Badge>
|
||||
)}
|
||||
<ActionButton
|
||||
onClick={persist}
|
||||
disabled={!hasUnsavedChanges || isSaving}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isSaving ? "Saving…" : "Save"}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="outline"
|
||||
onClick={validateDesign}
|
||||
disabled={isValidating}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{isValidating ? "Validating…" : "Validate"}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="outline"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{isExporting ? "Exporting…" : "Export"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{/* Action Library */}
|
||||
<div className="col-span-3">
|
||||
<Card className="h-[calc(100vh-12rem)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
Action Library
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ActionLibrary />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Step Flow */}
|
||||
<div className="col-span-6">
|
||||
<StepFlow
|
||||
steps={steps}
|
||||
selectedStepId={selectedStepId ?? null}
|
||||
selectedActionId={selectedActionId ?? null}
|
||||
onStepSelect={(id: string) => selectStep(id)}
|
||||
onActionSelect={(id: string) =>
|
||||
selectedStepId && id
|
||||
? selectAction(selectedStepId, id)
|
||||
: undefined
|
||||
}
|
||||
onStepDelete={(stepId: string) => {
|
||||
removeStep(stepId);
|
||||
toast.success("Step deleted");
|
||||
}}
|
||||
onStepUpdate={(
|
||||
stepId: string,
|
||||
updates: Partial<ExperimentStep>,
|
||||
) => {
|
||||
const step = steps.find((s) => s.id === stepId);
|
||||
if (!step) return;
|
||||
upsertStep({ ...step, ...updates });
|
||||
}}
|
||||
onActionDelete={(stepId: string, actionId: string) => {
|
||||
removeAction(stepId, actionId);
|
||||
toast.success("Action deleted");
|
||||
}}
|
||||
emptyState={
|
||||
<div className="text-muted-foreground py-10 text-center text-sm">
|
||||
Add your first step to begin designing.
|
||||
</div>
|
||||
}
|
||||
headerRight={
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={createNewStep}
|
||||
>
|
||||
+ Step
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Properties Panel */}
|
||||
<div className="col-span-3">
|
||||
<Tabs defaultValue="properties" className="h-[calc(100vh-12rem)]">
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-2">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="properties" className="text-xs">
|
||||
Properties
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="validation" className="text-xs">
|
||||
Issues
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="dependencies" className="text-xs">
|
||||
Dependencies
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<TabsContent value="properties" className="m-0 h-full">
|
||||
<ScrollArea className="h-full p-3">
|
||||
<PropertiesPanel
|
||||
design={{
|
||||
id: experimentId,
|
||||
name: designMeta.name,
|
||||
description: designMeta.description,
|
||||
version: designMeta.version,
|
||||
steps,
|
||||
lastSaved: new Date(),
|
||||
}}
|
||||
selectedStep={steps.find(
|
||||
(s) => s.id === selectedStepId,
|
||||
)}
|
||||
selectedAction={
|
||||
steps
|
||||
.find(
|
||||
(s: ExperimentStep) => s.id === selectedStepId,
|
||||
)
|
||||
?.actions.find(
|
||||
(a: ExperimentAction) =>
|
||||
a.id === selectedActionId,
|
||||
) ?? undefined
|
||||
}
|
||||
onActionUpdate={(stepId, actionId, updates) => {
|
||||
const step = steps.find((s) => s.id === stepId);
|
||||
if (!step) return;
|
||||
const action = step.actions.find(
|
||||
(a) => a.id === actionId,
|
||||
);
|
||||
if (!action) return;
|
||||
upsertAction(stepId, { ...action, ...updates });
|
||||
}}
|
||||
onStepUpdate={(stepId, updates) => {
|
||||
const step = steps.find((s) => s.id === stepId);
|
||||
if (!step) return;
|
||||
upsertStep({ ...step, ...updates });
|
||||
}}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="validation" className="m-0 h-full">
|
||||
<ValidationPanel
|
||||
issues={validationIssues}
|
||||
onIssueClick={(issue) => {
|
||||
if (issue.stepId) {
|
||||
selectStep(issue.stepId);
|
||||
if (issue.actionId) {
|
||||
selectAction(issue.stepId, issue.actionId);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="dependencies" className="m-0 h-full">
|
||||
<DependencyInspector
|
||||
steps={steps}
|
||||
actionSignatureDrift={actionSignatureDrift}
|
||||
actionDefinitions={actionRegistry.getAllActions()}
|
||||
onReconcileAction={(actionId) => {
|
||||
// TODO: Implement drift reconciliation
|
||||
toast.info(
|
||||
`Reconciliation for action ${actionId} - TODO`,
|
||||
);
|
||||
}}
|
||||
onRefreshDependencies={() => {
|
||||
// TODO: Implement dependency refresh
|
||||
toast.info("Dependency refresh - TODO");
|
||||
}}
|
||||
onInstallPlugin={(pluginId) => {
|
||||
// TODO: Implement plugin installation
|
||||
toast.info(`Install plugin ${pluginId} - TODO`);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DesignerShell;
|
||||
@@ -1,470 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Save,
|
||||
Download,
|
||||
Upload,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
GitBranch,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export type VersionStrategy = "manual" | "auto_minor" | "auto_patch";
|
||||
export type SaveState = "clean" | "dirty" | "saving" | "conflict" | "error";
|
||||
|
||||
export interface SaveBarProps {
|
||||
/**
|
||||
* Current save state
|
||||
*/
|
||||
saveState: SaveState;
|
||||
/**
|
||||
* Whether auto-save is enabled
|
||||
*/
|
||||
autoSaveEnabled: boolean;
|
||||
/**
|
||||
* Current version strategy
|
||||
*/
|
||||
versionStrategy: VersionStrategy;
|
||||
/**
|
||||
* Number of unsaved changes
|
||||
*/
|
||||
dirtyCount: number;
|
||||
/**
|
||||
* Current design hash for integrity
|
||||
*/
|
||||
currentHash?: string;
|
||||
/**
|
||||
* Last persisted hash
|
||||
*/
|
||||
persistedHash?: string;
|
||||
/**
|
||||
* Last save timestamp
|
||||
*/
|
||||
lastSaved?: Date;
|
||||
/**
|
||||
* Whether there's a conflict with server state
|
||||
*/
|
||||
hasConflict?: boolean;
|
||||
/**
|
||||
* Current experiment version
|
||||
*/
|
||||
currentVersion: number;
|
||||
/**
|
||||
* Called when user manually saves
|
||||
*/
|
||||
onSave: () => void;
|
||||
/**
|
||||
* Called when user exports the design
|
||||
*/
|
||||
onExport: () => void;
|
||||
/**
|
||||
* Called when user imports a design
|
||||
*/
|
||||
onImport?: (file: File) => void;
|
||||
/**
|
||||
* Called when auto-save setting changes
|
||||
*/
|
||||
onAutoSaveChange: (enabled: boolean) => void;
|
||||
/**
|
||||
* Called when version strategy changes
|
||||
*/
|
||||
onVersionStrategyChange: (strategy: VersionStrategy) => void;
|
||||
/**
|
||||
* Called when user resolves a conflict
|
||||
*/
|
||||
onResolveConflict?: () => void;
|
||||
/**
|
||||
* Called when user wants to validate the design
|
||||
*/
|
||||
onValidate?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Save State Configuration */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const saveStateConfig = {
|
||||
clean: {
|
||||
icon: CheckCircle,
|
||||
color: "text-green-600 dark:text-green-400",
|
||||
label: "Saved",
|
||||
description: "All changes saved",
|
||||
},
|
||||
dirty: {
|
||||
icon: AlertCircle,
|
||||
color: "text-amber-600 dark:text-amber-400",
|
||||
label: "Unsaved",
|
||||
description: "You have unsaved changes",
|
||||
},
|
||||
saving: {
|
||||
icon: RefreshCw,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
label: "Saving",
|
||||
description: "Saving changes...",
|
||||
},
|
||||
conflict: {
|
||||
icon: AlertTriangle,
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
label: "Conflict",
|
||||
description: "Server conflict detected",
|
||||
},
|
||||
error: {
|
||||
icon: AlertTriangle,
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
label: "Error",
|
||||
description: "Save failed",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Version Strategy Options */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const versionStrategyOptions = [
|
||||
{
|
||||
value: "manual" as const,
|
||||
label: "Manual",
|
||||
description: "Only increment version when explicitly requested",
|
||||
},
|
||||
{
|
||||
value: "auto_minor" as const,
|
||||
label: "Auto Minor",
|
||||
description: "Auto-increment minor version on structural changes",
|
||||
},
|
||||
{
|
||||
value: "auto_patch" as const,
|
||||
label: "Auto Patch",
|
||||
description: "Auto-increment patch version on any save",
|
||||
},
|
||||
];
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Utility Functions */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function formatLastSaved(date?: Date): string {
|
||||
if (!date) return "Never";
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
function getNextVersion(
|
||||
current: number,
|
||||
strategy: VersionStrategy,
|
||||
hasStructuralChanges = false,
|
||||
): number {
|
||||
switch (strategy) {
|
||||
case "manual":
|
||||
return current;
|
||||
case "auto_minor":
|
||||
return hasStructuralChanges ? current + 1 : current;
|
||||
case "auto_patch":
|
||||
return current + 1;
|
||||
default:
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Import Handler */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function ImportButton({ onImport }: { onImport?: (file: File) => void }) {
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file && onImport) {
|
||||
onImport(file);
|
||||
}
|
||||
// Reset input to allow re-selecting the same file
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
if (!onImport) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
id="import-design"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => document.getElementById("import-design")?.click()}
|
||||
>
|
||||
<Upload className="mr-2 h-3 w-3" />
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* SaveBar Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function SaveBar({
|
||||
saveState,
|
||||
autoSaveEnabled,
|
||||
versionStrategy,
|
||||
dirtyCount,
|
||||
currentHash,
|
||||
persistedHash,
|
||||
lastSaved,
|
||||
hasConflict,
|
||||
currentVersion,
|
||||
onSave,
|
||||
onExport,
|
||||
onImport,
|
||||
onAutoSaveChange,
|
||||
onVersionStrategyChange,
|
||||
onResolveConflict,
|
||||
onValidate,
|
||||
className,
|
||||
}: SaveBarProps) {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
const config = saveStateConfig[saveState];
|
||||
const IconComponent = config.icon;
|
||||
|
||||
const hasUnsavedChanges = saveState === "dirty" || dirtyCount > 0;
|
||||
const canSave = hasUnsavedChanges && saveState !== "saving";
|
||||
const hashesMatch =
|
||||
currentHash && persistedHash && currentHash === persistedHash;
|
||||
|
||||
return (
|
||||
<Card className={cn("rounded-t-none border-t-0", className)}>
|
||||
<div className="flex items-center justify-between p-3">
|
||||
{/* Left: Save Status & Info */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Save State Indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
<IconComponent
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
config.color,
|
||||
saveState === "saving" && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{config.label}</span>
|
||||
{dirtyCount > 0 && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({dirtyCount} changes)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
|
||||
{/* Version Info */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<GitBranch className="text-muted-foreground h-3 w-3" />
|
||||
<span className="text-muted-foreground">Version</span>
|
||||
<Badge variant="outline" className="h-5 text-xs">
|
||||
v{currentVersion}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Last Saved */}
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatLastSaved(lastSaved)}</span>
|
||||
</div>
|
||||
|
||||
{/* Hash Status */}
|
||||
{currentHash && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge
|
||||
variant={hashesMatch ? "outline" : "secondary"}
|
||||
className="h-5 font-mono text-[10px]"
|
||||
>
|
||||
{currentHash.slice(0, 8)}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Conflict Resolution */}
|
||||
{hasConflict && onResolveConflict && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={onResolveConflict}
|
||||
>
|
||||
<AlertTriangle className="mr-2 h-3 w-3" />
|
||||
Resolve Conflict
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Validate */}
|
||||
{onValidate && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={onValidate}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-3 w-3" />
|
||||
Validate
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Import */}
|
||||
<ImportButton onImport={onImport} />
|
||||
|
||||
{/* Export */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={onExport}
|
||||
>
|
||||
<Download className="mr-2 h-3 w-3" />
|
||||
Export
|
||||
</Button>
|
||||
|
||||
{/* Save */}
|
||||
<Button
|
||||
variant={canSave ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={onSave}
|
||||
disabled={!canSave}
|
||||
>
|
||||
<Save className="mr-2 h-3 w-3" />
|
||||
{saveState === "saving" ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
|
||||
{/* Settings Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings Panel */}
|
||||
{showSettings && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="bg-muted/30 space-y-3 p-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Auto-Save Toggle */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Auto-Save</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="auto-save"
|
||||
checked={autoSaveEnabled}
|
||||
onCheckedChange={onAutoSaveChange}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="auto-save"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
Save automatically when idle
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version Strategy */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Version Strategy</Label>
|
||||
<Select
|
||||
value={versionStrategy}
|
||||
onValueChange={onVersionStrategyChange}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{versionStrategyOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div>
|
||||
<div className="font-medium">{option.label}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{option.description}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Next Version */}
|
||||
{versionStrategy !== "manual" && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Next save will create version{" "}
|
||||
<Badge variant="outline" className="h-4 text-[10px]">
|
||||
v
|
||||
{getNextVersion(
|
||||
currentVersion,
|
||||
versionStrategy,
|
||||
hasUnsavedChanges,
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Details */}
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{config.description}
|
||||
{hasUnsavedChanges && autoSaveEnabled && (
|
||||
<span> • Auto-save enabled</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,443 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import {
|
||||
useSortable,
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import {
|
||||
GripVertical,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Trash2,
|
||||
Zap,
|
||||
MessageSquare,
|
||||
Hand,
|
||||
Navigation,
|
||||
Volume2,
|
||||
Clock,
|
||||
Eye,
|
||||
Bot,
|
||||
User,
|
||||
Timer,
|
||||
MousePointer,
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
GitBranch,
|
||||
} from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import type {
|
||||
ExperimentStep,
|
||||
ExperimentAction,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
import { actionRegistry } from "./ActionRegistry";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Icon Map (localized to avoid cross-file re-render dependencies) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
MessageSquare,
|
||||
Hand,
|
||||
Navigation,
|
||||
Volume2,
|
||||
Clock,
|
||||
Eye,
|
||||
Bot,
|
||||
User,
|
||||
Zap,
|
||||
Timer,
|
||||
MousePointer,
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
GitBranch,
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* DroppableStep */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface DroppableStepProps {
|
||||
stepId: string;
|
||||
children: React.ReactNode;
|
||||
isEmpty?: boolean;
|
||||
}
|
||||
|
||||
function DroppableStep({ stepId, children, isEmpty }: DroppableStepProps) {
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: `step-${stepId}`,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"min-h-[60px] rounded border-2 border-dashed transition-colors",
|
||||
isOver
|
||||
? "border-blue-500 bg-blue-50 dark:bg-blue-950/20"
|
||||
: "border-transparent",
|
||||
isEmpty && "bg-muted/20",
|
||||
)}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<div className="flex items-center justify-center p-4 text-center">
|
||||
<div className="text-muted-foreground">
|
||||
<Plus className="mx-auto mb-1 h-5 w-5" />
|
||||
<p className="text-xs">Drop actions here</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* SortableAction */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface SortableActionProps {
|
||||
action: ExperimentAction;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function SortableAction({
|
||||
action,
|
||||
index,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
}: SortableActionProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: action.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const def = actionRegistry.getAction(action.type);
|
||||
const IconComponent = iconMap[def?.icon ?? "Zap"] ?? Zap;
|
||||
|
||||
const categoryColors = {
|
||||
wizard: "bg-blue-500",
|
||||
robot: "bg-emerald-500",
|
||||
control: "bg-amber-500",
|
||||
observation: "bg-purple-500",
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
className={cn(
|
||||
"group flex cursor-pointer items-center justify-between rounded border p-2 text-xs transition-colors",
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-950/30"
|
||||
: "hover:bg-accent/50",
|
||||
isDragging && "opacity-50",
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
{...listeners}
|
||||
className="text-muted-foreground/80 hover:text-foreground cursor-grab rounded p-0.5"
|
||||
>
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</div>
|
||||
<Badge variant="outline" className="h-4 text-[10px]">
|
||||
{index + 1}
|
||||
</Badge>
|
||||
{def && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 flex-shrink-0 items-center justify-center rounded text-white",
|
||||
categoryColors[def.category],
|
||||
)}
|
||||
>
|
||||
<IconComponent className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
<span className="flex items-center gap-1 truncate font-medium">
|
||||
{action.source.kind === "plugin" ? (
|
||||
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-emerald-600 text-[8px] font-bold text-white">
|
||||
P
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-slate-500 text-[8px] font-bold text-white">
|
||||
C
|
||||
</span>
|
||||
)}
|
||||
{action.name}
|
||||
</span>
|
||||
<Badge variant="secondary" className="h-4 text-[10px] capitalize">
|
||||
{(action.type ?? "").replace(/_/g, " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* SortableStep */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface SortableStepProps {
|
||||
step: ExperimentStep;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
selectedActionId: string | null;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
onUpdate: (updates: Partial<ExperimentStep>) => void;
|
||||
onActionSelect: (actionId: string) => void;
|
||||
onActionDelete: (actionId: string) => void;
|
||||
}
|
||||
|
||||
function SortableStep({
|
||||
step,
|
||||
index,
|
||||
isSelected,
|
||||
selectedActionId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
onActionSelect,
|
||||
onActionDelete,
|
||||
}: SortableStepProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: step.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const stepTypeColors: Record<ExperimentStep["type"], string> = {
|
||||
sequential: "border-l-blue-500",
|
||||
parallel: "border-l-emerald-500",
|
||||
conditional: "border-l-amber-500",
|
||||
loop: "border-l-purple-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
<Card
|
||||
className={cn(
|
||||
"border-l-4 transition-all",
|
||||
stepTypeColors[step.type],
|
||||
isSelected
|
||||
? "bg-blue-50/50 ring-2 ring-blue-500 dark:bg-blue-950/20 dark:ring-blue-400"
|
||||
: "",
|
||||
isDragging && "rotate-2 opacity-50 shadow-lg",
|
||||
)}
|
||||
>
|
||||
<CardHeader className="cursor-pointer pb-2" onClick={() => onSelect()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUpdate({ expanded: !step.expanded });
|
||||
}}
|
||||
>
|
||||
{step.expanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Badge variant="outline" className="h-5 text-xs">
|
||||
{index + 1}
|
||||
</Badge>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{step.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{step.actions.length} actions • {step.type}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<div {...listeners} className="cursor-grab p-1">
|
||||
<GripVertical className="text-muted-foreground h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{step.expanded && (
|
||||
<CardContent className="pt-0">
|
||||
<DroppableStep stepId={step.id} isEmpty={step.actions.length === 0}>
|
||||
{step.actions.length > 0 && (
|
||||
<SortableContext
|
||||
items={step.actions.map((a) => a.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{step.actions.map((action, actionIndex) => (
|
||||
<SortableAction
|
||||
key={action.id}
|
||||
action={action}
|
||||
index={actionIndex}
|
||||
isSelected={selectedActionId === action.id}
|
||||
onSelect={() => onActionSelect(action.id)}
|
||||
onDelete={() => onActionDelete(action.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
)}
|
||||
</DroppableStep>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* StepFlow (Scrollable Container of Steps) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface StepFlowProps {
|
||||
steps: ExperimentStep[];
|
||||
selectedStepId: string | null;
|
||||
selectedActionId: string | null;
|
||||
onStepSelect: (id: string) => void;
|
||||
onStepDelete: (id: string) => void;
|
||||
onStepUpdate: (id: string, updates: Partial<ExperimentStep>) => void;
|
||||
onActionSelect: (actionId: string) => void;
|
||||
onActionDelete: (stepId: string, actionId: string) => void;
|
||||
onActionUpdate?: (
|
||||
stepId: string,
|
||||
actionId: string,
|
||||
updates: Partial<ExperimentAction>,
|
||||
) => void;
|
||||
emptyState?: React.ReactNode;
|
||||
headerRight?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function StepFlow({
|
||||
steps,
|
||||
selectedStepId,
|
||||
selectedActionId,
|
||||
onStepSelect,
|
||||
onStepDelete,
|
||||
onStepUpdate,
|
||||
onActionSelect,
|
||||
onActionDelete,
|
||||
emptyState,
|
||||
headerRight,
|
||||
}: StepFlowProps) {
|
||||
return (
|
||||
<Card className="h-[calc(100vh-12rem)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Experiment Flow
|
||||
</div>
|
||||
{headerRight}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2">
|
||||
{steps.length === 0 ? (
|
||||
(emptyState ?? (
|
||||
<div className="py-8 text-center">
|
||||
<GitBranch className="text-muted-foreground/50 mx-auto h-8 w-8" />
|
||||
<h3 className="mt-2 text-sm font-medium">No steps yet</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Add your first step to begin designing
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<SortableContext
|
||||
items={steps.map((s) => s.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id}>
|
||||
<SortableStep
|
||||
step={step}
|
||||
index={index}
|
||||
isSelected={selectedStepId === step.id}
|
||||
selectedActionId={selectedActionId}
|
||||
onSelect={() => onStepSelect(step.id)}
|
||||
onDelete={() => onStepDelete(step.id)}
|
||||
onUpdate={(updates) => onStepUpdate(step.id, updates)}
|
||||
onActionSelect={onActionSelect}
|
||||
onActionDelete={(actionId) =>
|
||||
onActionDelete(step.id, actionId)
|
||||
}
|
||||
/>
|
||||
{index < steps.length - 1 && (
|
||||
<div className="flex justify-center py-1">
|
||||
<div className="bg-border h-2 w-px" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
@@ -62,24 +61,24 @@ const severityConfig = {
|
||||
error: {
|
||||
icon: AlertCircle,
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
bgColor: "bg-red-50 dark:bg-red-950/20",
|
||||
borderColor: "border-red-200 dark:border-red-800",
|
||||
bgColor: "bg-red-100 dark:bg-red-950/60",
|
||||
borderColor: "border-red-300 dark:border-red-700",
|
||||
badgeVariant: "destructive" as const,
|
||||
label: "Error",
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
color: "text-amber-600 dark:text-amber-400",
|
||||
bgColor: "bg-amber-50 dark:bg-amber-950/20",
|
||||
borderColor: "border-amber-200 dark:border-amber-800",
|
||||
bgColor: "bg-amber-100 dark:bg-amber-950/60",
|
||||
borderColor: "border-amber-300 dark:border-amber-700",
|
||||
badgeVariant: "secondary" as const,
|
||||
label: "Warning",
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-50 dark:bg-blue-950/20",
|
||||
borderColor: "border-blue-200 dark:border-blue-800",
|
||||
bgColor: "bg-blue-100 dark:bg-blue-950/60",
|
||||
borderColor: "border-blue-300 dark:border-blue-700",
|
||||
badgeVariant: "outline" as const,
|
||||
label: "Info",
|
||||
},
|
||||
@@ -103,15 +102,7 @@ function flattenIssues(issuesMap: Record<string, ValidationIssue[]>) {
|
||||
return flattened;
|
||||
}
|
||||
|
||||
function getEntityDisplayName(entityId: string): string {
|
||||
if (entityId.startsWith("step-")) {
|
||||
return `Step ${entityId.replace("step-", "")}`;
|
||||
}
|
||||
if (entityId.startsWith("action-")) {
|
||||
return `Action ${entityId.replace("action-", "")}`;
|
||||
}
|
||||
return entityId;
|
||||
}
|
||||
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Issue Item Component */
|
||||
@@ -214,7 +205,7 @@ export function ValidationPanel({
|
||||
const [severityFilter, setSeverityFilter] = useState<
|
||||
"all" | "error" | "warning" | "info"
|
||||
>("all");
|
||||
const [categoryFilter, setCategoryFilter] = useState<
|
||||
const [categoryFilter] = useState<
|
||||
"all" | "structural" | "parameter" | "semantic" | "execution"
|
||||
>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -248,18 +239,11 @@ export function ValidationPanel({
|
||||
|
||||
React.useEffect(() => {
|
||||
// Debug: surface validation state to console
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.log("[ValidationPanel] issues", issues, { flatIssues, counts });
|
||||
}, [issues, flatIssues, counts]);
|
||||
|
||||
// Available categories
|
||||
const availableCategories = useMemo(() => {
|
||||
const flat = flattenIssues(issues);
|
||||
const categories = new Set(flat.map((i) => i.category).filter(Boolean));
|
||||
return Array.from(categories) as Array<
|
||||
"structural" | "parameter" | "semantic" | "execution"
|
||||
>;
|
||||
}, [issues]);
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -346,7 +330,7 @@ export function ValidationPanel({
|
||||
</div>
|
||||
|
||||
{/* Issues List */}
|
||||
<ScrollArea className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
|
||||
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
|
||||
<div className="flex min-w-0 flex-col gap-2 p-2 pr-2">
|
||||
{counts.total === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
@@ -382,7 +366,7 @@ export function ValidationPanel({
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { useDesignerStore } from "../state/store";
|
||||
import { StepFlow } from "../StepFlow";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import type {
|
||||
ExperimentAction,
|
||||
ExperimentStep,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
|
||||
/**
|
||||
* Hidden droppable anchors so actions dragged from the ActionLibraryPanel
|
||||
* can land on steps even though StepFlow is still a legacy component.
|
||||
* This avoids having to deeply modify StepFlow during the transitional phase.
|
||||
*/
|
||||
function HiddenDroppableAnchors({ stepIds }: { stepIds: string[] }) {
|
||||
return (
|
||||
<>
|
||||
{stepIds.map((id) => (
|
||||
<SingleAnchor key={id} id={id} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SingleAnchor({ id }: { id: string }) {
|
||||
// Register a droppable area matching the StepFlow internal step id pattern
|
||||
useDroppable({
|
||||
id: `step-${id}`,
|
||||
});
|
||||
// Render nothing (zero-size element) – DnD kit only needs the registration
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* FlowListView (Transitional)
|
||||
*
|
||||
* This component is a TEMPORARY compatibility wrapper around the legacy
|
||||
* StepFlow component while the new virtualized / dual-mode (List vs Graph)
|
||||
* flow workspace is implemented.
|
||||
*
|
||||
* Responsibilities (current):
|
||||
* - Read step + selection state from the designer store
|
||||
* - Provide mutation handlers (upsert, delete, reorder placeholder)
|
||||
* - Emit structured callbacks (reserved for future instrumentation)
|
||||
*
|
||||
* Planned Enhancements:
|
||||
* - Virtualization for large step counts
|
||||
* - Inline step creation affordances between steps
|
||||
* - Multi-select + bulk operations
|
||||
* - Drag reordering at step level (currently delegated to DnD kit)
|
||||
* - Graph mode toggle (will lift state to higher DesignerRoot)
|
||||
* - Performance memoization / fine-grained selectors
|
||||
*
|
||||
* Until the new system is complete, this wrapper allows incremental
|
||||
* replacement without breaking existing behavior.
|
||||
*/
|
||||
|
||||
export interface FlowListViewProps {
|
||||
/**
|
||||
* Optional callbacks for higher-level orchestration (e.g. autosave triggers)
|
||||
*/
|
||||
onStepMutated?: (
|
||||
step: ExperimentStep,
|
||||
kind: "create" | "update" | "delete",
|
||||
) => void;
|
||||
onActionMutated?: (
|
||||
action: ExperimentAction,
|
||||
step: ExperimentStep,
|
||||
kind: "create" | "update" | "delete",
|
||||
) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FlowListView({
|
||||
onStepMutated,
|
||||
onActionMutated,
|
||||
className,
|
||||
}: FlowListViewProps) {
|
||||
/* ----------------------------- Store Selectors ---------------------------- */
|
||||
const steps = useDesignerStore((s) => s.steps);
|
||||
const selectedStepId = useDesignerStore((s) => s.selectedStepId);
|
||||
const selectedActionId = useDesignerStore((s) => s.selectedActionId);
|
||||
|
||||
const selectStep = useDesignerStore((s) => s.selectStep);
|
||||
const selectAction = useDesignerStore((s) => s.selectAction);
|
||||
|
||||
const upsertStep = useDesignerStore((s) => s.upsertStep);
|
||||
const removeStep = useDesignerStore((s) => s.removeStep);
|
||||
const upsertAction = useDesignerStore((s) => s.upsertAction);
|
||||
const removeAction = useDesignerStore((s) => s.removeAction);
|
||||
|
||||
/* ------------------------------- Handlers --------------------------------- */
|
||||
|
||||
const handleStepUpdate = useCallback(
|
||||
(stepId: string, updates: Partial<ExperimentStep>) => {
|
||||
const existing = steps.find((s) => s.id === stepId);
|
||||
if (!existing) return;
|
||||
const next: ExperimentStep = { ...existing, ...updates };
|
||||
upsertStep(next);
|
||||
onStepMutated?.(next, "update");
|
||||
},
|
||||
[steps, upsertStep, onStepMutated],
|
||||
);
|
||||
|
||||
const handleStepDelete = useCallback(
|
||||
(stepId: string) => {
|
||||
const existing = steps.find((s) => s.id === stepId);
|
||||
if (!existing) return;
|
||||
removeStep(stepId);
|
||||
onStepMutated?.(existing, "delete");
|
||||
},
|
||||
[steps, removeStep, onStepMutated],
|
||||
);
|
||||
|
||||
const handleActionDelete = useCallback(
|
||||
(stepId: string, actionId: string) => {
|
||||
const step = steps.find((s) => s.id === stepId);
|
||||
const action = step?.actions.find((a) => a.id === actionId);
|
||||
removeAction(stepId, actionId);
|
||||
if (step && action) {
|
||||
onActionMutated?.(action, step, "delete");
|
||||
}
|
||||
},
|
||||
[steps, removeAction, onActionMutated],
|
||||
);
|
||||
|
||||
const totalActions = useMemo(
|
||||
() => steps.reduce((sum, s) => sum + s.actions.length, 0),
|
||||
[steps],
|
||||
);
|
||||
|
||||
/* ------------------------------- Render ----------------------------------- */
|
||||
|
||||
return (
|
||||
<div className={className} data-flow-mode="list">
|
||||
{/* NOTE: Header / toolbar will be hoisted into the main workspace toolbar in later iterations */}
|
||||
<div className="flex items-center justify-between border-b px-3 py-2 text-xs">
|
||||
<div className="flex items-center gap-3 font-medium">
|
||||
<span className="text-muted-foreground">Flow (List View)</span>
|
||||
<span className="text-muted-foreground/70">
|
||||
{steps.length} steps • {totalActions} actions
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground/60 text-[10px]">
|
||||
Transitional component
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[calc(100%-2.5rem)]">
|
||||
{/* Hidden droppable anchors to enable dropping actions onto steps */}
|
||||
<HiddenDroppableAnchors stepIds={steps.map((s) => s.id)} />
|
||||
<StepFlow
|
||||
steps={steps}
|
||||
selectedStepId={selectedStepId ?? null}
|
||||
selectedActionId={selectedActionId ?? null}
|
||||
onStepSelect={(id) => selectStep(id)}
|
||||
onActionSelect={(actionId) =>
|
||||
selectedStepId && actionId
|
||||
? selectAction(selectedStepId, actionId)
|
||||
: undefined
|
||||
}
|
||||
onStepDelete={handleStepDelete}
|
||||
onStepUpdate={handleStepUpdate}
|
||||
onActionDelete={handleActionDelete}
|
||||
emptyState={
|
||||
<div className="text-muted-foreground py-10 text-center text-sm">
|
||||
No steps yet. Use the + Step button to add your first step.
|
||||
</div>
|
||||
}
|
||||
headerRight={
|
||||
<div className="text-muted-foreground/70 text-[11px]">
|
||||
(Add Step control will move to global toolbar)
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlowListView;
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -27,8 +26,6 @@ import {
|
||||
Plus,
|
||||
Trash2,
|
||||
GitBranch,
|
||||
Sparkles,
|
||||
CircleDot,
|
||||
Edit3,
|
||||
} from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
@@ -88,9 +85,7 @@ function generateStepId(): string {
|
||||
return `step-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
function generateActionId(): string {
|
||||
return `action-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
|
||||
function sortableStepId(stepId: string) {
|
||||
return `s-step-${stepId}`;
|
||||
@@ -165,7 +160,7 @@ function SortableActionChip({
|
||||
className={cn(
|
||||
"group relative flex w-full flex-col items-start gap-1 rounded border px-3 py-2 text-[11px]",
|
||||
"bg-muted/40 hover:bg-accent/40 cursor-pointer",
|
||||
isSelected && "border-blue-500 bg-blue-50 dark:bg-blue-950/30",
|
||||
isSelected && "border-border bg-accent/30",
|
||||
isDragging && "opacity-70 shadow-lg",
|
||||
)}
|
||||
onClick={onSelect}
|
||||
@@ -245,7 +240,7 @@ export function FlowWorkspace({
|
||||
overscan = 400,
|
||||
onStepCreate,
|
||||
onStepDelete,
|
||||
onActionCreate,
|
||||
onActionCreate: _onActionCreate,
|
||||
}: FlowWorkspaceProps) {
|
||||
/* Store selectors */
|
||||
const steps = useDesignerStore((s) => s.steps);
|
||||
@@ -256,7 +251,7 @@ export function FlowWorkspace({
|
||||
|
||||
const upsertStep = useDesignerStore((s) => s.upsertStep);
|
||||
const removeStep = useDesignerStore((s) => s.removeStep);
|
||||
const upsertAction = useDesignerStore((s) => s.upsertAction);
|
||||
|
||||
const removeAction = useDesignerStore((s) => s.removeAction);
|
||||
const reorderStep = useDesignerStore((s) => s.reorderStep);
|
||||
const reorderAction = useDesignerStore((s) => s.reorderAction);
|
||||
@@ -266,12 +261,12 @@ export function FlowWorkspace({
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const measureRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
const pendingHeightsRef = useRef<Map<string, number> | null>(null);
|
||||
const heightsRafRef = useRef<number | null>(null);
|
||||
const [heights, setHeights] = useState<Map<string, number>>(new Map());
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState(600);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [renamingStepId, setRenamingStepId] = useState<string | null>(null);
|
||||
const [isDraggingLibraryAction, setIsDraggingLibraryAction] = useState(false);
|
||||
// dragKind state removed (unused after refactor)
|
||||
|
||||
/* Parent lookup for action reorder */
|
||||
@@ -293,41 +288,47 @@ export function FlowWorkspace({
|
||||
for (const entry of entries) {
|
||||
const cr = entry.contentRect;
|
||||
setViewportHeight(cr.height);
|
||||
setContainerWidth((prev) => {
|
||||
if (Math.abs(prev - cr.width) > 0.5) {
|
||||
// Invalidate cached heights on width change to force re-measure
|
||||
setHeights(new Map());
|
||||
}
|
||||
return cr.width;
|
||||
});
|
||||
// Do not invalidate all heights on width change; per-step observers will update as needed
|
||||
}
|
||||
});
|
||||
observer.observe(el);
|
||||
const cr = el.getBoundingClientRect();
|
||||
|
||||
setViewportHeight(el.clientHeight);
|
||||
setContainerWidth(cr.width);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
/* Per-step measurement observer (attach/detach on ref set) */
|
||||
useLayoutEffect(() => {
|
||||
roRef.current = new ResizeObserver((entries) => {
|
||||
setHeights((prev) => {
|
||||
const next = new Map(prev);
|
||||
let changed = false;
|
||||
for (const entry of entries) {
|
||||
const id = entry.target.getAttribute("data-step-id");
|
||||
if (!id) continue;
|
||||
const h = entry.contentRect.height;
|
||||
if (prev.get(id) !== h) {
|
||||
next.set(id, h);
|
||||
changed = true;
|
||||
pendingHeightsRef.current ??= new Map();
|
||||
for (const entry of entries) {
|
||||
const id = entry.target.getAttribute("data-step-id");
|
||||
if (!id) continue;
|
||||
const h = entry.contentRect.height;
|
||||
pendingHeightsRef.current.set(id, h);
|
||||
}
|
||||
heightsRafRef.current ??= requestAnimationFrame(() => {
|
||||
const pending = pendingHeightsRef.current;
|
||||
heightsRafRef.current = null;
|
||||
pendingHeightsRef.current = null;
|
||||
if (!pending) return;
|
||||
setHeights((prev) => {
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
for (const [id, h] of pending) {
|
||||
if (prev.get(id) !== h) {
|
||||
next.set(id, h);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
return changed ? next : prev;
|
||||
});
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
if (heightsRafRef.current) cancelAnimationFrame(heightsRafRef.current);
|
||||
heightsRafRef.current = null;
|
||||
pendingHeightsRef.current = null;
|
||||
roRef.current?.disconnect();
|
||||
roRef.current = null;
|
||||
};
|
||||
@@ -430,29 +431,6 @@ export function FlowWorkspace({
|
||||
[upsertStep],
|
||||
);
|
||||
|
||||
const addActionToStep = useCallback(
|
||||
(
|
||||
stepId: string,
|
||||
actionDef: { type: string; name: string; category: string },
|
||||
) => {
|
||||
const step = steps.find((s) => s.id === stepId);
|
||||
if (!step) return;
|
||||
const newAction: ExperimentAction = {
|
||||
id: generateActionId(),
|
||||
type: actionDef.type,
|
||||
name: actionDef.name,
|
||||
category: actionDef.category as ExperimentAction["category"],
|
||||
parameters: {},
|
||||
source: { kind: "core" },
|
||||
execution: { transport: "internal" },
|
||||
};
|
||||
upsertAction(stepId, newAction);
|
||||
onActionCreate?.(stepId, newAction);
|
||||
void recomputeHash();
|
||||
},
|
||||
[steps, upsertAction, onActionCreate, recomputeHash],
|
||||
);
|
||||
|
||||
const deleteAction = useCallback(
|
||||
(stepId: string, actionId: string) => {
|
||||
removeAction(stepId, actionId);
|
||||
@@ -469,14 +447,13 @@ export function FlowWorkspace({
|
||||
const handleLocalDragStart = useCallback((e: DragStartEvent) => {
|
||||
const id = e.active.id.toString();
|
||||
if (id.startsWith("action-")) {
|
||||
setIsDraggingLibraryAction(true);
|
||||
// no-op
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLocalDragEnd = useCallback(
|
||||
(e: DragEndEvent) => {
|
||||
const { active, over } = e;
|
||||
setIsDraggingLibraryAction(false);
|
||||
if (!over || !active) {
|
||||
return;
|
||||
}
|
||||
@@ -525,7 +502,7 @@ export function FlowWorkspace({
|
||||
onDragStart: handleLocalDragStart,
|
||||
onDragEnd: handleLocalDragEnd,
|
||||
onDragCancel: () => {
|
||||
setIsDraggingLibraryAction(false);
|
||||
// no-op
|
||||
},
|
||||
});
|
||||
|
||||
@@ -578,9 +555,9 @@ export function FlowWorkspace({
|
||||
<StepDroppableArea stepId={step.id} />
|
||||
<div
|
||||
className={cn(
|
||||
"rounded border shadow-sm transition-colors mb-2",
|
||||
"mb-2 rounded border shadow-sm transition-colors",
|
||||
selectedStepId === step.id
|
||||
? "border-blue-400/60 bg-blue-50/40 dark:bg-blue-950/20"
|
||||
? "border-border bg-accent/30"
|
||||
: "hover:bg-accent/30",
|
||||
isDragging && "opacity-80 ring-1 ring-blue-300",
|
||||
)}
|
||||
@@ -590,7 +567,8 @@ export function FlowWorkspace({
|
||||
onClick={(e) => {
|
||||
// Avoid selecting step when interacting with controls or inputs
|
||||
const tag = (e.target as HTMLElement).tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "button") return;
|
||||
if (tag === "input" || tag === "textarea" || tag === "button")
|
||||
return;
|
||||
selectStep(step.id);
|
||||
selectAction(step.id, undefined);
|
||||
}}
|
||||
@@ -718,7 +696,7 @@ export function FlowWorkspace({
|
||||
</div>
|
||||
{/* Persistent centered bottom drop hint */}
|
||||
<div className="mt-3 flex w-full items-center justify-center">
|
||||
<div className="text-muted-foreground border border-dashed border-muted-foreground/30 rounded px-2 py-1 text-[11px]">
|
||||
<div className="text-muted-foreground border-muted-foreground/30 rounded border border-dashed px-2 py-1 text-[11px]">
|
||||
Drop actions here
|
||||
</div>
|
||||
</div>
|
||||
@@ -734,7 +712,7 @@ export function FlowWorkspace({
|
||||
/* Render */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
<div className={cn("flex h-full min-h-0 flex-col", className)}>
|
||||
<div className="flex items-center justify-between border-b px-3 py-2 text-xs">
|
||||
<div className="flex items-center gap-3 font-medium">
|
||||
<span className="text-muted-foreground flex items-center gap-1">
|
||||
@@ -760,20 +738,24 @@ export function FlowWorkspace({
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex-1 overflow-y-auto"
|
||||
className="relative h-0 min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{steps.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center p-6">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full border">
|
||||
<GitBranch className="h-6 w-6 text-muted-foreground" />
|
||||
<GitBranch className="text-muted-foreground h-6 w-6" />
|
||||
</div>
|
||||
<p className="mb-2 text-sm font-medium">No steps yet</p>
|
||||
<p className="text-muted-foreground mb-3 text-xs">
|
||||
Create your first step to begin designing the flow.
|
||||
</p>
|
||||
<Button size="sm" className="h-7 px-2 text-[11px]" onClick={() => createStep()}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-2 text-[11px]"
|
||||
onClick={() => createStep()}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" /> Add Step
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,382 +1,310 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type Edge = "left" | "right";
|
||||
|
||||
export interface PanelsContainerProps {
|
||||
left?: React.ReactNode;
|
||||
center: React.ReactNode;
|
||||
right?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Draw dividers between panels (applied to center only to avoid double borders).
|
||||
* Defaults to true.
|
||||
*/
|
||||
showDividers?: boolean;
|
||||
|
||||
/** Class applied to the root container */
|
||||
className?: string;
|
||||
|
||||
/** Class applied to each panel wrapper (left/center/right) */
|
||||
panelClassName?: string;
|
||||
|
||||
/** Class applied to each panel's internal scroll container */
|
||||
contentClassName?: string;
|
||||
|
||||
/** Accessible label for the overall layout */
|
||||
"aria-label"?: string;
|
||||
|
||||
/** Min/Max fractional widths for left and right panels (0..1), clamped during drag */
|
||||
minLeftPct?: number;
|
||||
maxLeftPct?: number;
|
||||
minRightPct?: number;
|
||||
maxRightPct?: number;
|
||||
|
||||
/** Keyboard resize step (fractional) per arrow press; Shift increases by 2x */
|
||||
keyboardStepPct?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* PanelsContainer
|
||||
*
|
||||
* Structural layout component for the Experiment Designer refactor.
|
||||
* Provides:
|
||||
* - Optional left + right side panels (resizable + collapsible)
|
||||
* - Central workspace (always present)
|
||||
* - Persistent panel widths (localStorage)
|
||||
* - Keyboard-accessible resize handles
|
||||
* - Minimal DOM repaint during drag (inline styles)
|
||||
* Tailwind-first, grid-based panel layout with:
|
||||
* - Drag-resizable left/right panels (no persistence)
|
||||
* - Strict overflow containment (no page-level x-scroll)
|
||||
* - Internal y-scroll for each panel
|
||||
* - Optional visual dividers on the center panel only (prevents double borders)
|
||||
*
|
||||
* NOT responsible for:
|
||||
* - Business logic or data fetching
|
||||
* - Panel content semantics (passed via props)
|
||||
*
|
||||
* Accessibility:
|
||||
* - Resize handles are <button> elements with aria-label
|
||||
* - Keyboard: ArrowLeft / ArrowRight adjusts width by step
|
||||
* Implementation details:
|
||||
* - Uses CSS variables for column fractions and an explicit grid template:
|
||||
* [minmax(0,var(--col-left)) minmax(0,var(--col-center)) minmax(0,var(--col-right))]
|
||||
* - Resize handles are absolutely positioned over the grid at the left and right boundaries.
|
||||
* - Fractions are clamped with configurable min/max so panels remain usable at all sizes.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "hristudio-designer-panels-v1";
|
||||
|
||||
interface PersistedLayout {
|
||||
left: number;
|
||||
right: number;
|
||||
leftCollapsed: boolean;
|
||||
rightCollapsed: boolean;
|
||||
}
|
||||
|
||||
export interface PanelsContainerProps {
|
||||
left?: ReactNode;
|
||||
center: ReactNode;
|
||||
right?: ReactNode;
|
||||
|
||||
/**
|
||||
* Initial (non-collapsed) widths in pixels.
|
||||
* If panels are omitted, their widths are ignored.
|
||||
*/
|
||||
initialLeftWidth?: number;
|
||||
initialRightWidth?: number;
|
||||
|
||||
/**
|
||||
* Minimum / maximum constraints to avoid unusable panels.
|
||||
*/
|
||||
minLeftWidth?: number;
|
||||
minRightWidth?: number;
|
||||
maxLeftWidth?: number;
|
||||
maxRightWidth?: number;
|
||||
|
||||
/**
|
||||
* Whether persistence to localStorage should be skipped (e.g. SSR preview)
|
||||
*/
|
||||
disablePersistence?: boolean;
|
||||
|
||||
/**
|
||||
* ClassName pass-through for root container
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
edge: "left" | "right";
|
||||
startX: number;
|
||||
startWidth: number;
|
||||
}
|
||||
|
||||
export function PanelsContainer({
|
||||
left,
|
||||
center,
|
||||
right,
|
||||
initialLeftWidth = 280,
|
||||
initialRightWidth = 340,
|
||||
minLeftWidth = 200,
|
||||
minRightWidth = 260,
|
||||
maxLeftWidth = 520,
|
||||
maxRightWidth = 560,
|
||||
disablePersistence = false,
|
||||
showDividers = true,
|
||||
className,
|
||||
panelClassName,
|
||||
contentClassName,
|
||||
"aria-label": ariaLabel = "Designer panel layout",
|
||||
minLeftPct = 0.12,
|
||||
maxLeftPct = 0.33,
|
||||
minRightPct = 0.12,
|
||||
maxRightPct = 0.33,
|
||||
keyboardStepPct = 0.02,
|
||||
}: PanelsContainerProps) {
|
||||
const hasLeft = Boolean(left);
|
||||
const hasRight = Boolean(right);
|
||||
const hasCenter = Boolean(center);
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* State */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
// Fractions for side panels (center is derived as 1 - (left + right))
|
||||
const [leftPct, setLeftPct] = React.useState<number>(hasLeft ? 0.2 : 0);
|
||||
const [rightPct, setRightPct] = React.useState<number>(hasRight ? 0.24 : 0);
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(initialLeftWidth);
|
||||
const [rightWidth, setRightWidth] = useState(initialRightWidth);
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||
const rootRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const dragRef = React.useRef<{
|
||||
edge: Edge;
|
||||
startX: number;
|
||||
startLeft: number;
|
||||
startRight: number;
|
||||
containerWidth: number;
|
||||
} | null>(null);
|
||||
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const frameReq = useRef<number | null>(null);
|
||||
const clamp = (v: number, lo: number, hi: number): number =>
|
||||
Math.max(lo, Math.min(hi, v));
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Persistence */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
const recompute = React.useCallback(
|
||||
(lp: number, rp: number) => {
|
||||
if (!hasCenter) return { l: 0, c: 0, r: 0 };
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (disablePersistence) return;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as PersistedLayout;
|
||||
if (typeof parsed.left === "number") setLeftWidth(parsed.left);
|
||||
if (typeof parsed.right === "number")
|
||||
setRightWidth(Math.max(parsed.right, minRightWidth));
|
||||
if (typeof parsed.leftCollapsed === "boolean") {
|
||||
setLeftCollapsed(parsed.leftCollapsed);
|
||||
if (hasLeft && hasRight) {
|
||||
const l = clamp(lp, minLeftPct, maxLeftPct);
|
||||
const r = clamp(rp, minRightPct, maxRightPct);
|
||||
const c = Math.max(0.1, 1 - (l + r)); // always preserve some center space
|
||||
return { l, c, r };
|
||||
}
|
||||
// Always start with right panel visible to avoid hidden inspector state
|
||||
setRightCollapsed(false);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}, [disablePersistence, minRightWidth]);
|
||||
|
||||
const persist = useCallback(
|
||||
(next?: Partial<PersistedLayout>) => {
|
||||
if (disablePersistence) return;
|
||||
const snapshot: PersistedLayout = {
|
||||
left: leftWidth,
|
||||
right: rightWidth,
|
||||
leftCollapsed,
|
||||
rightCollapsed,
|
||||
...next,
|
||||
};
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
/* noop */
|
||||
if (hasLeft && !hasRight) {
|
||||
const l = clamp(lp, minLeftPct, maxLeftPct);
|
||||
const c = Math.max(0.2, 1 - l);
|
||||
return { l, c, r: 0 };
|
||||
}
|
||||
},
|
||||
[disablePersistence, leftWidth, rightWidth, leftCollapsed, rightCollapsed],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
persist();
|
||||
}, [leftWidth, rightWidth, leftCollapsed, rightCollapsed, persist]);
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Drag Handlers */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: PointerEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const { edge, startX, startWidth } = dragRef.current;
|
||||
const delta = e.clientX - startX;
|
||||
|
||||
if (edge === "left") {
|
||||
let next = startWidth + delta;
|
||||
next = Math.max(minLeftWidth, Math.min(maxLeftWidth, next));
|
||||
if (next !== leftWidth) {
|
||||
if (frameReq.current) cancelAnimationFrame(frameReq.current);
|
||||
frameReq.current = requestAnimationFrame(() => setLeftWidth(next));
|
||||
}
|
||||
} else if (edge === "right") {
|
||||
let next = startWidth - delta;
|
||||
next = Math.max(minRightWidth, Math.min(maxRightWidth, next));
|
||||
if (next !== rightWidth) {
|
||||
if (frameReq.current) cancelAnimationFrame(frameReq.current);
|
||||
frameReq.current = requestAnimationFrame(() => setRightWidth(next));
|
||||
}
|
||||
if (!hasLeft && hasRight) {
|
||||
const r = clamp(rp, minRightPct, maxRightPct);
|
||||
const c = Math.max(0.2, 1 - r);
|
||||
return { l: 0, c, r };
|
||||
}
|
||||
// Center only
|
||||
return { l: 0, c: 1, r: 0 };
|
||||
},
|
||||
[
|
||||
leftWidth,
|
||||
rightWidth,
|
||||
minLeftWidth,
|
||||
maxLeftWidth,
|
||||
minRightWidth,
|
||||
maxRightWidth,
|
||||
hasCenter,
|
||||
hasLeft,
|
||||
hasRight,
|
||||
minLeftPct,
|
||||
maxLeftPct,
|
||||
minRightPct,
|
||||
maxRightPct,
|
||||
],
|
||||
);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
const { l, c, r } = recompute(leftPct, rightPct);
|
||||
|
||||
// Attach/detach global pointer handlers safely
|
||||
const onPointerMove = React.useCallback(
|
||||
(e: PointerEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || d.containerWidth <= 0) return;
|
||||
|
||||
const deltaPx = e.clientX - d.startX;
|
||||
const deltaPct = deltaPx / d.containerWidth;
|
||||
|
||||
if (d.edge === "left" && hasLeft) {
|
||||
const nextLeft = clamp(d.startLeft + deltaPct, minLeftPct, maxLeftPct);
|
||||
setLeftPct(nextLeft);
|
||||
} else if (d.edge === "right" && hasRight) {
|
||||
// Dragging the right edge moves leftwards as delta increases
|
||||
const nextRight = clamp(
|
||||
d.startRight - deltaPct,
|
||||
minRightPct,
|
||||
maxRightPct,
|
||||
);
|
||||
setRightPct(nextRight);
|
||||
}
|
||||
},
|
||||
[hasLeft, hasRight, minLeftPct, maxLeftPct, minRightPct, maxRightPct],
|
||||
);
|
||||
|
||||
const endDrag = React.useCallback(() => {
|
||||
dragRef.current = null;
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
window.removeEventListener("pointerup", endDrag);
|
||||
}, [onPointerMove]);
|
||||
|
||||
const startDrag = useCallback(
|
||||
(edge: "left" | "right", e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const startDrag =
|
||||
(edge: Edge) => (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (!rootRef.current) return;
|
||||
e.preventDefault();
|
||||
if (edge === "left" && leftCollapsed) return;
|
||||
if (edge === "right" && rightCollapsed) return;
|
||||
|
||||
const rect = rootRef.current.getBoundingClientRect();
|
||||
dragRef.current = {
|
||||
edge,
|
||||
startX: e.clientX,
|
||||
startWidth: edge === "left" ? leftWidth : rightWidth,
|
||||
startLeft: leftPct,
|
||||
startRight: rightPct,
|
||||
containerWidth: rect.width,
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", onPointerMove);
|
||||
window.addEventListener("pointerup", endDrag);
|
||||
},
|
||||
[
|
||||
leftWidth,
|
||||
rightWidth,
|
||||
leftCollapsed,
|
||||
rightCollapsed,
|
||||
onPointerMove,
|
||||
endDrag,
|
||||
],
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
// Cleanup if unmounted mid-drag
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
window.removeEventListener("pointerup", endDrag);
|
||||
};
|
||||
}, [onPointerMove, endDrag]);
|
||||
|
||||
// Keyboard resize for handles
|
||||
const onKeyResize =
|
||||
(edge: Edge) => (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||
e.preventDefault();
|
||||
|
||||
const step = (e.shiftKey ? 2 : 1) * keyboardStepPct;
|
||||
|
||||
if (edge === "left" && hasLeft) {
|
||||
const next = clamp(
|
||||
leftPct + (e.key === "ArrowRight" ? step : -step),
|
||||
minLeftPct,
|
||||
maxLeftPct,
|
||||
);
|
||||
setLeftPct(next);
|
||||
} else if (edge === "right" && hasRight) {
|
||||
const next = clamp(
|
||||
rightPct + (e.key === "ArrowLeft" ? step : -step),
|
||||
minRightPct,
|
||||
maxRightPct,
|
||||
);
|
||||
setRightPct(next);
|
||||
}
|
||||
};
|
||||
|
||||
// CSS variables for the grid fractions
|
||||
const styleVars: React.CSSProperties & Record<string, string> = hasCenter
|
||||
? {
|
||||
"--col-left": `${(hasLeft ? l : 0) * 100}%`,
|
||||
"--col-center": `${c * 100}%`,
|
||||
"--col-right": `${(hasRight ? r : 0) * 100}%`,
|
||||
}
|
||||
: {};
|
||||
|
||||
// Explicit grid template depending on which side panels exist
|
||||
const gridCols =
|
||||
hasLeft && hasRight
|
||||
? "[grid-template-columns:minmax(0,var(--col-left))_minmax(0,var(--col-center))_minmax(0,var(--col-right))]"
|
||||
: hasLeft && !hasRight
|
||||
? "[grid-template-columns:minmax(0,var(--col-left))_minmax(0,var(--col-center))]"
|
||||
: !hasLeft && hasRight
|
||||
? "[grid-template-columns:minmax(0,var(--col-center))_minmax(0,var(--col-right))]"
|
||||
: "[grid-template-columns:minmax(0,1fr)]";
|
||||
|
||||
// Dividers on the center panel only (prevents double borders if children have their own borders)
|
||||
const centerDividers =
|
||||
showDividers && hasCenter
|
||||
? cn({
|
||||
"border-l": hasLeft,
|
||||
"border-r": hasRight,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const Panel: React.FC<React.PropsWithChildren<{ className?: string }>> = ({
|
||||
className: panelCls,
|
||||
children,
|
||||
}) => (
|
||||
<section
|
||||
className={cn("min-w-0 overflow-hidden", panelCls, panelClassName)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Collapse / Expand */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
const toggleLeft = useCallback(() => {
|
||||
if (!hasLeft) return;
|
||||
setLeftCollapsed((c) => {
|
||||
const next = !c;
|
||||
if (next === false && leftWidth < minLeftWidth) {
|
||||
setLeftWidth(initialLeftWidth);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [hasLeft, leftWidth, minLeftWidth, initialLeftWidth]);
|
||||
|
||||
const toggleRight = useCallback(() => {
|
||||
if (!hasRight) return;
|
||||
setRightCollapsed((c) => {
|
||||
const next = !c;
|
||||
if (next === false && rightWidth < minRightWidth) {
|
||||
setRightWidth(initialRightWidth);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [hasRight, rightWidth, minRightWidth, initialRightWidth]);
|
||||
|
||||
/* Keyboard resizing (focused handle) */
|
||||
const handleKeyResize = useCallback(
|
||||
(edge: "left" | "right", e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
const step = e.shiftKey ? 24 : 12;
|
||||
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
if (edge === "left" && !leftCollapsed) {
|
||||
setLeftWidth((w) => {
|
||||
const delta = e.key === "ArrowLeft" ? -step : step;
|
||||
return Math.max(minLeftWidth, Math.min(maxLeftWidth, w + delta));
|
||||
});
|
||||
} else if (edge === "right" && !rightCollapsed) {
|
||||
setRightWidth((w) => {
|
||||
const delta = e.key === "ArrowLeft" ? -step : step;
|
||||
return Math.max(minRightWidth, Math.min(maxRightWidth, w + delta));
|
||||
});
|
||||
}
|
||||
} else if (e.key === "Enter" || e.key === " ") {
|
||||
if (edge === "left") toggleLeft();
|
||||
else toggleRight();
|
||||
}
|
||||
},
|
||||
[
|
||||
leftCollapsed,
|
||||
rightCollapsed,
|
||||
minLeftWidth,
|
||||
maxLeftWidth,
|
||||
minRightWidth,
|
||||
maxRightWidth,
|
||||
toggleLeft,
|
||||
toggleRight,
|
||||
],
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Render */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
aria-label={ariaLabel}
|
||||
style={styleVars}
|
||||
className={cn(
|
||||
"flex h-full w-full overflow-hidden select-none",
|
||||
"relative grid h-full min-h-0 w-full overflow-hidden select-none",
|
||||
gridCols,
|
||||
className,
|
||||
)}
|
||||
aria-label="Designer panel layout"
|
||||
>
|
||||
{/* Left Panel */}
|
||||
{hasLeft && (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background/50 relative flex h-full flex-shrink-0 flex-col border-r transition-[width] duration-150",
|
||||
leftCollapsed ? "w-0 border-r-0" : "w-[var(--panel-left-width)]",
|
||||
)}
|
||||
style={
|
||||
leftCollapsed
|
||||
? undefined
|
||||
: ({
|
||||
["--panel-left-width" as string]: `${leftWidth}px`,
|
||||
} as React.CSSProperties)
|
||||
}
|
||||
>
|
||||
{!leftCollapsed && (
|
||||
<div className="flex-1 overflow-hidden">{left}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hasLeft && <Panel>{left}</Panel>}
|
||||
|
||||
{/* Left Resize Handle */}
|
||||
{hasLeft && !leftCollapsed && (
|
||||
{hasCenter && <Panel className={centerDividers}>{center}</Panel>}
|
||||
|
||||
{hasRight && <Panel>{right}</Panel>}
|
||||
|
||||
{/* Resize handles (only render where applicable) */}
|
||||
{hasCenter && hasLeft && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Resize left panel (Enter to toggle collapse)"
|
||||
onPointerDown={(e) => startDrag("left", e)}
|
||||
onDoubleClick={toggleLeft}
|
||||
onKeyDown={(e) => handleKeyResize("left", e)}
|
||||
className="hover:bg-accent/40 focus-visible:ring-ring relative z-10 h-full w-0 cursor-col-resize px-1 outline-none focus-visible:ring-2"
|
||||
role="separator"
|
||||
aria-label="Resize left panel"
|
||||
aria-orientation="vertical"
|
||||
onPointerDown={startDrag("left")}
|
||||
onKeyDown={onKeyResize("left")}
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-10 w-1 cursor-col-resize outline-none",
|
||||
"focus-visible:ring-ring focus-visible:ring-2",
|
||||
)}
|
||||
// Position at the boundary between left and center
|
||||
style={{ left: "var(--col-left)", transform: "translateX(-0.5px)" }}
|
||||
tabIndex={0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Left collapse toggle removed to prevent breadcrumb overlap */}
|
||||
|
||||
{/* Center (Workspace) */}
|
||||
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-hidden">{center}</div>
|
||||
</div>
|
||||
|
||||
{/* Right Resize Handle */}
|
||||
{hasRight && !rightCollapsed && (
|
||||
{hasCenter && hasRight && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Resize right panel (Enter to toggle collapse)"
|
||||
onPointerDown={(e) => startDrag("right", e)}
|
||||
onDoubleClick={toggleRight}
|
||||
onKeyDown={(e) => handleKeyResize("right", e)}
|
||||
className="hover:bg-accent/40 focus-visible:ring-ring relative z-10 h-full w-1 cursor-col-resize outline-none focus-visible:ring-2"
|
||||
role="separator"
|
||||
aria-label="Resize right panel"
|
||||
aria-orientation="vertical"
|
||||
onPointerDown={startDrag("right")}
|
||||
onKeyDown={onKeyResize("right")}
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-10 w-1 cursor-col-resize outline-none",
|
||||
"focus-visible:ring-ring focus-visible:ring-2",
|
||||
)}
|
||||
// Position at the boundary between center and right (offset from the right)
|
||||
style={{ right: "var(--col-right)", transform: "translateX(0.5px)" }}
|
||||
tabIndex={0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Right Panel */}
|
||||
{hasRight && (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background/50 relative flex h-full flex-shrink-0 flex-col transition-[width] duration-150",
|
||||
rightCollapsed ? "w-0" : "w-[var(--panel-right-width)]",
|
||||
)}
|
||||
style={
|
||||
rightCollapsed
|
||||
? undefined
|
||||
: ({
|
||||
["--panel-right-width" as string]: `${rightWidth}px`,
|
||||
} as React.CSSProperties)
|
||||
}
|
||||
>
|
||||
{!rightCollapsed && (
|
||||
<div className="min-w-0 flex-1 overflow-hidden">{right}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Minimal Right Toggle (top-right), non-intrusive like VSCode */}
|
||||
{hasRight && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
rightCollapsed ? "Expand inspector" : "Collapse inspector"
|
||||
}
|
||||
onClick={toggleRight}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground absolute top-1 z-20 p-1 text-[10px]",
|
||||
rightCollapsed ? "right-1" : "right-1",
|
||||
)}
|
||||
title={rightCollapsed ? "Show inspector" : "Hide inspector"}
|
||||
>
|
||||
{rightCollapsed ? "◀" : "▶"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useActionRegistry } from "../ActionRegistry";
|
||||
import type { ActionDefinition } from "~/lib/experiment-designer/types";
|
||||
@@ -79,14 +80,17 @@ function DraggableAction({
|
||||
onToggleFavorite,
|
||||
highlight,
|
||||
}: DraggableActionProps) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `action-${action.id}`,
|
||||
data: { action },
|
||||
});
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useDraggable({
|
||||
id: `action-${action.id}`,
|
||||
data: { action },
|
||||
});
|
||||
|
||||
// Disable visual translation during drag so the list does not shift items.
|
||||
// We still let dnd-kit manage the drag overlay internally (no manual transform).
|
||||
const style: React.CSSProperties = {};
|
||||
const style: React.CSSProperties = transform
|
||||
? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
}
|
||||
: {};
|
||||
|
||||
const IconComponent = iconMap[action.icon] ?? Sparkles;
|
||||
|
||||
@@ -104,12 +108,12 @@ function DraggableAction({
|
||||
{...listeners}
|
||||
style={style}
|
||||
className={cn(
|
||||
"group bg-background/60 hover:bg-accent/50 relative flex w-full cursor-grab touch-none flex-col gap-1 rounded border px-2 transition-colors select-none",
|
||||
"group bg-background/60 hover:bg-accent/50 relative flex w-full cursor-grab touch-none flex-col gap-1 rounded border px-2 text-left transition-colors select-none",
|
||||
compact ? "py-1.5 text-[11px]" : "py-2 text-[12px]",
|
||||
isDragging && "ring-border opacity-60 ring-1",
|
||||
isDragging && "opacity-50",
|
||||
)}
|
||||
draggable={false}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
title={action.description ?? ""}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -127,7 +131,7 @@ function DraggableAction({
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-2 select-none">
|
||||
<div className="flex min-w-0 items-start gap-2 select-none">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-white",
|
||||
@@ -331,8 +335,8 @@ export function ActionLibraryPanel() {
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="flex h-full max-w-[240px] flex-col overflow-hidden">
|
||||
<div className="bg-background/60 border-b p-2">
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="bg-background/60 flex-shrink-0 border-b p-2">
|
||||
<div className="relative mb-2">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
@@ -359,10 +363,11 @@ export function ActionLibraryPanel() {
|
||||
)}
|
||||
onClick={() => toggleCategory(cat.key)}
|
||||
aria-pressed={active}
|
||||
aria-label={cat.label}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{cat.label}
|
||||
<span className="ml-auto text-[10px] font-normal opacity-80">
|
||||
<span className="hidden md:inline">{cat.label}</span>
|
||||
<span className="ml-auto hidden text-[10px] font-normal opacity-80 lg:inline">
|
||||
{countsByCategory[cat.key]}
|
||||
</span>
|
||||
</Button>
|
||||
@@ -374,17 +379,17 @@ export function ActionLibraryPanel() {
|
||||
<Button
|
||||
variant={showOnlyFavorites ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-7 min-w-[80px] flex-1"
|
||||
className="h-7 flex-1"
|
||||
onClick={() => setShowOnlyFavorites((s) => !s)}
|
||||
aria-pressed={showOnlyFavorites}
|
||||
aria-label="Toggle favorites filter"
|
||||
>
|
||||
<Star className="mr-1 h-3 w-3" />
|
||||
Fav
|
||||
<Star className="h-3 w-3" />
|
||||
<span className="ml-1 hidden sm:inline">Fav</span>
|
||||
{showOnlyFavorites && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1 h-4 px-1 text-[10px]"
|
||||
className="ml-1 hidden h-4 px-1 text-[10px] sm:inline"
|
||||
title="Visible favorites"
|
||||
>
|
||||
{visibleFavoritesCount}
|
||||
@@ -394,7 +399,7 @@ export function ActionLibraryPanel() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 min-w-[80px] flex-1"
|
||||
className="h-7 flex-1"
|
||||
onClick={() =>
|
||||
setDensity((d) =>
|
||||
d === "comfortable" ? "compact" : "comfortable",
|
||||
@@ -402,18 +407,20 @@ export function ActionLibraryPanel() {
|
||||
}
|
||||
aria-label="Toggle density"
|
||||
>
|
||||
<SlidersHorizontal className="mr-1 h-3 w-3" />
|
||||
{density === "comfortable" ? "Dense" : "Relax"}
|
||||
<SlidersHorizontal className="h-3 w-3" />
|
||||
<span className="ml-1 hidden sm:inline">
|
||||
{density === "comfortable" ? "Dense" : "Relax"}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 min-w-[60px] flex-1"
|
||||
className="h-7 flex-1"
|
||||
onClick={clearFilters}
|
||||
aria-label="Clear filters"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
Clear
|
||||
<span className="ml-1 hidden sm:inline">Clear</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -432,8 +439,8 @@ export function ActionLibraryPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 overflow-x-hidden overflow-y-auto">
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<ScrollArea className="flex-1 overflow-hidden">
|
||||
<div className="grid grid-cols-1 gap-2 p-2">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-muted-foreground/70 flex flex-col items-center gap-2 py-10 text-center text-xs">
|
||||
<Filter className="h-6 w-6" />
|
||||
@@ -454,7 +461,7 @@ export function ActionLibraryPanel() {
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="bg-background/60 border-t p-2">
|
||||
<div className="bg-background/60 flex-shrink-0 border-t p-2">
|
||||
<div className="flex items-center justify-between text-[10px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="h-4 px-1 text-[10px]">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useMemo, useState, useCallback } from "react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useDesignerStore } from "../state/store";
|
||||
import { actionRegistry } from "../ActionRegistry";
|
||||
@@ -200,7 +200,7 @@ export function InspectorPanel({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background/40 border-border relative flex h-full min-w-0 flex-col overflow-hidden border-l backdrop-blur-sm",
|
||||
"bg-background/40 border-border relative flex h-full min-w-0 flex-col overflow-hidden break-words whitespace-normal backdrop-blur-sm",
|
||||
className,
|
||||
)}
|
||||
style={{ contain: "layout paint size" }}
|
||||
@@ -208,62 +208,51 @@ export function InspectorPanel({
|
||||
aria-label="Inspector panel"
|
||||
>
|
||||
{/* Tab Header */}
|
||||
<div className="border-b px-2 py-1.5">
|
||||
<Tabs
|
||||
value={effectiveTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="flex h-9 w-full items-center gap-1 overflow-hidden">
|
||||
<TabsTrigger
|
||||
value="properties"
|
||||
className="flex min-w-0 flex-1 items-center justify-center gap-1 truncate text-[11px]"
|
||||
title="Properties (Step / Action)"
|
||||
>
|
||||
<Tabs
|
||||
value={effectiveTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="flex min-h-0 w-full flex-1 flex-col"
|
||||
>
|
||||
<div className="px-2 py-1.5">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="properties" title="Properties (Step / Action)">
|
||||
<Settings className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="hidden sm:inline">Props</span>
|
||||
<span className="hidden md:inline">Props</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="issues"
|
||||
className="flex min-w-0 flex-1 items-center justify-center gap-1 truncate text-[11px]"
|
||||
title="Validation Issues"
|
||||
>
|
||||
|
||||
<TabsTrigger value="issues" title="Validation Issues">
|
||||
<AlertTriangle className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="hidden sm:inline">
|
||||
<span className="hidden md:inline">
|
||||
Issues{issueCount > 0 ? ` (${issueCount})` : ""}
|
||||
</span>
|
||||
{issueCount > 0 && (
|
||||
<span className="xs:hidden text-amber-600 dark:text-amber-400">
|
||||
<span className="text-amber-600 md:hidden dark:text-amber-400">
|
||||
{issueCount}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="dependencies"
|
||||
className="flex min-w-0 flex-1 items-center justify-center gap-1 truncate text-[11px]"
|
||||
title="Dependencies / Drift"
|
||||
>
|
||||
|
||||
<TabsTrigger value="dependencies" title="Dependencies / Drift">
|
||||
<PackageSearch className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="hidden sm:inline">
|
||||
<span className="hidden md:inline">
|
||||
Deps{driftCount > 0 ? ` (${driftCount})` : ""}
|
||||
</span>
|
||||
{driftCount > 0 && (
|
||||
<span className="xs:hidden text-purple-600 dark:text-purple-400">
|
||||
<span className="text-purple-600 md:hidden dark:text-purple-400">
|
||||
{driftCount}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/*
|
||||
{/* Content */}
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{/*
|
||||
Force consistent width for tab bodies to prevent reflow when
|
||||
switching between content with different intrinsic widths.
|
||||
*/}
|
||||
<Tabs value={effectiveTab}>
|
||||
|
||||
{/* Properties */}
|
||||
<TabsContent
|
||||
value="properties"
|
||||
@@ -282,8 +271,8 @@ export function InspectorPanel({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="w-full px-3 py-3">
|
||||
<div className="flex-1 overflow-x-hidden overflow-y-auto">
|
||||
<div className="w-full px-0 py-2 break-words whitespace-normal">
|
||||
<PropertiesPanel
|
||||
design={{
|
||||
id: "design",
|
||||
@@ -299,7 +288,7 @@ export function InspectorPanel({
|
||||
onStepUpdate={handleStepUpdate}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
@@ -344,8 +333,8 @@ export function InspectorPanel({
|
||||
value="dependencies"
|
||||
className="m-0 flex min-h-0 flex-1 flex-col data-[state=inactive]:hidden"
|
||||
>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="w-full px-3 py-3">
|
||||
<div className="flex-1 overflow-x-hidden overflow-y-auto">
|
||||
<div className="w-full px-3 py-3 break-words whitespace-normal">
|
||||
<DependencyInspector
|
||||
steps={steps}
|
||||
actionSignatureDrift={actionSignatureDrift}
|
||||
@@ -363,10 +352,10 @@ export function InspectorPanel({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
{/* Footer (lightweight) */}
|
||||
<div className="text-muted-foreground border-t px-3 py-1.5 text-[10px]">
|
||||
|
||||
@@ -70,8 +70,8 @@ function canonicalize(value: unknown): CanonicalValue {
|
||||
function bufferToHex(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let hex = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const b = bytes[i]?.toString(16).padStart(2, "0");
|
||||
for (const byte of bytes) {
|
||||
const b = byte.toString(16).padStart(2, "0");
|
||||
hex += b;
|
||||
}
|
||||
return hex;
|
||||
@@ -90,8 +90,9 @@ async function hashString(input: string): Promise<string> {
|
||||
|
||||
// Fallback to Node (should not execute in Edge runtime)
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const nodeCrypto: typeof import("crypto") = require("crypto");
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
||||
const nodeCrypto = require("crypto");
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
return nodeCrypto.createHash("sha256").update(input).digest("hex");
|
||||
} catch {
|
||||
throw new Error("No suitable crypto implementation available for hashing.");
|
||||
|
||||
@@ -434,7 +434,7 @@ export function validateParameters(
|
||||
// Unknown parameter type
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message: `Unknown parameter type '${paramDef.type}' for '${paramDef.name}'`,
|
||||
message: `Unknown parameter type '${String(paramDef.type)}' for '${paramDef.name}'`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
@@ -723,9 +723,7 @@ export function groupIssuesByEntity(
|
||||
|
||||
issues.forEach((issue) => {
|
||||
const entityId = issue.actionId ?? issue.stepId ?? "experiment";
|
||||
if (!grouped[entityId]) {
|
||||
grouped[entityId] = [];
|
||||
}
|
||||
grouped[entityId] ??= [];
|
||||
grouped[entityId].push(issue);
|
||||
});
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "hristudio-theme",
|
||||
attribute = "class",
|
||||
attribute: _attribute = "class",
|
||||
enableSystem = true,
|
||||
disableTransitionOnChange = false,
|
||||
...props
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { useTheme } from "./theme-provider";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme, theme } = useTheme();
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -268,7 +268,7 @@ function TrialCard({ trial, userRole, onTrialAction }: TrialCardProps) {
|
||||
}
|
||||
|
||||
export function TrialsGrid() {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
|
||||
const { data: userSession } = api.auth.me.useQuery();
|
||||
@@ -282,7 +282,15 @@ export function TrialsGrid() {
|
||||
{
|
||||
page: 1,
|
||||
limit: 50,
|
||||
status: statusFilter === "all" ? undefined : (statusFilter as any),
|
||||
status:
|
||||
statusFilter === "all"
|
||||
? undefined
|
||||
: (statusFilter as
|
||||
| "scheduled"
|
||||
| "in_progress"
|
||||
| "completed"
|
||||
| "aborted"
|
||||
| "failed"),
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
@@ -309,16 +317,13 @@ export function TrialsGrid() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrialCreated = () => {
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
void refetch();
|
||||
};
|
||||
|
||||
|
||||
// Group trials by status for better organization
|
||||
const upcomingTrials = trials.filter((t) => t.status === "scheduled");
|
||||
const activeTrials = trials.filter((t) => t.status === "in_progress");
|
||||
const completedTrials = trials.filter((t) => t.status === "completed");
|
||||
const cancelledTrials = trials.filter((t) => t.status === "aborted");
|
||||
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -2,20 +2,35 @@
|
||||
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
Activity, AlertTriangle, ArrowRight, Bot, Camera, CheckCircle, Eye, Hand, MessageSquare, Pause, Play, Settings, User, Volume2, XCircle
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Bot,
|
||||
Camera,
|
||||
CheckCircle,
|
||||
Eye,
|
||||
Hand,
|
||||
MessageSquare,
|
||||
Pause,
|
||||
Play,
|
||||
Settings,
|
||||
User,
|
||||
Volume2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { api } from "~/trpc/react";
|
||||
import type { WebSocketMessage } from "~/hooks/useWebSocket";
|
||||
|
||||
interface EventsLogProps {
|
||||
trialId: string;
|
||||
refreshKey: number;
|
||||
isLive: boolean;
|
||||
maxEvents?: number;
|
||||
realtimeEvents?: any[];
|
||||
realtimeEvents?: WebSocketMessage[];
|
||||
isWebSocketConnected?: boolean;
|
||||
}
|
||||
|
||||
@@ -24,7 +39,7 @@ interface TrialEvent {
|
||||
trialId: string;
|
||||
eventType: string;
|
||||
timestamp: Date;
|
||||
data: any;
|
||||
data: Record<string, unknown> | null;
|
||||
notes: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -177,7 +192,17 @@ export function EventsLog({
|
||||
{
|
||||
trialId,
|
||||
limit: maxEvents,
|
||||
type: filter === "all" ? undefined : filter as "error" | "custom" | "trial_start" | "trial_end" | "step_start" | "step_end" | "wizard_intervention",
|
||||
type:
|
||||
filter === "all"
|
||||
? undefined
|
||||
: (filter as
|
||||
| "error"
|
||||
| "custom"
|
||||
| "trial_start"
|
||||
| "trial_end"
|
||||
| "step_start"
|
||||
| "step_end"
|
||||
| "wizard_intervention"),
|
||||
},
|
||||
{
|
||||
refetchInterval: isLive && !isWebSocketConnected ? 2000 : 10000, // Less frequent polling when WebSocket is active
|
||||
@@ -186,23 +211,48 @@ export function EventsLog({
|
||||
},
|
||||
);
|
||||
|
||||
// Convert WebSocket events to trial events format
|
||||
const convertWebSocketEvent = (wsEvent: any): TrialEvent => ({
|
||||
id: `ws-${Date.now()}-${Math.random()}`,
|
||||
trialId,
|
||||
eventType:
|
||||
wsEvent.type === "trial_action_executed"
|
||||
? "wizard_action"
|
||||
: wsEvent.type === "intervention_logged"
|
||||
? "wizard_intervention"
|
||||
: wsEvent.type === "step_changed"
|
||||
? "step_transition"
|
||||
: wsEvent.type || "system_event",
|
||||
timestamp: new Date(wsEvent.data?.timestamp || Date.now()),
|
||||
data: wsEvent.data || {},
|
||||
notes: wsEvent.data?.notes || null,
|
||||
createdAt: new Date(wsEvent.data?.timestamp || Date.now()),
|
||||
});
|
||||
// Convert WebSocket events to trial events format (type-safe)
|
||||
const convertWebSocketEvent = useCallback(
|
||||
(wsEvent: WebSocketMessage): TrialEvent => {
|
||||
const eventType =
|
||||
wsEvent.type === "trial_action_executed"
|
||||
? "wizard_action"
|
||||
: wsEvent.type === "intervention_logged"
|
||||
? "wizard_intervention"
|
||||
: wsEvent.type === "step_changed"
|
||||
? "step_transition"
|
||||
: wsEvent.type || "system_event";
|
||||
|
||||
const rawData = wsEvent.data;
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null;
|
||||
|
||||
const data: Record<string, unknown> | null = isRecord(rawData)
|
||||
? rawData
|
||||
: null;
|
||||
|
||||
const ts =
|
||||
isRecord(rawData) && typeof rawData.timestamp === "number"
|
||||
? rawData.timestamp
|
||||
: Date.now();
|
||||
|
||||
const notes =
|
||||
isRecord(rawData) && typeof rawData.notes === "string"
|
||||
? rawData.notes
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: `ws-${Date.now()}-${Math.random()}`,
|
||||
trialId,
|
||||
eventType,
|
||||
timestamp: new Date(ts),
|
||||
data,
|
||||
notes,
|
||||
createdAt: new Date(ts),
|
||||
};
|
||||
},
|
||||
[trialId],
|
||||
);
|
||||
|
||||
// Update events when data changes (prioritize WebSocket events)
|
||||
useEffect(() => {
|
||||
@@ -210,11 +260,26 @@ export function EventsLog({
|
||||
|
||||
// Add database events
|
||||
if (eventsData) {
|
||||
newEvents = eventsData.map((event) => ({
|
||||
...event,
|
||||
type ApiTrialEvent = {
|
||||
id: string;
|
||||
trialId: string;
|
||||
eventType: string;
|
||||
timestamp: string | Date;
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
const apiEvents = (eventsData as unknown as ApiTrialEvent[]) ?? [];
|
||||
newEvents = apiEvents.map((event) => ({
|
||||
id: event.id,
|
||||
trialId: event.trialId,
|
||||
eventType: event.eventType,
|
||||
timestamp: new Date(event.timestamp),
|
||||
data:
|
||||
typeof event.data === "object" && event.data !== null
|
||||
? (event.data as Record<string, unknown>)
|
||||
: null,
|
||||
notes: null,
|
||||
createdAt: new Date(event.timestamp),
|
||||
notes: null, // Add required field
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -240,7 +305,14 @@ export function EventsLog({
|
||||
.slice(-maxEvents); // Keep only the most recent events
|
||||
|
||||
setEvents(uniqueEvents);
|
||||
}, [eventsData, refreshKey, realtimeEvents, trialId, maxEvents]);
|
||||
}, [
|
||||
eventsData,
|
||||
refreshKey,
|
||||
realtimeEvents,
|
||||
trialId,
|
||||
maxEvents,
|
||||
convertWebSocketEvent,
|
||||
]);
|
||||
|
||||
// Auto-scroll to bottom when new events arrive
|
||||
useEffect(() => {
|
||||
@@ -256,41 +328,87 @@ export function EventsLog({
|
||||
);
|
||||
};
|
||||
|
||||
const formatEventData = (eventType: string, data: any) => {
|
||||
const formatEventData = (
|
||||
eventType: string,
|
||||
data: Record<string, unknown> | null,
|
||||
): string | null => {
|
||||
if (!data) return null;
|
||||
|
||||
const str = (k: string): string | undefined => {
|
||||
const v = data[k];
|
||||
return typeof v === "string" ? v : undefined;
|
||||
};
|
||||
const num = (k: string): number | undefined => {
|
||||
const v = data[k];
|
||||
return typeof v === "number" ? v : undefined;
|
||||
};
|
||||
|
||||
switch (eventType) {
|
||||
case "step_transition":
|
||||
return `Step ${data.from_step + 1} → Step ${data.to_step + 1}${data.step_name ? `: ${data.step_name}` : ""}`;
|
||||
case "step_transition": {
|
||||
const fromIdx = num("from_step");
|
||||
const toIdx = num("to_step");
|
||||
const stepName = str("step_name");
|
||||
if (typeof toIdx === "number") {
|
||||
const fromLabel =
|
||||
typeof fromIdx === "number" ? `${fromIdx + 1} → ` : "";
|
||||
const nameLabel = stepName ? `: ${stepName}` : "";
|
||||
return `Step ${fromLabel}${toIdx + 1}${nameLabel}`;
|
||||
}
|
||||
return "Step changed";
|
||||
}
|
||||
|
||||
case "wizard_action":
|
||||
return `${data.action_type ? data.action_type.replace(/_/g, " ") : "Action executed"}${data.step_name ? ` in ${data.step_name}` : ""}`;
|
||||
case "wizard_action": {
|
||||
const actionType = str("action_type");
|
||||
const stepName = str("step_name");
|
||||
const actionLabel = actionType
|
||||
? actionType.replace(/_/g, " ")
|
||||
: "Action executed";
|
||||
const inStep = stepName ? ` in ${stepName}` : "";
|
||||
return `${actionLabel}${inStep}`;
|
||||
}
|
||||
|
||||
case "robot_action":
|
||||
return `${data.action_name || "Robot action"}${data.parameters ? ` with parameters` : ""}`;
|
||||
case "robot_action": {
|
||||
const actionName = str("action_name") ?? "Robot action";
|
||||
const hasParams =
|
||||
typeof data.parameters !== "undefined" && data.parameters !== null;
|
||||
return `${actionName}${hasParams ? " with parameters" : ""}`;
|
||||
}
|
||||
|
||||
case "emergency_action":
|
||||
return `Emergency: ${data.emergency_type ? data.emergency_type.replace(/_/g, " ") : "Unknown"}`;
|
||||
case "emergency_action": {
|
||||
const emergency = str("emergency_type");
|
||||
return `Emergency: ${
|
||||
emergency ? emergency.replace(/_/g, " ") : "Unknown"
|
||||
}`;
|
||||
}
|
||||
|
||||
case "recording_control":
|
||||
return `Recording ${data.action === "start_recording" ? "started" : "stopped"}`;
|
||||
case "recording_control": {
|
||||
const action = str("action");
|
||||
return `Recording ${action === "start_recording" ? "started" : "stopped"}`;
|
||||
}
|
||||
|
||||
case "video_control":
|
||||
return `Video ${data.action === "video_on" ? "enabled" : "disabled"}`;
|
||||
case "video_control": {
|
||||
const action = str("action");
|
||||
return `Video ${action === "video_on" ? "enabled" : "disabled"}`;
|
||||
}
|
||||
|
||||
case "audio_control":
|
||||
return `Audio ${data.action === "audio_on" ? "enabled" : "disabled"}`;
|
||||
case "audio_control": {
|
||||
const action = str("action");
|
||||
return `Audio ${action === "audio_on" ? "enabled" : "disabled"}`;
|
||||
}
|
||||
|
||||
case "wizard_intervention":
|
||||
case "wizard_intervention": {
|
||||
return (
|
||||
data.content || data.intervention_type || "Intervention recorded"
|
||||
str("content") ?? str("intervention_type") ?? "Intervention recorded"
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
if (typeof data === "string") return data;
|
||||
if (data.message) return data.message;
|
||||
if (data.description) return data.description;
|
||||
default: {
|
||||
const message = str("message");
|
||||
if (message) return message;
|
||||
const description = str("description");
|
||||
if (description) return description;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -305,7 +423,8 @@ export function EventsLog({
|
||||
if (
|
||||
index === 0 ||
|
||||
Math.abs(
|
||||
event.timestamp.getTime() - (events[index - 1]?.timestamp.getTime() ?? 0),
|
||||
event.timestamp.getTime() -
|
||||
(events[index - 1]?.timestamp.getTime() ?? 0),
|
||||
) > 30000
|
||||
) {
|
||||
groups.push([event]);
|
||||
@@ -317,7 +436,7 @@ export function EventsLog({
|
||||
[],
|
||||
);
|
||||
|
||||
const uniqueEventTypes = Array.from(new Set(events.map((e) => e.eventType)));
|
||||
// uniqueEventTypes removed (unused)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -433,9 +552,11 @@ export function EventsLog({
|
||||
</div>
|
||||
<div className="h-px flex-1 bg-slate-200"></div>
|
||||
<div className="text-xs text-slate-400">
|
||||
{group[0] ? formatDistanceToNow(group[0].timestamp, {
|
||||
addSuffix: true,
|
||||
}) : ""}
|
||||
{group[0]
|
||||
? formatDistanceToNow(group[0].timestamp, {
|
||||
addSuffix: true,
|
||||
})
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -503,20 +624,22 @@ export function EventsLog({
|
||||
|
||||
{event.notes && (
|
||||
<p className="mt-1 text-xs text-slate-500 italic">
|
||||
"{event.notes}"
|
||||
{event.notes}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{event.data && Object.keys(event.data).length > 0 && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-blue-600 hover:text-blue-800">
|
||||
View details
|
||||
</summary>
|
||||
<pre className="mt-1 overflow-x-auto rounded border bg-white p-2 text-xs text-slate-600">
|
||||
{JSON.stringify(event.data, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
{event.data &&
|
||||
typeof event.data === "object" &&
|
||||
Object.keys(event.data).length > 0 && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-blue-600 hover:text-blue-800">
|
||||
View details
|
||||
</summary>
|
||||
<pre className="mt-1 overflow-x-auto rounded border bg-white p-2 text-xs text-slate-600">
|
||||
{JSON.stringify(event.data, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 text-xs text-slate-400">
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -106,13 +107,19 @@ const statusConfig = {
|
||||
};
|
||||
|
||||
function TrialActionsCell({ trial }: { trial: Trial }) {
|
||||
const startTrialMutation = api.trials.start.useMutation();
|
||||
const completeTrialMutation = api.trials.complete.useMutation();
|
||||
const abortTrialMutation = api.trials.abort.useMutation();
|
||||
// const deleteTrialMutation = api.trials.delete.useMutation();
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (
|
||||
window.confirm(`Are you sure you want to delete trial "${trial.name}"?`)
|
||||
) {
|
||||
try {
|
||||
// Delete trial functionality not yet implemented
|
||||
toast.success("Trial deleted successfully");
|
||||
// await deleteTrialMutation.mutateAsync({ id: trial.id });
|
||||
toast.success("Trial deletion not yet implemented");
|
||||
// window.location.reload();
|
||||
} catch {
|
||||
toast.error("Failed to delete trial");
|
||||
}
|
||||
@@ -124,14 +131,22 @@ function TrialActionsCell({ trial }: { trial: Trial }) {
|
||||
toast.success("Trial ID copied to clipboard");
|
||||
};
|
||||
|
||||
const handleStartTrial = () => {
|
||||
window.location.href = `/trials/${trial.id}/wizard`;
|
||||
const handleStartTrial = async () => {
|
||||
try {
|
||||
await startTrialMutation.mutateAsync({ id: trial.id });
|
||||
toast.success("Trial started successfully");
|
||||
window.location.href = `/trials/${trial.id}/wizard`;
|
||||
} catch {
|
||||
toast.error("Failed to start trial");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePauseTrial = async () => {
|
||||
try {
|
||||
// Pause trial functionality not yet implemented
|
||||
toast.success("Trial paused");
|
||||
// For now, pausing means completing the trial
|
||||
await completeTrialMutation.mutateAsync({ id: trial.id });
|
||||
toast.success("Trial paused/completed");
|
||||
window.location.reload();
|
||||
} catch {
|
||||
toast.error("Failed to pause trial");
|
||||
}
|
||||
@@ -140,8 +155,9 @@ function TrialActionsCell({ trial }: { trial: Trial }) {
|
||||
const handleStopTrial = async () => {
|
||||
if (window.confirm("Are you sure you want to stop this trial?")) {
|
||||
try {
|
||||
// Stop trial functionality not yet implemented
|
||||
await abortTrialMutation.mutateAsync({ id: trial.id });
|
||||
toast.success("Trial stopped");
|
||||
window.location.reload();
|
||||
} catch {
|
||||
toast.error("Failed to stop trial");
|
||||
}
|
||||
|
||||
@@ -180,12 +180,18 @@ export function TrialsDataTable() {
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Trials"
|
||||
description="Monitor and manage trial execution for your HRI experiments"
|
||||
description="Schedule and manage trials for your HRI studies"
|
||||
icon={TestTube}
|
||||
actions={
|
||||
<ActionButton href="/trials/new">
|
||||
<ActionButton
|
||||
href={
|
||||
selectedStudyId
|
||||
? `/studies/${selectedStudyId}/trials/new`
|
||||
: "/trials/new"
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Trial
|
||||
Schedule Trial
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
@@ -210,12 +216,18 @@ export function TrialsDataTable() {
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Trials"
|
||||
description="Monitor and manage trial execution for your HRI experiments"
|
||||
description="Schedule and manage trials for your HRI studies"
|
||||
icon={TestTube}
|
||||
actions={
|
||||
<ActionButton href="/trials/new">
|
||||
<ActionButton
|
||||
href={
|
||||
selectedStudyId
|
||||
? `/studies/${selectedStudyId}/trials/new`
|
||||
: "/trials/new"
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Trial
|
||||
Schedule Trial
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,43 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertTriangle, Camera, Clock, Hand, HelpCircle, Lightbulb, MessageSquare, Pause,
|
||||
Play,
|
||||
RotateCcw, Target, Video,
|
||||
VideoOff, Volume2,
|
||||
VolumeX, Zap
|
||||
AlertTriangle,
|
||||
Camera,
|
||||
Clock,
|
||||
Hand,
|
||||
HelpCircle,
|
||||
Lightbulb,
|
||||
MessageSquare,
|
||||
Pause,
|
||||
RotateCcw,
|
||||
Target,
|
||||
Video,
|
||||
VideoOff,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
|
||||
interface ActionControlsProps {
|
||||
trialId: string;
|
||||
currentStep: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional_branch";
|
||||
parameters?: any;
|
||||
actions?: any[];
|
||||
type:
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
description?: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
duration?: number;
|
||||
} | null;
|
||||
onExecuteAction: (actionType: string, actionData: any) => Promise<void>;
|
||||
trialId: string;
|
||||
onActionComplete: (
|
||||
actionId: string,
|
||||
actionData: Record<string, unknown>,
|
||||
) => void;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
interface QuickAction {
|
||||
@@ -50,7 +69,12 @@ interface QuickAction {
|
||||
requiresConfirmation?: boolean;
|
||||
}
|
||||
|
||||
export function ActionControls({ currentStep, onExecuteAction, trialId }: ActionControlsProps) {
|
||||
export function ActionControls({
|
||||
trialId: _trialId,
|
||||
currentStep,
|
||||
onActionComplete,
|
||||
isConnected: _isConnected,
|
||||
}: ActionControlsProps) {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isVideoOn, setIsVideoOn] = useState(true);
|
||||
const [isAudioOn, setIsAudioOn] = useState(true);
|
||||
@@ -119,82 +143,71 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
|
||||
{ value: "cut_power", label: "Emergency Power Cut" },
|
||||
];
|
||||
|
||||
const handleQuickAction = async (action: QuickAction) => {
|
||||
const handleQuickAction = (action: QuickAction) => {
|
||||
if (action.requiresConfirmation) {
|
||||
setShowEmergencyDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onExecuteAction(action.action, {
|
||||
action_id: action.id,
|
||||
step_id: currentStep?.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (_error) {
|
||||
console.error(`Failed to execute ${action.action}:`, _error);
|
||||
}
|
||||
onActionComplete(action.id, {
|
||||
action_type: action.action,
|
||||
notes: action.description,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleEmergencyAction = async () => {
|
||||
const handleEmergencyAction = () => {
|
||||
if (!selectedEmergencyAction) return;
|
||||
|
||||
try {
|
||||
await onExecuteAction("emergency_action", {
|
||||
emergency_type: selectedEmergencyAction,
|
||||
step_id: currentStep?.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: "high",
|
||||
});
|
||||
setShowEmergencyDialog(false);
|
||||
setSelectedEmergencyAction("");
|
||||
} catch (_error) {
|
||||
console.error("Failed to execute emergency action:", _error);
|
||||
}
|
||||
onActionComplete("emergency_action", {
|
||||
emergency_type: selectedEmergencyAction,
|
||||
notes: interventionNote || "Emergency action executed",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
setShowEmergencyDialog(false);
|
||||
setSelectedEmergencyAction("");
|
||||
setInterventionNote("");
|
||||
};
|
||||
|
||||
const handleInterventionSubmit = async () => {
|
||||
const handleInterventionSubmit = () => {
|
||||
if (!interventionNote.trim()) return;
|
||||
|
||||
try {
|
||||
await onExecuteAction("wizard_intervention", {
|
||||
intervention_type: "note",
|
||||
content: interventionNote,
|
||||
step_id: currentStep?.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
setInterventionNote("");
|
||||
setIsCommunicationOpen(false);
|
||||
} catch (_error) {
|
||||
console.error("Failed to submit intervention:", _error);
|
||||
}
|
||||
onActionComplete("wizard_intervention", {
|
||||
intervention_type: "note",
|
||||
content: interventionNote,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
setInterventionNote("");
|
||||
setIsCommunicationOpen(false);
|
||||
};
|
||||
|
||||
const toggleRecording = async () => {
|
||||
const toggleRecording = () => {
|
||||
const newState = !isRecording;
|
||||
setIsRecording(newState);
|
||||
|
||||
await onExecuteAction("recording_control", {
|
||||
onActionComplete("recording_control", {
|
||||
action: newState ? "start_recording" : "stop_recording",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
const toggleVideo = async () => {
|
||||
const toggleVideo = () => {
|
||||
const newState = !isVideoOn;
|
||||
setIsVideoOn(newState);
|
||||
|
||||
await onExecuteAction("video_control", {
|
||||
onActionComplete("video_control", {
|
||||
action: newState ? "video_on" : "video_off",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAudio = async () => {
|
||||
const toggleAudio = () => {
|
||||
const newState = !isAudioOn;
|
||||
setIsAudioOn(newState);
|
||||
|
||||
await onExecuteAction("audio_control", {
|
||||
onActionComplete("audio_control", {
|
||||
action: newState ? "audio_on" : "audio_off",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
@@ -217,7 +230,9 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
|
||||
onClick={toggleRecording}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<div className={`w-2 h-2 rounded-full ${isRecording ? "bg-white animate-pulse" : "bg-red-500"}`}></div>
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${isRecording ? "animate-pulse" : ""}`}
|
||||
></div>
|
||||
<span>{isRecording ? "Stop Recording" : "Start Recording"}</span>
|
||||
</Button>
|
||||
|
||||
@@ -226,7 +241,11 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
|
||||
onClick={toggleVideo}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
{isVideoOn ? <Video className="h-4 w-4" /> : <VideoOff className="h-4 w-4" />}
|
||||
{isVideoOn ? (
|
||||
<Video className="h-4 w-4" />
|
||||
) : (
|
||||
<VideoOff className="h-4 w-4" />
|
||||
)}
|
||||
<span>Video</span>
|
||||
</Button>
|
||||
|
||||
@@ -235,7 +254,11 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
|
||||
onClick={toggleAudio}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
{isAudioOn ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
|
||||
{isAudioOn ? (
|
||||
<Volume2 className="h-4 w-4" />
|
||||
) : (
|
||||
<VolumeX className="h-4 w-4" />
|
||||
)}
|
||||
<span>Audio</span>
|
||||
</Button>
|
||||
|
||||
@@ -265,15 +288,18 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
|
||||
<Button
|
||||
key={action.id}
|
||||
variant={
|
||||
action.type === "emergency" ? "destructive" :
|
||||
action.type === "primary" ? "default" : "outline"
|
||||
action.type === "emergency"
|
||||
? "destructive"
|
||||
: action.type === "primary"
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => handleQuickAction(action)}
|
||||
className="flex items-center justify-start space-x-3 h-12"
|
||||
className="flex h-12 items-center justify-start space-x-3"
|
||||
>
|
||||
<action.icon className="h-4 w-4 flex-shrink-0" />
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-medium">{action.label}</div>
|
||||
<h4 className="font-medium">{action.label}</h4>
|
||||
<div className="text-xs opacity-75">{action.description}</div>
|
||||
</div>
|
||||
</Button>
|
||||
@@ -293,29 +319,14 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-slate-600">
|
||||
Current step: <span className="font-medium">{currentStep.name}</span>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Current step:{" "}
|
||||
<span className="font-medium">{currentStep.name}</span>
|
||||
</div>
|
||||
|
||||
{currentStep.actions && currentStep.actions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Available Actions:</Label>
|
||||
<div className="grid gap-2">
|
||||
{currentStep.actions.map((action: any, index: number) => (
|
||||
<Button
|
||||
key={action.id || index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onExecuteAction(`step_action_${action.id}`, action)}
|
||||
className="justify-start text-left"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-2" />
|
||||
{action.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Use the controls below to execute wizard actions for this step.
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -343,8 +354,8 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4 text-slate-500" />
|
||||
<span className="text-sm text-slate-500">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{new Date().toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -370,18 +381,22 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
|
||||
<Dialog open={showEmergencyDialog} onOpenChange={setShowEmergencyDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center space-x-2 text-red-600">
|
||||
<DialogTitle className="flex items-center space-x-2">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<span>Emergency Action Required</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select the type of emergency action to perform. This will immediately stop or override current robot operations.
|
||||
Select the type of emergency action to perform. This will
|
||||
immediately stop or override current robot operations.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="emergency-select">Emergency Action Type</Label>
|
||||
<Select value={selectedEmergencyAction} onValueChange={setSelectedEmergencyAction}>
|
||||
<Select
|
||||
value={selectedEmergencyAction}
|
||||
onValueChange={setSelectedEmergencyAction}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select emergency action..." />
|
||||
</SelectTrigger>
|
||||
@@ -394,11 +409,13 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex items-start space-x-2">
|
||||
<AlertTriangle className="h-4 w-4 text-red-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-red-800">
|
||||
<strong>Warning:</strong> Emergency actions will immediately halt all robot operations and may require manual intervention to resume.
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<strong>Warning:</strong> Emergency actions will immediately
|
||||
halt all robot operations and may require manual intervention
|
||||
to resume.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
151
src/components/trials/wizard/EventsLogSidebar.tsx
Normal file
151
src/components/trials/wizard/EventsLogSidebar.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { Clock, Activity, User, Bot, AlertCircle } from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { WebSocketMessage } from "~/hooks/useWebSocket";
|
||||
|
||||
interface EventsLogSidebarProps {
|
||||
events: WebSocketMessage[];
|
||||
maxEvents?: number;
|
||||
showTimestamps?: boolean;
|
||||
}
|
||||
|
||||
const getEventIcon = (eventType: string) => {
|
||||
switch (eventType) {
|
||||
case "trial_status":
|
||||
case "trial_action_executed":
|
||||
return Activity;
|
||||
case "step_changed":
|
||||
return Clock;
|
||||
case "wizard_intervention":
|
||||
case "intervention_logged":
|
||||
return User;
|
||||
case "robot_action":
|
||||
return Bot;
|
||||
case "error":
|
||||
return AlertCircle;
|
||||
default:
|
||||
return Activity;
|
||||
}
|
||||
};
|
||||
|
||||
const getEventVariant = (eventType: string) => {
|
||||
switch (eventType) {
|
||||
case "error":
|
||||
return "destructive" as const;
|
||||
case "wizard_intervention":
|
||||
case "intervention_logged":
|
||||
return "secondary" as const;
|
||||
case "trial_status":
|
||||
return "default" as const;
|
||||
default:
|
||||
return "outline" as const;
|
||||
}
|
||||
};
|
||||
|
||||
const formatEventData = (event: WebSocketMessage): string => {
|
||||
switch (event.type) {
|
||||
case "trial_status":
|
||||
const trialData = event.data as { trial: { status: string } };
|
||||
return `Trial status: ${trialData.trial.status}`;
|
||||
|
||||
case "step_changed":
|
||||
const stepData = event.data as {
|
||||
to_step: number;
|
||||
step_name?: string;
|
||||
};
|
||||
return `Step ${stepData.to_step + 1}${stepData.step_name ? `: ${stepData.step_name}` : ""}`;
|
||||
|
||||
case "trial_action_executed":
|
||||
const actionData = event.data as { action_type: string };
|
||||
return `Action: ${actionData.action_type}`;
|
||||
|
||||
case "wizard_intervention":
|
||||
case "intervention_logged":
|
||||
const interventionData = event.data as { content?: string };
|
||||
return interventionData.content ?? "Wizard intervention";
|
||||
|
||||
case "error":
|
||||
const errorData = event.data as { message?: string };
|
||||
return errorData.message ?? "System error";
|
||||
|
||||
default:
|
||||
return `Event: ${event.type}`;
|
||||
}
|
||||
};
|
||||
|
||||
const getEventTimestamp = (event: WebSocketMessage): Date => {
|
||||
const data = event.data as { timestamp?: number };
|
||||
return data.timestamp ? new Date(data.timestamp) : new Date();
|
||||
};
|
||||
|
||||
export function EventsLogSidebar({
|
||||
events,
|
||||
maxEvents = 10,
|
||||
showTimestamps = true,
|
||||
}: EventsLogSidebarProps) {
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
const displayEvents = events.slice(-maxEvents).reverse();
|
||||
|
||||
if (displayEvents.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Clock className="text-muted-foreground mb-3 h-8 w-8" />
|
||||
<p className="text-muted-foreground text-sm">No events yet</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Events will appear here during trial execution
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-3">
|
||||
{displayEvents.map((event, index) => {
|
||||
const Icon = getEventIcon(event.type);
|
||||
const timestamp = getEventTimestamp(event);
|
||||
const eventText = formatEventData(event);
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
<div className="bg-muted rounded-full p-1.5">
|
||||
<Icon className="h-3 w-3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<Badge
|
||||
variant={getEventVariant(event.type)}
|
||||
className="text-xs"
|
||||
>
|
||||
{event.type.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
{showTimestamps && isClient && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatDistanceToNow(timestamp, { addSuffix: true })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-foreground text-sm break-words">
|
||||
{eventText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
330
src/components/trials/wizard/ExecutionStepDisplay.tsx
Normal file
330
src/components/trials/wizard/ExecutionStepDisplay.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle, Clock, PlayCircle, AlertCircle, Eye } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
|
||||
interface ActionDefinition {
|
||||
id: string;
|
||||
stepId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
type: string;
|
||||
orderIndex: number;
|
||||
parameters: Record<string, unknown>;
|
||||
timeout?: number;
|
||||
required: boolean;
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
interface StepDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
type: string;
|
||||
orderIndex: number;
|
||||
condition?: string;
|
||||
actions: ActionDefinition[];
|
||||
}
|
||||
|
||||
interface ExecutionContext {
|
||||
trialId: string;
|
||||
experimentId: string;
|
||||
participantId: string;
|
||||
wizardId?: string;
|
||||
currentStepIndex: number;
|
||||
startTime: Date;
|
||||
variables: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ExecutionStepDisplayProps {
|
||||
currentStep: StepDefinition | null;
|
||||
executionContext: ExecutionContext | null;
|
||||
totalSteps: number;
|
||||
onExecuteStep: () => void;
|
||||
onAdvanceStep: () => void;
|
||||
onCompleteWizardAction: (
|
||||
actionId: string,
|
||||
data?: Record<string, unknown>,
|
||||
) => void;
|
||||
isExecuting: boolean;
|
||||
}
|
||||
|
||||
export function ExecutionStepDisplay({
|
||||
currentStep,
|
||||
executionContext,
|
||||
totalSteps,
|
||||
onExecuteStep,
|
||||
onAdvanceStep,
|
||||
onCompleteWizardAction,
|
||||
isExecuting,
|
||||
}: ExecutionStepDisplayProps) {
|
||||
if (!currentStep || !executionContext) {
|
||||
return (
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="text-muted-foreground">
|
||||
<Clock className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p className="text-sm">No active step</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Trial may not be started or all steps completed
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const progress =
|
||||
totalSteps > 0
|
||||
? ((executionContext.currentStepIndex + 1) / totalSteps) * 100
|
||||
: 0;
|
||||
|
||||
const getActionConfig = (
|
||||
type: string,
|
||||
): { icon: typeof PlayCircle; label: string } => {
|
||||
const configs: Record<string, { icon: typeof PlayCircle; label: string }> =
|
||||
{
|
||||
wizard_say: {
|
||||
icon: PlayCircle,
|
||||
label: "Wizard Speech",
|
||||
},
|
||||
wizard_gesture: {
|
||||
icon: PlayCircle,
|
||||
label: "Wizard Gesture",
|
||||
},
|
||||
wizard_show_object: {
|
||||
icon: Eye,
|
||||
label: "Show Object",
|
||||
},
|
||||
observe_behavior: {
|
||||
icon: Eye,
|
||||
label: "Observe Behavior",
|
||||
},
|
||||
wait: { icon: Clock, label: "Wait" },
|
||||
};
|
||||
|
||||
return (
|
||||
configs[type] ?? {
|
||||
icon: PlayCircle,
|
||||
label: "Action",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const getWizardInstructions = (action: ActionDefinition): string => {
|
||||
switch (action.type) {
|
||||
case "wizard_say":
|
||||
return `Say: "${String(action.parameters.text) ?? "Please speak to the participant"}";`;
|
||||
case "wizard_gesture":
|
||||
return `Perform gesture: ${String(action.parameters.gesture) ?? "as specified in the protocol"}`;
|
||||
case "wizard_show_object":
|
||||
return `Show object: ${String(action.parameters.object) ?? "as specified in the protocol"}`;
|
||||
case "observe_behavior":
|
||||
return `Observe and record: ${String(action.parameters.behavior) ?? "participant behavior"}`;
|
||||
case "wait":
|
||||
return `Wait for ${String(action.parameters.duration) ?? "1000"}ms`;
|
||||
default:
|
||||
return `Execute: ${action.name ?? "Unknown Action"}`;
|
||||
}
|
||||
};
|
||||
|
||||
const requiresWizardInput = (action: ActionDefinition): boolean => {
|
||||
return [
|
||||
"wizard_say",
|
||||
"wizard_gesture",
|
||||
"wizard_show_object",
|
||||
"observe_behavior",
|
||||
].includes(action.type);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Step Progress */}
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg font-semibold">
|
||||
Step {executionContext.currentStepIndex + 1} of {totalSteps}
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{Math.round(progress)}% Complete
|
||||
</Badge>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">{currentStep.name}</h3>
|
||||
{currentStep.description && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{currentStep.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{currentStep.type
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{currentStep.actions.length} action
|
||||
{currentStep.actions.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Step Actions */}
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">
|
||||
Step Actions
|
||||
</CardTitle>
|
||||
<Button
|
||||
onClick={onExecuteStep}
|
||||
disabled={isExecuting}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
<PlayCircle className="mr-1 h-3 w-3" />
|
||||
{isExecuting ? "Executing..." : "Execute Step"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-3">
|
||||
{currentStep.actions?.map((action, _index) => {
|
||||
const config = getActionConfig(action.type);
|
||||
const Icon = config.icon;
|
||||
const needsWizardInput = requiresWizardInput(action);
|
||||
|
||||
return (
|
||||
<div key={action.id} className="rounded-lg border p-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">
|
||||
{action.name}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.label}
|
||||
</Badge>
|
||||
{action.required && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{action.description && (
|
||||
<p className="text-muted-foreground ml-6 text-xs">
|
||||
{action.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{needsWizardInput && (
|
||||
<Alert className="mt-2 ml-6">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
{getWizardInstructions(action)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Action Parameters */}
|
||||
{Object.keys(action.parameters).length > 0 && (
|
||||
<div className="mt-2 ml-6">
|
||||
<details className="text-xs">
|
||||
<summary className="text-muted-foreground cursor-pointer">
|
||||
Parameters (
|
||||
{Object.keys(action.parameters).length})
|
||||
</summary>
|
||||
<div className="mt-1 space-y-1">
|
||||
{Object.entries(action.parameters).map(
|
||||
([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex justify-between text-xs"
|
||||
>
|
||||
<span className="text-muted-foreground font-mono">
|
||||
{key}:
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
{typeof value === "string"
|
||||
? `"${value}"`
|
||||
: String(value)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{needsWizardInput && (
|
||||
<Button
|
||||
onClick={() => onCompleteWizardAction(action.id, {})}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
Complete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Step Controls */}
|
||||
<div className="mt-4 flex justify-end space-x-2">
|
||||
<Button
|
||||
onClick={onAdvanceStep}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isExecuting}
|
||||
>
|
||||
Next Step
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Execution Variables (if any) */}
|
||||
{Object.keys(executionContext.variables).length > 0 && (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-medium">
|
||||
Execution Variables
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-1">
|
||||
{Object.entries(executionContext.variables).map(
|
||||
([key, value]) => (
|
||||
<div key={key} className="flex justify-between text-xs">
|
||||
<span className="font-mono text-slate-600">{key}:</span>
|
||||
<span className="font-mono text-slate-900">
|
||||
{typeof value === "string" ? `"${value}"` : String(value)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Briefcase, Clock, GraduationCap, Info, Mail, Shield, User
|
||||
} from "lucide-react";
|
||||
import { Briefcase, Clock, GraduationCap, Info, Shield } from "lucide-react";
|
||||
import { Avatar, AvatarFallback } from "~/components/ui/avatar";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
|
||||
interface ParticipantInfoProps {
|
||||
participant: {
|
||||
id: string;
|
||||
participantCode: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
demographics: any;
|
||||
demographics: Record<string, unknown> | null;
|
||||
};
|
||||
trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
}
|
||||
|
||||
export function ParticipantInfo({ participant }: ParticipantInfoProps) {
|
||||
const demographics = participant.demographics || {};
|
||||
export function ParticipantInfo({
|
||||
participant,
|
||||
trialStatus: _trialStatus,
|
||||
}: ParticipantInfoProps) {
|
||||
const demographics = participant.demographics ?? {};
|
||||
|
||||
// Extract common demographic fields
|
||||
const age = demographics.age;
|
||||
const gender = demographics.gender;
|
||||
const occupation = demographics.occupation;
|
||||
const education = demographics.education;
|
||||
const language = demographics.primaryLanguage || demographics.language;
|
||||
const location = demographics.location || demographics.city;
|
||||
const experience = demographics.robotExperience || demographics.experience;
|
||||
const age = demographics.age as string | number | undefined;
|
||||
const gender = demographics.gender as string | undefined;
|
||||
const occupation = demographics.occupation as string | undefined;
|
||||
const education = demographics.education as string | undefined;
|
||||
const language =
|
||||
(demographics.primaryLanguage as string | undefined) ??
|
||||
(demographics.language as string | undefined);
|
||||
const experience =
|
||||
(demographics.robotExperience as string | undefined) ??
|
||||
(demographics.experience as string | undefined);
|
||||
|
||||
// Get participant initials for avatar
|
||||
const getInitials = () => {
|
||||
if (participant.name) {
|
||||
const nameParts = participant.name.split(" ");
|
||||
return nameParts.map((part) => part.charAt(0).toUpperCase()).join("");
|
||||
}
|
||||
return participant.participantCode.substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
const formatDemographicValue = (key: string, value: any) => {
|
||||
const formatDemographicValue = (key: string, value: unknown) => {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
|
||||
// Handle different data types
|
||||
@@ -53,81 +51,64 @@ export function ParticipantInfo({ participant }: ParticipantInfoProps) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
return typeof value === "string" ? value : JSON.stringify(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<User className="h-4 w-4 text-slate-600" />
|
||||
<h3 className="font-medium text-slate-900">Participant</h3>
|
||||
</div>
|
||||
|
||||
{/* Basic Info Card */}
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback className="bg-blue-100 font-medium text-blue-600">
|
||||
{getInitials()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-slate-900">
|
||||
{participant.name || "Anonymous"}
|
||||
</div>
|
||||
<div className="text-sm text-slate-600">
|
||||
ID: {participant.participantCode}
|
||||
</div>
|
||||
{participant.email && (
|
||||
<div className="mt-1 flex items-center space-x-1 text-xs text-slate-500">
|
||||
<Mail className="h-3 w-3" />
|
||||
<span className="truncate">{participant.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Basic Info */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback className="font-medium">
|
||||
{getInitials()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-slate-900">
|
||||
Participant {participant.participantCode}
|
||||
</div>
|
||||
<div className="text-sm text-slate-600">
|
||||
ID: {participant.participantCode}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Demographics */}
|
||||
{(age || gender || language) && (
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-1 gap-2 text-sm">
|
||||
{age && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-600">Age:</span>
|
||||
<span className="font-medium">{age}</span>
|
||||
</div>
|
||||
)}
|
||||
{gender && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-600">Gender:</span>
|
||||
<span className="font-medium capitalize">{gender}</span>
|
||||
</div>
|
||||
)}
|
||||
{language && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-600">Language:</span>
|
||||
<span className="font-medium">{language}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{(age ?? gender ?? language) && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="grid grid-cols-1 gap-2 text-sm">
|
||||
{age && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-600">Age:</span>
|
||||
<span className="font-medium">{age}</span>
|
||||
</div>
|
||||
)}
|
||||
{gender && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-600">Gender:</span>
|
||||
<span className="font-medium capitalize">{gender}</span>
|
||||
</div>
|
||||
)}
|
||||
{language && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-600">Language:</span>
|
||||
<span className="font-medium">{language}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Info */}
|
||||
{(occupation || education || experience) && (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center space-x-1 text-sm font-medium text-slate-700">
|
||||
<Info className="h-3 w-3" />
|
||||
<span>Background</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 pt-0">
|
||||
{(occupation ?? education ?? experience) && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-3 flex items-center space-x-1 text-sm font-medium text-slate-700">
|
||||
<Info className="h-3 w-3" />
|
||||
<span>Background</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{occupation && (
|
||||
<div className="flex items-start space-x-2 text-sm">
|
||||
<Briefcase className="mt-0.5 h-3 w-3 flex-shrink-0 text-slate-400" />
|
||||
@@ -155,19 +136,17 @@ export function ParticipantInfo({ participant }: ParticipantInfoProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional Demographics */}
|
||||
{Object.keys(demographics).length > 0 && (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-700">
|
||||
Additional Info
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-3 text-sm font-medium text-slate-700">
|
||||
Additional Info
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(demographics)
|
||||
.filter(
|
||||
@@ -211,30 +190,26 @@ export function ParticipantInfo({ participant }: ParticipantInfoProps) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Consent Status */}
|
||||
<Card className="border-green-200 bg-green-50 shadow-sm">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
||||
<span className="text-sm font-medium text-green-800">
|
||||
Consent Verified
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-green-600">
|
||||
Participant has provided informed consent
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
||||
<span className="text-sm font-medium">Consent Verified</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
Participant has provided informed consent
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Info */}
|
||||
<div className="space-y-1 text-xs text-slate-500">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>Session started: {new Date().toLocaleTimeString()}</span>
|
||||
<span>Session active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity, AlertTriangle, Battery,
|
||||
BatteryLow, Bot, CheckCircle,
|
||||
Clock, RefreshCw, Signal,
|
||||
SignalHigh,
|
||||
SignalLow,
|
||||
SignalMedium, WifiOff
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Battery,
|
||||
BatteryLow,
|
||||
Bot,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
Signal,
|
||||
SignalHigh,
|
||||
SignalLow,
|
||||
SignalMedium,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
|
||||
interface RobotStatusProps {
|
||||
@@ -37,10 +44,10 @@ interface RobotStatus {
|
||||
z?: number;
|
||||
orientation?: number;
|
||||
};
|
||||
sensors?: Record<string, any>;
|
||||
sensors?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function RobotStatus({ trialId }: RobotStatusProps) {
|
||||
export function RobotStatus({ trialId: _trialId }: RobotStatusProps) {
|
||||
const [robotStatus, setRobotStatus] = useState<RobotStatus | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@@ -62,32 +69,43 @@ export function RobotStatus({ trialId }: RobotStatusProps) {
|
||||
position: {
|
||||
x: 1.2,
|
||||
y: 0.8,
|
||||
orientation: 45
|
||||
orientation: 45,
|
||||
},
|
||||
sensors: {
|
||||
lidar: "operational",
|
||||
camera: "operational",
|
||||
imu: "operational",
|
||||
odometry: "operational"
|
||||
}
|
||||
odometry: "operational",
|
||||
},
|
||||
};
|
||||
|
||||
setRobotStatus(mockStatus);
|
||||
|
||||
// Simulate periodic updates
|
||||
const interval = setInterval(() => {
|
||||
setRobotStatus(prev => {
|
||||
setRobotStatus((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
batteryLevel: Math.max(0, (prev.batteryLevel || 0) - Math.random() * 0.5),
|
||||
signalStrength: Math.max(0, Math.min(100, (prev.signalStrength || 0) + (Math.random() - 0.5) * 10)),
|
||||
batteryLevel: Math.max(
|
||||
0,
|
||||
(prev.batteryLevel ?? 0) - Math.random() * 0.5,
|
||||
),
|
||||
signalStrength: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
(prev.signalStrength ?? 0) + (Math.random() - 0.5) * 10,
|
||||
),
|
||||
),
|
||||
lastHeartbeat: new Date(),
|
||||
position: prev.position ? {
|
||||
...prev.position,
|
||||
x: prev.position.x + (Math.random() - 0.5) * 0.1,
|
||||
y: prev.position.y + (Math.random() - 0.5) * 0.1,
|
||||
} : undefined
|
||||
position: prev.position
|
||||
? {
|
||||
...prev.position,
|
||||
x: prev.position.x + (Math.random() - 0.5) * 0.1,
|
||||
y: prev.position.y + (Math.random() - 0.5) * 0.1,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
setLastUpdate(new Date());
|
||||
@@ -103,35 +121,35 @@ export function RobotStatus({ trialId }: RobotStatusProps) {
|
||||
icon: CheckCircle,
|
||||
color: "text-green-600",
|
||||
bgColor: "bg-green-100",
|
||||
label: "Connected"
|
||||
label: "Connected",
|
||||
};
|
||||
case "connecting":
|
||||
return {
|
||||
icon: RefreshCw,
|
||||
color: "text-blue-600",
|
||||
bgColor: "bg-blue-100",
|
||||
label: "Connecting"
|
||||
label: "Connecting",
|
||||
};
|
||||
case "disconnected":
|
||||
return {
|
||||
icon: WifiOff,
|
||||
color: "text-gray-600",
|
||||
bgColor: "bg-gray-100",
|
||||
label: "Disconnected"
|
||||
label: "Disconnected",
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
icon: AlertTriangle,
|
||||
color: "text-red-600",
|
||||
bgColor: "bg-red-100",
|
||||
label: "Error"
|
||||
label: "Error",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: WifiOff,
|
||||
color: "text-gray-600",
|
||||
bgColor: "bg-gray-100",
|
||||
label: "Unknown"
|
||||
label: "Unknown",
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -159,182 +177,173 @@ export function RobotStatus({ trialId }: RobotStatusProps) {
|
||||
if (!robotStatus) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Bot className="h-4 w-4 text-slate-600" />
|
||||
<h3 className="font-medium text-slate-900">Robot Status</h3>
|
||||
<div className="rounded-lg border p-4 text-center">
|
||||
<div className="text-slate-500">
|
||||
<Bot className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p className="text-sm">No robot connected</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-slate-500">
|
||||
<Bot className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No robot connected</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusConfig = getConnectionStatusConfig(robotStatus.connectionStatus);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const SignalIcon = getSignalIcon(robotStatus.signalStrength || 0);
|
||||
const BatteryIcon = getBatteryIcon(robotStatus.batteryLevel || 0);
|
||||
const SignalIcon = getSignalIcon(robotStatus.signalStrength ?? 0);
|
||||
const BatteryIcon = getBatteryIcon(robotStatus.batteryLevel ?? 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Bot className="h-4 w-4 text-slate-600" />
|
||||
<h3 className="font-medium text-slate-900">Robot Status</h3>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefreshStatus}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Main Status Card */}
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-3">
|
||||
{/* Robot Info */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium text-slate-900">{robotStatus.name}</div>
|
||||
<Badge className={`${statusConfig.bgColor} ${statusConfig.color}`} variant="secondary">
|
||||
<StatusIcon className="mr-1 h-3 w-3" />
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Connection Details */}
|
||||
<div className="text-sm text-slate-600">
|
||||
Protocol: {robotStatus.communicationProtocol}
|
||||
</div>
|
||||
|
||||
{/* Status Indicators */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Battery */}
|
||||
{robotStatus.batteryLevel !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-1 text-xs text-slate-600">
|
||||
<BatteryIcon className={`h-3 w-3 ${
|
||||
robotStatus.batteryLevel <= 20 ? 'text-red-500' : 'text-green-500'
|
||||
}`} />
|
||||
<span>Battery</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Progress
|
||||
value={robotStatus.batteryLevel}
|
||||
className="flex-1 h-1.5"
|
||||
/>
|
||||
<span className="text-xs font-medium w-8">
|
||||
{Math.round(robotStatus.batteryLevel)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signal Strength */}
|
||||
{robotStatus.signalStrength !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-1 text-xs text-slate-600">
|
||||
<SignalIcon className="h-3 w-3" />
|
||||
<span>Signal</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Progress
|
||||
value={robotStatus.signalStrength}
|
||||
className="flex-1 h-1.5"
|
||||
/>
|
||||
<span className="text-xs font-medium w-8">
|
||||
{Math.round(robotStatus.signalStrength)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Current Mode */}
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-3">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="space-y-3">
|
||||
{/* Robot Info */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Activity className="h-3 w-3 text-slate-600" />
|
||||
<span className="text-sm text-slate-600">Mode:</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{robotStatus.currentMode.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
<div className="font-medium text-slate-900">{robotStatus.name}</div>
|
||||
<Badge
|
||||
className={`${statusConfig.bgColor} ${statusConfig.color}`}
|
||||
variant="secondary"
|
||||
>
|
||||
<StatusIcon className="mr-1 h-3 w-3" />
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
{robotStatus.isMoving && (
|
||||
<div className="flex items-center space-x-1 mt-2 text-xs text-blue-600">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse"></div>
|
||||
<span>Robot is moving</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Connection Details */}
|
||||
<div className="text-sm text-slate-600">
|
||||
Protocol: {robotStatus.communicationProtocol}
|
||||
</div>
|
||||
|
||||
{/* Status Indicators */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Battery */}
|
||||
{robotStatus.batteryLevel !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-1 text-xs text-slate-600">
|
||||
<BatteryIcon className="h-3 w-3" />
|
||||
<span>Battery</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Progress
|
||||
value={robotStatus.batteryLevel}
|
||||
className="h-1.5 flex-1"
|
||||
/>
|
||||
<span className="w-8 text-xs font-medium">
|
||||
{Math.round(robotStatus.batteryLevel)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signal Strength */}
|
||||
{robotStatus.signalStrength !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-1 text-xs text-slate-600">
|
||||
<SignalIcon className="h-3 w-3" />
|
||||
<span>Signal</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Progress
|
||||
value={robotStatus.signalStrength}
|
||||
className="h-1.5 flex-1"
|
||||
/>
|
||||
<span className="w-8 text-xs font-medium">
|
||||
{Math.round(robotStatus.signalStrength)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Mode */}
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Activity className="h-3 w-3 text-slate-600" />
|
||||
<span className="text-sm text-slate-600">Mode:</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{robotStatus.currentMode
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||
</Badge>
|
||||
</div>
|
||||
{robotStatus.isMoving && (
|
||||
<div className="mt-2 flex items-center space-x-1 text-xs">
|
||||
<div className="h-1.5 w-1.5 animate-pulse rounded-full"></div>
|
||||
<span>Robot is moving</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Position Info */}
|
||||
{robotStatus.position && (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-700">Position</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-3 text-sm font-medium text-slate-700">
|
||||
Position
|
||||
</div>
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">X:</span>
|
||||
<span className="font-mono">{robotStatus.position.x.toFixed(2)}m</span>
|
||||
<span className="font-mono">
|
||||
{robotStatus.position.x.toFixed(2)}m
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Y:</span>
|
||||
<span className="font-mono">{robotStatus.position.y.toFixed(2)}m</span>
|
||||
<span className="font-mono">
|
||||
{robotStatus.position.y.toFixed(2)}m
|
||||
</span>
|
||||
</div>
|
||||
{robotStatus.position.orientation !== undefined && (
|
||||
<div className="flex justify-between col-span-2">
|
||||
<div className="col-span-2 flex justify-between">
|
||||
<span className="text-slate-600">Orientation:</span>
|
||||
<span className="font-mono">{Math.round(robotStatus.position.orientation)}°</span>
|
||||
<span className="font-mono">
|
||||
{Math.round(robotStatus.position.orientation)}°
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sensors Status */}
|
||||
{robotStatus.sensors && (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-700">Sensors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-3 text-sm font-medium text-slate-700">Sensors</div>
|
||||
<div>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(robotStatus.sensors).map(([sensor, status]) => (
|
||||
<div key={sensor} className="flex items-center justify-between text-xs">
|
||||
<div
|
||||
key={sensor}
|
||||
className="flex items-center justify-between text-xs"
|
||||
>
|
||||
<span className="text-slate-600 capitalize">{sensor}:</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${
|
||||
status === 'operational'
|
||||
? 'text-green-600 border-green-200'
|
||||
: 'text-red-600 border-red-200'
|
||||
}`}
|
||||
>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Alert */}
|
||||
@@ -348,7 +357,7 @@ export function RobotStatus({ trialId }: RobotStatusProps) {
|
||||
)}
|
||||
|
||||
{/* Last Update */}
|
||||
<div className="text-xs text-slate-500 flex items-center space-x-1">
|
||||
<div className="flex items-center space-x-1 text-xs text-slate-500">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>Last update: {lastUpdate.toLocaleTimeString()}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity, ArrowRight, Bot, CheckCircle, GitBranch, MessageSquare, Play, Settings, Timer,
|
||||
User, Users
|
||||
Activity,
|
||||
ArrowRight,
|
||||
Bot,
|
||||
CheckCircle,
|
||||
GitBranch,
|
||||
MessageSquare,
|
||||
Play,
|
||||
Settings,
|
||||
Timer,
|
||||
User,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
||||
import { useState } from "react";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -16,7 +30,11 @@ interface StepDisplayProps {
|
||||
step: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional_branch";
|
||||
type:
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
description?: string;
|
||||
parameters?: any;
|
||||
duration?: number;
|
||||
@@ -63,10 +81,12 @@ export function StepDisplay({
|
||||
stepIndex,
|
||||
totalSteps,
|
||||
isActive,
|
||||
onExecuteAction
|
||||
onExecuteAction,
|
||||
}: StepDisplayProps) {
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [completedActions, setCompletedActions] = useState<Set<string>>(new Set());
|
||||
const [completedActions, setCompletedActions] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const stepConfig = stepTypeConfig[step.type];
|
||||
const StepIcon = stepConfig.icon;
|
||||
@@ -75,7 +95,7 @@ export function StepDisplay({
|
||||
setIsExecuting(true);
|
||||
try {
|
||||
await onExecuteAction(actionId, actionData);
|
||||
setCompletedActions(prev => new Set([...prev, actionId]));
|
||||
setCompletedActions((prev) => new Set([...prev, actionId]));
|
||||
} catch (_error) {
|
||||
console.error("Failed to execute action:", _error);
|
||||
} finally {
|
||||
@@ -97,17 +117,19 @@ export function StepDisplay({
|
||||
|
||||
{step.actions && step.actions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-slate-900">Available Actions:</h4>
|
||||
<h4 className="font-medium text-slate-900">
|
||||
Available Actions:
|
||||
</h4>
|
||||
<div className="grid gap-2">
|
||||
{step.actions.map((action: any, index: number) => {
|
||||
const isCompleted = completedActions.has(action.id);
|
||||
return (
|
||||
<div
|
||||
key={action.id || index}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border ${
|
||||
className={`flex items-center justify-between rounded-lg border p-3 ${
|
||||
isCompleted
|
||||
? "bg-green-50 border-green-200"
|
||||
: "bg-slate-50 border-slate-200"
|
||||
? "border-green-200 bg-green-50"
|
||||
: "border-slate-200 bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
@@ -117,16 +139,20 @@ export function StepDisplay({
|
||||
<Play className="h-4 w-4 text-slate-400" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-sm">{action.name}</p>
|
||||
<p className="text-sm font-medium">{action.name}</p>
|
||||
{action.description && (
|
||||
<p className="text-xs text-slate-600">{action.description}</p>
|
||||
<p className="text-xs text-slate-600">
|
||||
{action.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isActive && !isCompleted && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleActionExecution(action.id, action)}
|
||||
onClick={() =>
|
||||
handleActionExecution(action.id, action)
|
||||
}
|
||||
disabled={isExecuting}
|
||||
>
|
||||
Execute
|
||||
@@ -153,8 +179,10 @@ export function StepDisplay({
|
||||
|
||||
{step.parameters && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-slate-900">Robot Parameters:</h4>
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-sm font-mono">
|
||||
<h4 className="font-medium text-slate-900">
|
||||
Robot Parameters:
|
||||
</h4>
|
||||
<div className="rounded-lg bg-slate-50 p-3 font-mono text-sm">
|
||||
<pre>{JSON.stringify(step.parameters, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,22 +209,26 @@ export function StepDisplay({
|
||||
|
||||
{step.substeps && step.substeps.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-slate-900">Parallel Actions:</h4>
|
||||
<h4 className="font-medium text-slate-900">
|
||||
Parallel Actions:
|
||||
</h4>
|
||||
<div className="grid gap-3">
|
||||
{step.substeps.map((substep: any, index: number) => (
|
||||
<div
|
||||
key={substep.id || index}
|
||||
className="flex items-center space-x-3 p-3 bg-slate-50 rounded-lg border"
|
||||
className="flex items-center space-x-3 rounded-lg border bg-slate-50 p-3"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-6 h-6 rounded-full bg-purple-100 flex items-center justify-center text-xs font-medium text-purple-600">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-purple-100 text-xs font-medium text-purple-600">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">{substep.name}</p>
|
||||
<p className="text-sm font-medium">{substep.name}</p>
|
||||
{substep.description && (
|
||||
<p className="text-xs text-slate-600">{substep.description}</p>
|
||||
<p className="text-xs text-slate-600">
|
||||
{substep.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
@@ -225,7 +257,7 @@ export function StepDisplay({
|
||||
{step.conditions && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-slate-900">Conditions:</h4>
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-sm">
|
||||
<div className="rounded-lg bg-slate-50 p-3 text-sm">
|
||||
<pre>{JSON.stringify(step.conditions, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,19 +265,23 @@ export function StepDisplay({
|
||||
|
||||
{step.branches && step.branches.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-slate-900">Possible Branches:</h4>
|
||||
<h4 className="font-medium text-slate-900">
|
||||
Possible Branches:
|
||||
</h4>
|
||||
<div className="grid gap-2">
|
||||
{step.branches.map((branch: any, index: number) => (
|
||||
<div
|
||||
key={branch.id || index}
|
||||
className="flex items-center justify-between p-3 bg-slate-50 rounded-lg border"
|
||||
className="flex items-center justify-between rounded-lg border bg-slate-50 p-3"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<ArrowRight className="h-4 w-4 text-orange-500" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">{branch.name}</p>
|
||||
<p className="text-sm font-medium">{branch.name}</p>
|
||||
{branch.condition && (
|
||||
<p className="text-xs text-slate-600">If: {branch.condition}</p>
|
||||
<p className="text-xs text-slate-600">
|
||||
If: {branch.condition}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,7 +289,9 @@ export function StepDisplay({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleActionExecution(`branch_${branch.id}`, branch)}
|
||||
onClick={() =>
|
||||
handleActionExecution(`branch_${branch.id}`, branch)
|
||||
}
|
||||
disabled={isExecuting}
|
||||
>
|
||||
Select
|
||||
@@ -269,8 +307,8 @@ export function StepDisplay({
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<Settings className="h-8 w-8 mx-auto mb-2" />
|
||||
<div className="py-8 text-center text-slate-500">
|
||||
<Settings className="mx-auto mb-2 h-8 w-8" />
|
||||
<p>Unknown step type: {step.type}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -278,32 +316,46 @@ export function StepDisplay({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`transition-all duration-200 ${
|
||||
isActive ? "ring-2 ring-blue-500 shadow-lg" : "border-slate-200"
|
||||
}`}>
|
||||
<Card
|
||||
className={`transition-all duration-200 ${
|
||||
isActive ? "shadow-lg ring-2 ring-blue-500" : "border-slate-200"
|
||||
}`}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className={`flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
stepConfig.color === "blue" ? "bg-blue-100" :
|
||||
stepConfig.color === "green" ? "bg-green-100" :
|
||||
stepConfig.color === "purple" ? "bg-purple-100" :
|
||||
stepConfig.color === "orange" ? "bg-orange-100" :
|
||||
"bg-slate-100"
|
||||
}`}>
|
||||
<StepIcon className={`h-5 w-5 ${
|
||||
stepConfig.color === "blue" ? "text-blue-600" :
|
||||
stepConfig.color === "green" ? "text-green-600" :
|
||||
stepConfig.color === "purple" ? "text-purple-600" :
|
||||
stepConfig.color === "orange" ? "text-orange-600" :
|
||||
"text-slate-600"
|
||||
}`} />
|
||||
<div
|
||||
className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg ${
|
||||
stepConfig.color === "blue"
|
||||
? "bg-blue-100"
|
||||
: stepConfig.color === "green"
|
||||
? "bg-green-100"
|
||||
: stepConfig.color === "purple"
|
||||
? "bg-purple-100"
|
||||
: stepConfig.color === "orange"
|
||||
? "bg-orange-100"
|
||||
: "bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
<StepIcon
|
||||
className={`h-5 w-5 ${
|
||||
stepConfig.color === "blue"
|
||||
? "text-blue-600"
|
||||
: stepConfig.color === "green"
|
||||
? "text-green-600"
|
||||
: stepConfig.color === "purple"
|
||||
? "text-purple-600"
|
||||
: stepConfig.color === "orange"
|
||||
? "text-orange-600"
|
||||
: "text-slate-600"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-lg font-semibold text-slate-900">
|
||||
{step.name}
|
||||
</CardTitle>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<div className="mt-1 flex items-center space-x-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{stepConfig.label}
|
||||
</Badge>
|
||||
@@ -311,7 +363,7 @@ export function StepDisplay({
|
||||
Step {stepIndex + 1} of {totalSteps}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
{stepConfig.description}
|
||||
</p>
|
||||
</div>
|
||||
@@ -341,9 +393,14 @@ export function StepDisplay({
|
||||
<Separator className="my-4" />
|
||||
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>Step Progress</span>
|
||||
<span>{stepIndex + 1}/{totalSteps}</span>
|
||||
<span>
|
||||
{stepIndex + 1}/{totalSteps}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={((stepIndex + 1) / totalSteps) * 100} className="h-1 mt-2" />
|
||||
<Progress
|
||||
value={((stepIndex + 1) / totalSteps) * 100}
|
||||
className="mt-2 h-1"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity, Bot, CheckCircle,
|
||||
Circle, Clock, GitBranch, Play, Target, Users
|
||||
Activity,
|
||||
Bot,
|
||||
CheckCircle,
|
||||
Circle,
|
||||
Clock,
|
||||
GitBranch,
|
||||
Play,
|
||||
Target,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
@@ -13,10 +20,14 @@ interface TrialProgressProps {
|
||||
steps: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional_branch";
|
||||
type:
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
description?: string;
|
||||
duration?: number;
|
||||
parameters?: any;
|
||||
parameters?: Record<string, unknown>;
|
||||
}>;
|
||||
currentStepIndex: number;
|
||||
trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
@@ -29,7 +40,7 @@ const stepTypeConfig = {
|
||||
color: "blue",
|
||||
bgColor: "bg-blue-100",
|
||||
textColor: "text-blue-600",
|
||||
borderColor: "border-blue-300"
|
||||
borderColor: "border-blue-300",
|
||||
},
|
||||
robot_action: {
|
||||
label: "Robot",
|
||||
@@ -37,7 +48,7 @@ const stepTypeConfig = {
|
||||
color: "green",
|
||||
bgColor: "bg-green-100",
|
||||
textColor: "text-green-600",
|
||||
borderColor: "border-green-300"
|
||||
borderColor: "border-green-300",
|
||||
},
|
||||
parallel_steps: {
|
||||
label: "Parallel",
|
||||
@@ -45,7 +56,7 @@ const stepTypeConfig = {
|
||||
color: "purple",
|
||||
bgColor: "bg-purple-100",
|
||||
textColor: "text-purple-600",
|
||||
borderColor: "border-purple-300"
|
||||
borderColor: "border-purple-300",
|
||||
},
|
||||
conditional_branch: {
|
||||
label: "Branch",
|
||||
@@ -53,17 +64,21 @@ const stepTypeConfig = {
|
||||
color: "orange",
|
||||
bgColor: "bg-orange-100",
|
||||
textColor: "text-orange-600",
|
||||
borderColor: "border-orange-300"
|
||||
}
|
||||
borderColor: "border-orange-300",
|
||||
},
|
||||
};
|
||||
|
||||
export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialProgressProps) {
|
||||
export function TrialProgress({
|
||||
steps,
|
||||
currentStepIndex,
|
||||
trialStatus,
|
||||
}: TrialProgressProps) {
|
||||
if (!steps || steps.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="text-slate-500">
|
||||
<Target className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<Target className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p className="text-sm">No experiment steps defined</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -71,19 +86,28 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
);
|
||||
}
|
||||
|
||||
const progress = trialStatus === "completed" ? 100 :
|
||||
trialStatus === "aborted" ? 0 :
|
||||
((currentStepIndex + 1) / steps.length) * 100;
|
||||
const progress =
|
||||
trialStatus === "completed"
|
||||
? 100
|
||||
: trialStatus === "aborted"
|
||||
? 0
|
||||
: ((currentStepIndex + 1) / steps.length) * 100;
|
||||
|
||||
const completedSteps = trialStatus === "completed" ? steps.length :
|
||||
trialStatus === "aborted" || trialStatus === "failed" ? 0 :
|
||||
currentStepIndex;
|
||||
const completedSteps =
|
||||
trialStatus === "completed"
|
||||
? steps.length
|
||||
: trialStatus === "aborted" || trialStatus === "failed"
|
||||
? 0
|
||||
: currentStepIndex;
|
||||
|
||||
const getStepStatus = (index: number) => {
|
||||
if (trialStatus === "aborted" || trialStatus === "failed") return "aborted";
|
||||
if (trialStatus === "completed" || index < currentStepIndex) return "completed";
|
||||
if (index === currentStepIndex && trialStatus === "in_progress") return "active";
|
||||
if (index === currentStepIndex && trialStatus === "scheduled") return "pending";
|
||||
if (trialStatus === "completed" || index < currentStepIndex)
|
||||
return "completed";
|
||||
if (index === currentStepIndex && trialStatus === "in_progress")
|
||||
return "active";
|
||||
if (index === currentStepIndex && trialStatus === "scheduled")
|
||||
return "pending";
|
||||
return "upcoming";
|
||||
};
|
||||
|
||||
@@ -95,7 +119,7 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
iconColor: "text-green-600",
|
||||
bgColor: "bg-green-100",
|
||||
borderColor: "border-green-300",
|
||||
textColor: "text-green-800"
|
||||
textColor: "text-green-800",
|
||||
};
|
||||
case "active":
|
||||
return {
|
||||
@@ -103,7 +127,7 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
iconColor: "text-blue-600",
|
||||
bgColor: "bg-blue-100",
|
||||
borderColor: "border-blue-300",
|
||||
textColor: "text-blue-800"
|
||||
textColor: "text-blue-800",
|
||||
};
|
||||
case "pending":
|
||||
return {
|
||||
@@ -111,7 +135,7 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
iconColor: "text-amber-600",
|
||||
bgColor: "bg-amber-100",
|
||||
borderColor: "border-amber-300",
|
||||
textColor: "text-amber-800"
|
||||
textColor: "text-amber-800",
|
||||
};
|
||||
case "aborted":
|
||||
return {
|
||||
@@ -119,7 +143,7 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
iconColor: "text-red-600",
|
||||
bgColor: "bg-red-100",
|
||||
borderColor: "border-red-300",
|
||||
textColor: "text-red-800"
|
||||
textColor: "text-red-800",
|
||||
};
|
||||
default: // upcoming
|
||||
return {
|
||||
@@ -127,12 +151,15 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
iconColor: "text-slate-400",
|
||||
bgColor: "bg-slate-100",
|
||||
borderColor: "border-slate-300",
|
||||
textColor: "text-slate-600"
|
||||
textColor: "text-slate-600",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const totalDuration = steps.reduce((sum, step) => sum + (step.duration || 0), 0);
|
||||
const totalDuration = steps.reduce(
|
||||
(sum, step) => sum + (step.duration ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -165,19 +192,25 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
<Progress
|
||||
value={progress}
|
||||
className={`h-2 ${
|
||||
trialStatus === "completed" ? "bg-green-100" :
|
||||
trialStatus === "aborted" || trialStatus === "failed" ? "bg-red-100" :
|
||||
"bg-blue-100"
|
||||
trialStatus === "completed"
|
||||
? "bg-green-100"
|
||||
: trialStatus === "aborted" || trialStatus === "failed"
|
||||
? "bg-red-100"
|
||||
: "bg-blue-100"
|
||||
}`}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-slate-500">
|
||||
<span>Start</span>
|
||||
<span>
|
||||
{trialStatus === "completed" ? "Completed" :
|
||||
trialStatus === "aborted" ? "Aborted" :
|
||||
trialStatus === "failed" ? "Failed" :
|
||||
trialStatus === "in_progress" ? "In Progress" :
|
||||
"Not Started"}
|
||||
{trialStatus === "completed"
|
||||
? "Completed"
|
||||
: trialStatus === "aborted"
|
||||
? "Aborted"
|
||||
: trialStatus === "failed"
|
||||
? "Failed"
|
||||
: trialStatus === "in_progress"
|
||||
? "In Progress"
|
||||
: "Not Started"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,7 +219,9 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
|
||||
{/* Steps Timeline */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-slate-900 text-sm">Experiment Steps</h4>
|
||||
<h4 className="text-sm font-medium text-slate-900">
|
||||
Experiment Steps
|
||||
</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
{steps.map((step, index) => {
|
||||
@@ -201,9 +236,10 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
{/* Connection Line */}
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={`absolute left-6 top-12 w-0.5 h-6 ${
|
||||
className={`absolute top-12 left-6 h-6 w-0.5 ${
|
||||
getStepStatus(index + 1) === "completed" ||
|
||||
(getStepStatus(index + 1) === "active" && status === "completed")
|
||||
(getStepStatus(index + 1) === "active" &&
|
||||
status === "completed")
|
||||
? "bg-green-300"
|
||||
: "bg-slate-300"
|
||||
}`}
|
||||
@@ -211,57 +247,76 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
)}
|
||||
|
||||
{/* Step Card */}
|
||||
<div className={`flex items-start space-x-3 p-3 rounded-lg border transition-all ${
|
||||
status === "active"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200`
|
||||
: status === "completed"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
|
||||
: status === "aborted"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
|
||||
: "bg-slate-50 border-slate-200"
|
||||
}`}>
|
||||
<div
|
||||
className={`flex items-start space-x-3 rounded-lg border p-3 transition-all ${
|
||||
status === "active"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200`
|
||||
: status === "completed"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
|
||||
: status === "aborted"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
|
||||
: "border-slate-200 bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
{/* Step Number & Status */}
|
||||
<div className="flex-shrink-0 space-y-1">
|
||||
<div className={`w-12 h-8 rounded-lg flex items-center justify-center ${
|
||||
status === "active" ? statusConfig.bgColor :
|
||||
status === "completed" ? "bg-green-100" :
|
||||
status === "aborted" ? "bg-red-100" :
|
||||
"bg-slate-100"
|
||||
}`}>
|
||||
<span className={`text-sm font-medium ${
|
||||
status === "active" ? statusConfig.textColor :
|
||||
status === "completed" ? "text-green-700" :
|
||||
status === "aborted" ? "text-red-700" :
|
||||
"text-slate-600"
|
||||
}`}>
|
||||
<div
|
||||
className={`flex h-8 w-12 items-center justify-center rounded-lg ${
|
||||
status === "active"
|
||||
? statusConfig.bgColor
|
||||
: status === "completed"
|
||||
? "bg-green-100"
|
||||
: status === "aborted"
|
||||
? "bg-red-100"
|
||||
: "bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
status === "active"
|
||||
? statusConfig.textColor
|
||||
: status === "completed"
|
||||
? "text-green-700"
|
||||
: status === "aborted"
|
||||
? "text-red-700"
|
||||
: "text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<StatusIcon className={`h-4 w-4 ${statusConfig.iconColor}`} />
|
||||
<StatusIcon
|
||||
className={`h-4 w-4 ${statusConfig.iconColor}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h5 className={`font-medium truncate ${
|
||||
status === "active" ? "text-slate-900" :
|
||||
status === "completed" ? "text-green-900" :
|
||||
status === "aborted" ? "text-red-900" :
|
||||
"text-slate-700"
|
||||
}`}>
|
||||
<h5
|
||||
className={`truncate font-medium ${
|
||||
status === "active"
|
||||
? "text-slate-900"
|
||||
: status === "completed"
|
||||
? "text-green-900"
|
||||
: status === "aborted"
|
||||
? "text-red-900"
|
||||
: "text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{step.name}
|
||||
</h5>
|
||||
{step.description && (
|
||||
<p className="text-sm text-slate-600 mt-1 line-clamp-2">
|
||||
<p className="mt-1 line-clamp-2 text-sm text-slate-600">
|
||||
{step.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 ml-3 space-y-1">
|
||||
<div className="ml-3 flex-shrink-0 space-y-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${stepConfig.textColor} ${stepConfig.borderColor}`}
|
||||
@@ -280,19 +335,19 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
|
||||
{/* Step Status Message */}
|
||||
{status === "active" && trialStatus === "in_progress" && (
|
||||
<div className="flex items-center space-x-1 mt-2 text-sm text-blue-600">
|
||||
<div className="mt-2 flex items-center space-x-1 text-sm text-blue-600">
|
||||
<Activity className="h-3 w-3 animate-pulse" />
|
||||
<span>Currently executing...</span>
|
||||
</div>
|
||||
)}
|
||||
{status === "active" && trialStatus === "scheduled" && (
|
||||
<div className="flex items-center space-x-1 mt-2 text-sm text-amber-600">
|
||||
<div className="mt-2 flex items-center space-x-1 text-sm text-amber-600">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>Ready to start</span>
|
||||
</div>
|
||||
)}
|
||||
{status === "completed" && (
|
||||
<div className="flex items-center space-x-1 mt-2 text-sm text-green-600">
|
||||
<div className="mt-2 flex items-center space-x-1 text-sm text-green-600">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
<span>Completed</span>
|
||||
</div>
|
||||
@@ -309,7 +364,9 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
<Separator />
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600">{completedSteps}</div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{completedSteps}
|
||||
</div>
|
||||
<div className="text-xs text-slate-600">Completed</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -320,7 +377,9 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-600">
|
||||
{steps.length - completedSteps - (trialStatus === "in_progress" ? 1 : 0)}
|
||||
{steps.length -
|
||||
completedSteps -
|
||||
(trialStatus === "in_progress" ? 1 : 0)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-600">Remaining</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@ function CommandDialog({
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
showCloseButton: _showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
|
||||
@@ -63,7 +63,7 @@ export function EntityForm<T extends FieldValues = FieldValues>({
|
||||
entityName,
|
||||
entityNamePlural,
|
||||
backUrl,
|
||||
listUrl,
|
||||
listUrl: _listUrl,
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
@@ -195,7 +195,7 @@ export function EntityForm<T extends FieldValues = FieldValues>({
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
submitText || defaultSubmitText
|
||||
(submitText ?? defaultSubmitText)
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertCircle, CheckCircle, File, FileAudio, FileImage,
|
||||
FileVideo, Loader2, Upload,
|
||||
X
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
File,
|
||||
FileAudio,
|
||||
FileImage,
|
||||
FileVideo,
|
||||
Loader2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
@@ -62,20 +68,23 @@ export function FileUpload({
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const validateFile = (file: File): string | null => {
|
||||
if (file.size > maxSize) {
|
||||
return `File size exceeds ${Math.round(maxSize / 1024 / 1024)}MB limit`;
|
||||
}
|
||||
|
||||
if (allowedTypes.length > 0) {
|
||||
const extension = file.name.split('.').pop()?.toLowerCase() || '';
|
||||
if (!allowedTypes.includes(extension)) {
|
||||
return `File type .${extension} is not allowed`;
|
||||
const validateFile = useCallback(
|
||||
(file: File): string | null => {
|
||||
if (file.size > maxSize) {
|
||||
return `File size exceeds ${Math.round(maxSize / 1024 / 1024)}MB limit`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
if (allowedTypes && allowedTypes.length > 0) {
|
||||
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
if (!allowedTypes.includes(extension)) {
|
||||
return `File type .${extension} is not allowed`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[maxSize, allowedTypes],
|
||||
);
|
||||
|
||||
const createFilePreview = (file: File): FileWithPreview => {
|
||||
const fileWithPreview = file as FileWithPreview;
|
||||
@@ -83,66 +92,69 @@ export function FileUpload({
|
||||
fileWithPreview.uploaded = false;
|
||||
|
||||
// Create preview for images
|
||||
if (file.type.startsWith('image/')) {
|
||||
if (file.type.startsWith("image/")) {
|
||||
fileWithPreview.preview = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
return fileWithPreview;
|
||||
};
|
||||
|
||||
const handleFiles = useCallback((newFiles: FileList | File[]) => {
|
||||
const fileArray = Array.from(newFiles);
|
||||
const handleFiles = useCallback(
|
||||
(newFiles: FileList | File[]) => {
|
||||
const fileArray = Array.from(newFiles);
|
||||
|
||||
// Check max files limit
|
||||
if (!multiple && fileArray.length > 1) {
|
||||
onUploadError?.("Only one file is allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.length + fileArray.length > maxFiles) {
|
||||
onUploadError?.(`Maximum ${maxFiles} files allowed`);
|
||||
return;
|
||||
}
|
||||
|
||||
const validFiles: FileWithPreview[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
fileArray.forEach((file) => {
|
||||
const error = validateFile(file);
|
||||
if (error) {
|
||||
errors.push(`${file.name}: ${error}`);
|
||||
} else {
|
||||
validFiles.push(createFilePreview(file));
|
||||
// Check max files limit
|
||||
if (!multiple && fileArray.length > 1) {
|
||||
onUploadError?.("Only one file is allowed");
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
onUploadError?.(errors.join(', '));
|
||||
return;
|
||||
}
|
||||
if (files.length + fileArray.length > maxFiles) {
|
||||
onUploadError?.(`Maximum ${maxFiles} files allowed`);
|
||||
return;
|
||||
}
|
||||
|
||||
setFiles((prev) => [...prev, ...validFiles]);
|
||||
}, [files.length, maxFiles, multiple, maxSize, allowedTypes, onUploadError]);
|
||||
const validFiles: FileWithPreview[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
fileArray.forEach((file) => {
|
||||
const error = validateFile(file);
|
||||
if (error) {
|
||||
errors.push(`${file.name}: ${error}`);
|
||||
} else {
|
||||
validFiles.push(createFilePreview(file));
|
||||
}
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
onUploadError?.(errors.join(", "));
|
||||
return;
|
||||
}
|
||||
|
||||
setFiles((prev) => [...prev, ...validFiles]);
|
||||
},
|
||||
[files.length, maxFiles, multiple, onUploadError, validateFile],
|
||||
);
|
||||
|
||||
const uploadFile = async (file: FileWithPreview): Promise<UploadedFile> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('category', category);
|
||||
formData.append("file", file);
|
||||
formData.append("category", category);
|
||||
if (trialId) {
|
||||
formData.append('trialId', trialId);
|
||||
formData.append("trialId", trialId);
|
||||
}
|
||||
|
||||
const response = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
const response = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Upload failed');
|
||||
const error = (await response.json()) as { error?: string };
|
||||
throw new Error(error.error ?? "Upload failed");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const result = (await response.json()) as { data: UploadedFile };
|
||||
return result.data;
|
||||
};
|
||||
|
||||
@@ -160,17 +172,17 @@ export function FileUpload({
|
||||
try {
|
||||
// Update progress
|
||||
setFiles((prev) =>
|
||||
prev.map((f, index) =>
|
||||
index === i ? { ...f, progress: 0 } : f
|
||||
)
|
||||
prev.map((f, index) => (index === i ? { ...f, progress: 0 } : f)),
|
||||
);
|
||||
|
||||
// Simulate progress (in real implementation, use XMLHttpRequest for progress)
|
||||
const progressInterval = setInterval(() => {
|
||||
setFiles((prev) =>
|
||||
prev.map((f, index) =>
|
||||
index === i ? { ...f, progress: Math.min((f.progress || 0) + 10, 90) } : f
|
||||
)
|
||||
index === i
|
||||
? { ...f, progress: Math.min((f.progress ?? 0) + 10, 90) }
|
||||
: f,
|
||||
),
|
||||
);
|
||||
}, 100);
|
||||
|
||||
@@ -188,19 +200,20 @@ export function FileUpload({
|
||||
uploaded: true,
|
||||
uploadedData: uploadedFile,
|
||||
}
|
||||
: f
|
||||
)
|
||||
: f,
|
||||
),
|
||||
);
|
||||
|
||||
uploadedFiles.push(uploadedFile);
|
||||
} catch (_error) {
|
||||
const errorMessage = _error instanceof Error ? _error.message : 'Upload failed';
|
||||
const errorMessage =
|
||||
_error instanceof Error ? _error.message : "Upload failed";
|
||||
errors.push(`${file?.name}: ${errorMessage}`);
|
||||
|
||||
setFiles((prev) =>
|
||||
prev.map((f, index) =>
|
||||
index === i ? { ...f, error: errorMessage, progress: 0 } : f
|
||||
)
|
||||
index === i ? { ...f, error: errorMessage, progress: 0 } : f,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -208,7 +221,7 @@ export function FileUpload({
|
||||
setIsUploading(false);
|
||||
|
||||
if (errors.length > 0) {
|
||||
onUploadError?.(errors.join(', '));
|
||||
onUploadError?.(errors.join(", "));
|
||||
}
|
||||
|
||||
if (uploadedFiles.length > 0) {
|
||||
@@ -240,15 +253,18 @@ export function FileUpload({
|
||||
handleFiles(droppedFiles);
|
||||
}
|
||||
},
|
||||
[handleFiles, disabled]
|
||||
[handleFiles, disabled],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
if (!disabled) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
}, [disabled]);
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
if (!disabled) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
},
|
||||
[disabled],
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -262,24 +278,24 @@ export function FileUpload({
|
||||
handleFiles(selectedFiles);
|
||||
}
|
||||
// Reset input value to allow selecting the same file again
|
||||
e.target.value = '';
|
||||
e.target.value = "";
|
||||
},
|
||||
[handleFiles]
|
||||
[handleFiles],
|
||||
);
|
||||
|
||||
const getFileIcon = (file: File) => {
|
||||
if (file.type.startsWith('image/')) return FileImage;
|
||||
if (file.type.startsWith('video/')) return FileVideo;
|
||||
if (file.type.startsWith('audio/')) return FileAudio;
|
||||
if (file.type.startsWith("image/")) return FileImage;
|
||||
if (file.type.startsWith("video/")) return FileVideo;
|
||||
if (file.type.startsWith("audio/")) return FileAudio;
|
||||
return File;
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -287,11 +303,11 @@ export function FileUpload({
|
||||
{/* Upload Area */}
|
||||
<Card
|
||||
className={cn(
|
||||
"border-2 border-dashed transition-colors cursor-pointer",
|
||||
"cursor-pointer border-2 border-dashed transition-colors",
|
||||
isDragging
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-slate-300 hover:border-slate-400",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
@@ -299,10 +315,12 @@ export function FileUpload({
|
||||
onClick={() => !disabled && fileInputRef.current?.click()}
|
||||
>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Upload className={cn(
|
||||
"h-12 w-12 mb-4",
|
||||
isDragging ? "text-blue-500" : "text-slate-400"
|
||||
)} />
|
||||
<Upload
|
||||
className={cn(
|
||||
"mb-4 h-12 w-12",
|
||||
isDragging ? "text-blue-500" : "text-slate-400",
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg font-medium">
|
||||
{isDragging ? "Drop files here" : "Upload files"}
|
||||
@@ -312,7 +330,7 @@ export function FileUpload({
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2 text-xs text-slate-500">
|
||||
{allowedTypes.length > 0 && (
|
||||
<span>Allowed: {allowedTypes.join(', ')}</span>
|
||||
<span>Allowed: {allowedTypes.join(", ")}</span>
|
||||
)}
|
||||
<span>Max size: {Math.round(maxSize / 1024 / 1024)}MB</span>
|
||||
{multiple && <span>Max files: {maxFiles}</span>}
|
||||
@@ -340,7 +358,7 @@ export function FileUpload({
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading || files.every(f => f.uploaded)}
|
||||
disabled={isUploading || files.every((f) => f.uploaded)}
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
@@ -369,6 +387,7 @@ export function FileUpload({
|
||||
<Card key={index} className="p-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
{file.preview ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={file.preview}
|
||||
alt={file.name}
|
||||
@@ -380,8 +399,8 @@ export function FileUpload({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{file.name}</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{file.name}</p>
|
||||
<p className="text-sm text-slate-600">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
|
||||
@@ -14,6 +14,7 @@ interface PageHeaderProps {
|
||||
variant?: "default" | "secondary" | "destructive" | "outline";
|
||||
className?: string;
|
||||
}>;
|
||||
breadcrumbs?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
@@ -24,33 +25,44 @@ export function PageHeader({
|
||||
icon: Icon,
|
||||
iconClassName,
|
||||
badges,
|
||||
breadcrumbs,
|
||||
actions,
|
||||
className,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex items-start justify-between", className)}>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 items-start justify-between gap-2 md:gap-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-3 md:gap-4">
|
||||
{/* Icon */}
|
||||
{Icon && (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg",
|
||||
"bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg md:h-12 md:w-12",
|
||||
iconClassName,
|
||||
)}
|
||||
>
|
||||
<Icon className="text-primary h-6 w-6" />
|
||||
<Icon className="text-primary h-5 w-5 md:h-6 md:w-6" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and description */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h1 className="text-foreground text-3xl font-bold tracking-tight">
|
||||
{breadcrumbs && (
|
||||
<div className="text-muted-foreground/80 mb-1 truncate text-xs md:text-sm">
|
||||
{breadcrumbs}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 items-center gap-2 md:gap-3">
|
||||
<h1 className="text-foreground truncate text-2xl font-bold tracking-tight md:text-3xl">
|
||||
{title}
|
||||
</h1>
|
||||
{/* Badges */}
|
||||
{badges && badges.length > 0 && (
|
||||
<div className="flex space-x-2">
|
||||
<div className="hidden flex-shrink-0 items-center gap-2 sm:flex">
|
||||
{badges.map((badge, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
@@ -64,7 +76,7 @@ export function PageHeader({
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-muted-foreground mt-2 text-base">
|
||||
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-sm md:mt-2 md:text-base">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
@@ -72,7 +84,9 @@ export function PageHeader({
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{actions && <div className="flex-shrink-0">{actions}</div>}
|
||||
{actions && (
|
||||
<div className="flex flex-shrink-0 items-center gap-2">{actions}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -82,7 +96,13 @@ interface ActionButtonProps {
|
||||
children: ReactNode;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
variant?: "default" | "secondary" | "outline" | "destructive" | "ghost" | "link";
|
||||
variant?:
|
||||
| "default"
|
||||
| "secondary"
|
||||
| "outline"
|
||||
| "destructive"
|
||||
| "ghost"
|
||||
| "link";
|
||||
size?: "default" | "sm" | "lg" | "icon";
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
|
||||
@@ -81,8 +81,8 @@ export function PageLayout({
|
||||
className,
|
||||
title,
|
||||
description,
|
||||
userName,
|
||||
userRole,
|
||||
userName: _userName,
|
||||
userRole: _userRole,
|
||||
breadcrumb,
|
||||
createButton,
|
||||
quickActions,
|
||||
@@ -201,7 +201,7 @@ export function PageLayout({
|
||||
variant={
|
||||
action.variant === "primary"
|
||||
? "default"
|
||||
: action.variant || "default"
|
||||
: (action.variant ?? "default")
|
||||
}
|
||||
className="h-auto flex-col gap-2 p-4"
|
||||
>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
@@ -15,17 +15,17 @@ function Progress({
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
export { Progress };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user