Pre-conf work 2025

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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