mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-02-05 07:56:30 -05:00
Pre-conf work 2025
This commit is contained in:
@@ -3,15 +3,15 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Calendar, FlaskConical, Plus, Settings, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { api } from "~/trpc/react";
|
||||
@@ -173,8 +173,6 @@ function ExperimentCard({ experiment }: ExperimentCardProps) {
|
||||
}
|
||||
|
||||
export function ExperimentsGrid() {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const {
|
||||
data: experimentsData,
|
||||
isLoading,
|
||||
@@ -189,11 +187,6 @@ export function ExperimentsGrid() {
|
||||
|
||||
const experiments = experimentsData?.experiments ?? [];
|
||||
|
||||
const handleExperimentCreated = () => {
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
void refetch();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -295,10 +288,10 @@ export function ExperimentsGrid() {
|
||||
Failed to Load Experiments
|
||||
</h3>
|
||||
<p className="mb-4 text-slate-600">
|
||||
{error.message ||
|
||||
{error?.message ??
|
||||
"An error occurred while loading your experiments."}
|
||||
</p>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
<Button onClick={() => void refetch()} variant="outline">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
@@ -320,52 +313,54 @@ export function ExperimentsGrid() {
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Create New Experiment Card */}
|
||||
<Card className="border-2 border-dashed border-slate-300 transition-colors hover:border-slate-400">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-blue-100">
|
||||
<Plus className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
<CardTitle>Create New Experiment</CardTitle>
|
||||
<CardDescription>Design a new experimental protocol</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/experiments/new">Create Experiment</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Experiments */}
|
||||
{experiments.map((experiment) => (
|
||||
<ExperimentCard key={experiment.id} experiment={experiment} />
|
||||
))}
|
||||
|
||||
{/* Empty State */}
|
||||
{experiments.length === 0 && (
|
||||
<Card className="md:col-span-2 lg:col-span-2">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-lg bg-slate-100">
|
||||
<FlaskConical className="h-12 w-12 text-slate-400" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-slate-900">
|
||||
No Experiments Yet
|
||||
</h3>
|
||||
<p className="mb-4 text-slate-600">
|
||||
Create your first experiment to start designing HRI protocols.
|
||||
Experiments define the structure and flow of your research
|
||||
trials.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/experiments/new">
|
||||
Create Your First Experiment
|
||||
</Link>
|
||||
</Button>
|
||||
{/* Create New Experiment Card */}
|
||||
<Card className="border-2 border-dashed border-slate-300 transition-colors hover:border-slate-400">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-blue-100">
|
||||
<Plus className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
<CardTitle>Create New Experiment</CardTitle>
|
||||
<CardDescription>
|
||||
Design a new experimental protocol
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/experiments/new">Create Experiment</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Experiments */}
|
||||
{experiments.map((experiment) => (
|
||||
<ExperimentCard key={experiment.id} experiment={experiment} />
|
||||
))}
|
||||
|
||||
{/* Empty State */}
|
||||
{experiments.length === 0 && (
|
||||
<Card className="md:col-span-2 lg:col-span-2">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-lg bg-slate-100">
|
||||
<FlaskConical className="h-12 w-12 text-slate-400" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-slate-900">
|
||||
No Experiments Yet
|
||||
</h3>
|
||||
<p className="mb-4 text-slate-600">
|
||||
Create your first experiment to start designing HRI protocols.
|
||||
Experiments define the structure and flow of your research
|
||||
trials.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/experiments/new">
|
||||
Create Your First Experiment
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useActionRegistry } from "./ActionRegistry";
|
||||
import type { ActionDefinition } from "~/lib/experiment-designer/types";
|
||||
import {
|
||||
Plus,
|
||||
User,
|
||||
Bot,
|
||||
GitBranch,
|
||||
Eye,
|
||||
GripVertical,
|
||||
Zap,
|
||||
MessageSquare,
|
||||
Hand,
|
||||
Navigation,
|
||||
Volume2,
|
||||
Clock,
|
||||
Timer,
|
||||
MousePointer,
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
} from "lucide-react";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
|
||||
// Local icon map (duplicated minimal map for isolation to avoid circular imports)
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
MessageSquare,
|
||||
Hand,
|
||||
Navigation,
|
||||
Volume2,
|
||||
Clock,
|
||||
Eye,
|
||||
Bot,
|
||||
User,
|
||||
Zap,
|
||||
Timer,
|
||||
MousePointer,
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
};
|
||||
|
||||
interface DraggableActionProps {
|
||||
action: ActionDefinition;
|
||||
}
|
||||
|
||||
function DraggableAction({ action }: DraggableActionProps) {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useDraggable({
|
||||
id: `action-${action.id}`,
|
||||
data: { action },
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: transform
|
||||
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const IconComponent = iconMap[action.icon] ?? Zap;
|
||||
|
||||
const categoryColors: Record<ActionDefinition["category"], string> = {
|
||||
wizard: "bg-blue-500",
|
||||
robot: "bg-emerald-500",
|
||||
control: "bg-amber-500",
|
||||
observation: "bg-purple-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={cn(
|
||||
"group hover:bg-accent/50 relative flex cursor-grab items-center gap-2 rounded-md border p-2 text-xs transition-colors",
|
||||
isDragging && "opacity-50",
|
||||
)}
|
||||
onMouseEnter={() => setShowTooltip(true)}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
draggable={false}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-white",
|
||||
categoryColors[action.category],
|
||||
)}
|
||||
>
|
||||
<IconComponent className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1 truncate font-medium">
|
||||
{action.source.kind === "plugin" ? (
|
||||
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-emerald-600 text-[8px] font-bold text-white">
|
||||
P
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-slate-500 text-[8px] font-bold text-white">
|
||||
C
|
||||
</span>
|
||||
)}
|
||||
{action.name}
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate text-xs">
|
||||
{action.description ?? ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</div>
|
||||
|
||||
{showTooltip && (
|
||||
<div className="bg-popover absolute top-0 left-full z-50 ml-2 max-w-xs rounded-md border p-2 text-xs shadow-md">
|
||||
<div className="font-medium">{action.name}</div>
|
||||
<div className="text-muted-foreground">{action.description}</div>
|
||||
<div className="mt-1 text-xs opacity-75">
|
||||
Category: {action.category} • ID: {action.id}
|
||||
</div>
|
||||
{action.parameters.length > 0 && (
|
||||
<div className="mt-1 text-xs opacity-75">
|
||||
Parameters: {action.parameters.map((p) => p.name).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ActionLibraryProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ActionLibrary({ className }: ActionLibraryProps) {
|
||||
const registry = useActionRegistry();
|
||||
const [activeCategory, setActiveCategory] =
|
||||
useState<ActionDefinition["category"]>("wizard");
|
||||
|
||||
const categories: Array<{
|
||||
key: ActionDefinition["category"];
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
}> = [
|
||||
{
|
||||
key: "wizard",
|
||||
label: "Wizard",
|
||||
icon: User,
|
||||
color: "bg-blue-500",
|
||||
},
|
||||
{
|
||||
key: "robot",
|
||||
label: "Robot",
|
||||
icon: Bot,
|
||||
color: "bg-emerald-500",
|
||||
},
|
||||
{
|
||||
key: "control",
|
||||
label: "Control",
|
||||
icon: GitBranch,
|
||||
color: "bg-amber-500",
|
||||
},
|
||||
{
|
||||
key: "observation",
|
||||
label: "Observe",
|
||||
icon: Eye,
|
||||
color: "bg-purple-500",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
{/* Category tabs */}
|
||||
<div className="border-b p-2">
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{categories.map((category) => {
|
||||
const IconComponent = category.icon;
|
||||
const isActive = activeCategory === category.key;
|
||||
return (
|
||||
<Button
|
||||
key={category.key}
|
||||
variant={isActive ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 justify-start text-xs",
|
||||
isActive && `${category.color} text-white hover:opacity-90`,
|
||||
)}
|
||||
onClick={() => setActiveCategory(category.key)}
|
||||
>
|
||||
<IconComponent className="mr-1 h-3 w-3" />
|
||||
{category.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions list */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-1 p-2">
|
||||
{registry.getActionsByCategory(activeCategory).length === 0 ? (
|
||||
<div className="text-muted-foreground py-8 text-center">
|
||||
<div className="bg-muted mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full">
|
||||
<Plus className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-sm">No actions available</p>
|
||||
<p className="text-xs">Check plugin configuration</p>
|
||||
</div>
|
||||
) : (
|
||||
registry
|
||||
.getActionsByCategory(activeCategory)
|
||||
.map((action) => (
|
||||
<DraggableAction key={action.id} action={action} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="border-t p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{registry.getAllActions().length} total
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{registry.getActionsByCategory(activeCategory).length} in view
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Debug info */}
|
||||
<div className="text-muted-foreground mt-1 text-[9px]">
|
||||
W:{registry.getActionsByCategory("wizard").length} R:
|
||||
{registry.getActionsByCategory("robot").length} C:
|
||||
{registry.getActionsByCategory("control").length} O:
|
||||
{registry.getActionsByCategory("observation").length}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-[9px]">
|
||||
Core loaded: {registry.getDebugInfo().coreActionsLoaded ? "✓" : "✗"}
|
||||
Plugins loaded:{" "}
|
||||
{registry.getDebugInfo().pluginActionsLoaded ? "✓" : "✗"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,677 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* BlockDesigner is being phased out in favor of DesignerShell (see DesignerShell.tsx).
|
||||
* TODO: Remove this file after full migration of add/update/delete handlers, hashing,
|
||||
* validation, drift detection, and export logic to the new architecture.
|
||||
*/
|
||||
|
||||
/**
|
||||
* BlockDesigner (Modular Refactor)
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Own overall experiment design state (steps + actions)
|
||||
* - Coordinate drag & drop between ActionLibrary (source) and StepFlow (targets)
|
||||
* - Persist design via experiments.update mutation (optionally compiling execution graph)
|
||||
* - Trigger server-side validation (experiments.validateDesign) to obtain integrity hash
|
||||
* - Track & surface "hash drift" (design changed since last validation or mismatch with stored integrityHash)
|
||||
*
|
||||
* Extracted Modules:
|
||||
* - ActionRegistry -> ./ActionRegistry.ts
|
||||
* - ActionLibrary -> ./ActionLibrary.tsx
|
||||
* - StepFlow -> ./StepFlow.tsx
|
||||
* - PropertiesPanel -> ./PropertiesPanel.tsx
|
||||
*
|
||||
* Enhancements Added Here:
|
||||
* - Hash drift indicator logic (Validated / Drift / Unvalidated)
|
||||
* - Modular wiring replacing previous monolithic file
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { Save, Download, Play, Plus } from "lucide-react";
|
||||
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { PageHeader, ActionButton } from "~/components/ui/page-header";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
|
||||
import {
|
||||
type ExperimentDesign,
|
||||
type ExperimentStep,
|
||||
type ExperimentAction,
|
||||
type ActionDefinition,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { ActionLibrary } from "./ActionLibrary";
|
||||
import { StepFlow } from "./StepFlow";
|
||||
import { PropertiesPanel } from "./PropertiesPanel";
|
||||
import { actionRegistry } from "./ActionRegistry";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Utilities */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Build a lightweight JSON string representing the current design for drift checks.
|
||||
* We include full steps & actions; param value churn will intentionally flag drift
|
||||
* (acceptable trade-off for now; can switch to structural signature if too noisy).
|
||||
*/
|
||||
function serializeDesignSteps(steps: ExperimentStep[]): string {
|
||||
return JSON.stringify(
|
||||
steps.map((s) => ({
|
||||
id: s.id,
|
||||
order: s.order,
|
||||
type: s.type,
|
||||
trigger: {
|
||||
type: s.trigger.type,
|
||||
conditionKeys: Object.keys(s.trigger.conditions).sort(),
|
||||
},
|
||||
actions: s.actions.map((a) => ({
|
||||
id: a.id,
|
||||
type: a.type,
|
||||
sourceKind: a.source.kind,
|
||||
pluginId: a.source.pluginId,
|
||||
pluginVersion: a.source.pluginVersion,
|
||||
transport: a.execution.transport,
|
||||
parameterKeys: Object.keys(a.parameters).sort(),
|
||||
})),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Props */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface BlockDesignerProps {
|
||||
experimentId: string;
|
||||
initialDesign?: ExperimentDesign;
|
||||
onSave?: (design: ExperimentDesign) => void;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function BlockDesigner({
|
||||
experimentId,
|
||||
initialDesign,
|
||||
onSave,
|
||||
}: BlockDesignerProps) {
|
||||
/* ---------------------------- Experiment Query ---------------------------- */
|
||||
const { data: experiment } = api.experiments.get.useQuery({
|
||||
id: experimentId,
|
||||
});
|
||||
|
||||
/* ------------------------------ Local Design ------------------------------ */
|
||||
const [design, setDesign] = useState<ExperimentDesign>(() => {
|
||||
const defaultDesign: ExperimentDesign = {
|
||||
id: experimentId,
|
||||
name: "New Experiment",
|
||||
description: "",
|
||||
steps: [],
|
||||
version: 1,
|
||||
lastSaved: new Date(),
|
||||
};
|
||||
return initialDesign ?? defaultDesign;
|
||||
});
|
||||
|
||||
const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
|
||||
const [selectedActionId, setSelectedActionId] = useState<string | null>(null);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
/* ------------------------- Validation / Drift Tracking -------------------- */
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [lastValidatedHash, setLastValidatedHash] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [lastValidatedDesignJson, setLastValidatedDesignJson] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
// Recompute drift conditions
|
||||
const currentDesignJson = useMemo(
|
||||
() => serializeDesignSteps(design.steps),
|
||||
[design.steps],
|
||||
);
|
||||
|
||||
const hasIntegrityHash = !!experiment?.integrityHash;
|
||||
const hashMismatch =
|
||||
hasIntegrityHash &&
|
||||
lastValidatedHash &&
|
||||
experiment?.integrityHash !== lastValidatedHash;
|
||||
const designChangedSinceValidation =
|
||||
!!lastValidatedDesignJson && lastValidatedDesignJson !== currentDesignJson;
|
||||
|
||||
const drift =
|
||||
hasIntegrityHash && (hashMismatch ? true : designChangedSinceValidation);
|
||||
|
||||
/* ---------------------------- Active Drag State --------------------------- */
|
||||
// Removed unused activeId state (drag overlay removed in modular refactor)
|
||||
|
||||
/* ------------------------------- tRPC Mutations --------------------------- */
|
||||
const updateExperiment = api.experiments.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Experiment saved");
|
||||
setHasUnsavedChanges(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(`Failed to save: ${err.message}`);
|
||||
},
|
||||
});
|
||||
const trpcUtils = api.useUtils();
|
||||
|
||||
/* ------------------------------- Plugins Load ----------------------------- */
|
||||
const { data: studyPlugins } = api.robots.plugins.getStudyPlugins.useQuery(
|
||||
{ studyId: experiment?.studyId ?? "" },
|
||||
{ enabled: !!experiment?.studyId },
|
||||
);
|
||||
|
||||
/* ---------------------------- Registry Loading ---------------------------- */
|
||||
useEffect(() => {
|
||||
actionRegistry.loadCoreActions().catch((err) => {
|
||||
console.error("Core actions load failed:", err);
|
||||
toast.error("Failed to load core action library");
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (experiment?.studyId && (studyPlugins?.length ?? 0) > 0) {
|
||||
actionRegistry.loadPluginActions(
|
||||
experiment.studyId,
|
||||
(studyPlugins ?? []).map((sp) => ({
|
||||
plugin: {
|
||||
id: sp.plugin.id,
|
||||
robotId: sp.plugin.robotId,
|
||||
version: sp.plugin.version,
|
||||
actionDefinitions: Array.isArray(sp.plugin.actionDefinitions)
|
||||
? sp.plugin.actionDefinitions
|
||||
: undefined,
|
||||
},
|
||||
})) ?? [],
|
||||
);
|
||||
}
|
||||
}, [experiment?.studyId, studyPlugins]);
|
||||
|
||||
/* ------------------------------ Breadcrumbs ------------------------------- */
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
{
|
||||
label: experiment?.study?.name ?? "Study",
|
||||
href: `/studies/${experiment?.studyId}`,
|
||||
},
|
||||
{ label: "Experiments", href: `/studies/${experiment?.studyId}` },
|
||||
{ label: design.name, href: `/experiments/${experimentId}` },
|
||||
{ label: "Designer" },
|
||||
]);
|
||||
|
||||
/* ------------------------------ DnD Sensors ------------------------------- */
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback((_event: DragStartEvent) => {
|
||||
// activeId tracking removed (drag overlay no longer used)
|
||||
}, []);
|
||||
|
||||
/* ------------------------------ Helpers ----------------------------------- */
|
||||
|
||||
const addActionToStep = useCallback(
|
||||
(stepId: string, def: ActionDefinition) => {
|
||||
const newAction: ExperimentAction = {
|
||||
id: `action_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
type: def.type,
|
||||
name: def.name,
|
||||
parameters: {},
|
||||
category: def.category,
|
||||
source: def.source,
|
||||
execution: def.execution ?? { transport: "internal" },
|
||||
parameterSchemaRaw: def.parameterSchemaRaw,
|
||||
};
|
||||
// Default param values
|
||||
def.parameters.forEach((p) => {
|
||||
if (p.value !== undefined) {
|
||||
newAction.parameters[p.id] = p.value;
|
||||
}
|
||||
});
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s) =>
|
||||
s.id === stepId ? { ...s, actions: [...s.actions, newAction] } : s,
|
||||
),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
toast.success(`Added ${def.name}`);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
// activeId reset removed (no longer tracked)
|
||||
if (!over) return;
|
||||
|
||||
const activeIdStr = active.id.toString();
|
||||
const overIdStr = over.id.toString();
|
||||
|
||||
// From library to step droppable
|
||||
if (activeIdStr.startsWith("action-") && overIdStr.startsWith("step-")) {
|
||||
const actionId = activeIdStr.replace("action-", "");
|
||||
const stepId = overIdStr.replace("step-", "");
|
||||
const def = actionRegistry.getAction(actionId);
|
||||
if (def) {
|
||||
addActionToStep(stepId, def);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Step reorder (both plain ids of steps)
|
||||
if (
|
||||
!activeIdStr.startsWith("action-") &&
|
||||
!overIdStr.startsWith("step-") &&
|
||||
!overIdStr.startsWith("action-")
|
||||
) {
|
||||
const oldIndex = design.steps.findIndex((s) => s.id === activeIdStr);
|
||||
const newIndex = design.steps.findIndex((s) => s.id === overIdStr);
|
||||
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: arrayMove(prev.steps, oldIndex, newIndex).map(
|
||||
(s, index) => ({ ...s, order: index }),
|
||||
),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Action reorder (within same step)
|
||||
if (
|
||||
!activeIdStr.startsWith("action-") &&
|
||||
!overIdStr.startsWith("step-") &&
|
||||
activeIdStr !== overIdStr
|
||||
) {
|
||||
// Identify which step these actions belong to
|
||||
const containingStep = design.steps.find((s) =>
|
||||
s.actions.some((a) => a.id === activeIdStr),
|
||||
);
|
||||
const targetStep = design.steps.find((s) =>
|
||||
s.actions.some((a) => a.id === overIdStr),
|
||||
);
|
||||
if (
|
||||
containingStep &&
|
||||
targetStep &&
|
||||
containingStep.id === targetStep.id
|
||||
) {
|
||||
const oldActionIndex = containingStep.actions.findIndex(
|
||||
(a) => a.id === activeIdStr,
|
||||
);
|
||||
const newActionIndex = containingStep.actions.findIndex(
|
||||
(a) => a.id === overIdStr,
|
||||
);
|
||||
if (
|
||||
oldActionIndex !== -1 &&
|
||||
newActionIndex !== -1 &&
|
||||
oldActionIndex !== newActionIndex
|
||||
) {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s) =>
|
||||
s.id === containingStep.id
|
||||
? {
|
||||
...s,
|
||||
actions: arrayMove(
|
||||
s.actions,
|
||||
oldActionIndex,
|
||||
newActionIndex,
|
||||
),
|
||||
}
|
||||
: s,
|
||||
),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[design.steps, addActionToStep],
|
||||
);
|
||||
|
||||
const addStep = useCallback(() => {
|
||||
const newStep: ExperimentStep = {
|
||||
id: `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
name: `Step ${design.steps.length + 1}`,
|
||||
description: "",
|
||||
type: "sequential",
|
||||
order: design.steps.length,
|
||||
trigger: {
|
||||
type: design.steps.length === 0 ? "trial_start" : "previous_step",
|
||||
conditions: {},
|
||||
},
|
||||
actions: [],
|
||||
expanded: true,
|
||||
};
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: [...prev.steps, newStep],
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
}, [design.steps.length]);
|
||||
|
||||
const updateStep = useCallback(
|
||||
(stepId: string, updates: Partial<ExperimentStep>) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s) =>
|
||||
s.id === stepId ? { ...s, ...updates } : s,
|
||||
),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteStep = useCallback(
|
||||
(stepId: string) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.filter((s) => s.id !== stepId),
|
||||
}));
|
||||
if (selectedStepId === stepId) setSelectedStepId(null);
|
||||
setHasUnsavedChanges(true);
|
||||
},
|
||||
[selectedStepId],
|
||||
);
|
||||
|
||||
const updateAction = useCallback(
|
||||
(stepId: string, actionId: string, updates: Partial<ExperimentAction>) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s) =>
|
||||
s.id === stepId
|
||||
? {
|
||||
...s,
|
||||
actions: s.actions.map((a) =>
|
||||
a.id === actionId ? { ...a, ...updates } : a,
|
||||
),
|
||||
}
|
||||
: s,
|
||||
),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteAction = useCallback(
|
||||
(stepId: string, actionId: string) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s) =>
|
||||
s.id === stepId
|
||||
? {
|
||||
...s,
|
||||
actions: s.actions.filter((a) => a.id !== actionId),
|
||||
}
|
||||
: s,
|
||||
),
|
||||
}));
|
||||
if (selectedActionId === actionId) setSelectedActionId(null);
|
||||
setHasUnsavedChanges(true);
|
||||
},
|
||||
[selectedActionId],
|
||||
);
|
||||
|
||||
/* ------------------------------- Validation ------------------------------- */
|
||||
const runValidation = useCallback(async () => {
|
||||
setIsValidating(true);
|
||||
try {
|
||||
const result = await trpcUtils.experiments.validateDesign.fetch({
|
||||
experimentId,
|
||||
visualDesign: { steps: design.steps },
|
||||
});
|
||||
|
||||
if (!result.valid) {
|
||||
toast.error(
|
||||
`Validation failed: ${result.issues.slice(0, 3).join(", ")}${
|
||||
result.issues.length > 3 ? "…" : ""
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.integrityHash) {
|
||||
setLastValidatedHash(result.integrityHash);
|
||||
setLastValidatedDesignJson(currentDesignJson);
|
||||
toast.success(
|
||||
`Validated • Hash: ${result.integrityHash.slice(0, 10)}…`,
|
||||
);
|
||||
} else {
|
||||
toast.success("Validated (no hash produced)");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Validation error: ${
|
||||
err instanceof Error ? err.message : "Unknown error"
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [experimentId, design.steps, trpcUtils, currentDesignJson]);
|
||||
|
||||
/* --------------------------------- Saving --------------------------------- */
|
||||
const saveDesign = useCallback(() => {
|
||||
const visualDesign = {
|
||||
steps: design.steps,
|
||||
version: design.version,
|
||||
lastSaved: new Date().toISOString(),
|
||||
};
|
||||
updateExperiment.mutate({
|
||||
id: experimentId,
|
||||
visualDesign,
|
||||
createSteps: true,
|
||||
compileExecution: true,
|
||||
});
|
||||
const updatedDesign = { ...design, lastSaved: new Date() };
|
||||
setDesign(updatedDesign);
|
||||
onSave?.(updatedDesign);
|
||||
}, [design, experimentId, onSave, updateExperiment]);
|
||||
|
||||
/* --------------------------- Selection Resolution ------------------------- */
|
||||
const selectedStep = design.steps.find((s) => s.id === selectedStepId);
|
||||
const selectedAction = selectedStep?.actions.find(
|
||||
(a) => a.id === selectedActionId,
|
||||
);
|
||||
|
||||
/* ------------------------------- Header Badges ---------------------------- */
|
||||
const validationBadge = drift ? (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="text-xs"
|
||||
title="Design has drifted since last validation or differs from stored hash"
|
||||
>
|
||||
Drift
|
||||
</Badge>
|
||||
) : lastValidatedHash ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-green-400 text-xs text-green-700 dark:text-green-400"
|
||||
title="Design matches last validated structure"
|
||||
>
|
||||
Validated
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs" title="Not yet validated">
|
||||
Unvalidated
|
||||
</Badge>
|
||||
);
|
||||
|
||||
/* ---------------------------------- Render -------------------------------- */
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title={design.name}
|
||||
description="Design your experiment using steps and categorized actions"
|
||||
icon={Play}
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{validationBadge}
|
||||
{experiment?.integrityHash && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Hash: {experiment.integrityHash.slice(0, 10)}…
|
||||
</Badge>
|
||||
)}
|
||||
{experiment?.executionGraphSummary && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Exec: {experiment.executionGraphSummary.steps ?? 0}s /
|
||||
{experiment.executionGraphSummary.actions ?? 0}a
|
||||
</Badge>
|
||||
)}
|
||||
{Array.isArray(experiment?.pluginDependencies) &&
|
||||
experiment.pluginDependencies.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{experiment.pluginDependencies.length} plugins
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{design.steps.length} steps
|
||||
</Badge>
|
||||
{hasUnsavedChanges && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-orange-300 text-orange-600"
|
||||
>
|
||||
Unsaved
|
||||
</Badge>
|
||||
)}
|
||||
<ActionButton
|
||||
onClick={saveDesign}
|
||||
disabled={!hasUnsavedChanges || updateExperiment.isPending}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{updateExperiment.isPending ? "Saving…" : "Save"}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setHasUnsavedChanges(false); // immediate feedback
|
||||
void runValidation();
|
||||
}}
|
||||
disabled={isValidating}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{isValidating ? "Validating…" : "Revalidate"}
|
||||
</ActionButton>
|
||||
<ActionButton variant="outline">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</ActionButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{/* Action Library */}
|
||||
<div className="col-span-3">
|
||||
<Card className="h-[calc(100vh-12rem)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
Action Library
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ActionLibrary />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Flow */}
|
||||
<div className="col-span-6">
|
||||
<StepFlow
|
||||
steps={design.steps}
|
||||
selectedStepId={selectedStepId}
|
||||
selectedActionId={selectedActionId}
|
||||
onStepSelect={(id) => {
|
||||
setSelectedStepId(id);
|
||||
setSelectedActionId(null);
|
||||
}}
|
||||
onStepDelete={deleteStep}
|
||||
onStepUpdate={updateStep}
|
||||
onActionSelect={(actionId) => setSelectedActionId(actionId)}
|
||||
onActionDelete={deleteAction}
|
||||
emptyState={
|
||||
<div className="py-8 text-center">
|
||||
<Play className="text-muted-foreground/50 mx-auto h-8 w-8" />
|
||||
<h3 className="mt-2 text-sm font-medium">No steps yet</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Add your first step to begin designing
|
||||
</p>
|
||||
<Button className="mt-2" size="sm" onClick={addStep}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add First Step
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
headerRight={
|
||||
<Button size="sm" onClick={addStep} className="h-6 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add Step
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Properties */}
|
||||
<div className="col-span-3">
|
||||
<Card className="h-[calc(100vh-12rem)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
Properties
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<ScrollArea className="h-full pr-1">
|
||||
<PropertiesPanel
|
||||
design={design}
|
||||
selectedStep={selectedStep}
|
||||
selectedAction={selectedAction}
|
||||
onActionUpdate={updateAction}
|
||||
onStepUpdate={updateStep}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -420,7 +420,7 @@ export function DependencyInspector({
|
||||
dependencies.some((d) => d.status !== "available") || drifts.length > 0;
|
||||
|
||||
return (
|
||||
<Card className={cn("h-[calc(100vh-12rem)]", className)}>
|
||||
<Card className={cn("h-full", className)}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Play } from "lucide-react";
|
||||
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
@@ -176,7 +170,7 @@ export function DesignerRoot({
|
||||
const recomputeHash = useDesignerStore((s) => s.recomputeHash);
|
||||
const lastPersistedHash = useDesignerStore((s) => s.lastPersistedHash);
|
||||
const currentDesignHash = useDesignerStore((s) => s.currentDesignHash);
|
||||
const lastValidatedHash = useDesignerStore((s) => s.lastValidatedHash);
|
||||
|
||||
const setPersistedHash = useDesignerStore((s) => s.setPersistedHash);
|
||||
const setValidatedHash = useDesignerStore((s) => s.setValidatedHash);
|
||||
const upsertStep = useDesignerStore((s) => s.upsertStep);
|
||||
@@ -236,6 +230,7 @@ export function DesignerRoot({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const [lastSavedAt, setLastSavedAt] = useState<Date | undefined>(undefined);
|
||||
const [inspectorTab, setInspectorTab] = useState<
|
||||
"properties" | "issues" | "dependencies"
|
||||
@@ -324,12 +319,6 @@ export function DesignerRoot({
|
||||
const hasUnsavedChanges =
|
||||
!!currentDesignHash && lastPersistedHash !== currentDesignHash;
|
||||
|
||||
const driftStatus = useMemo<"unvalidated" | "drift" | "validated">(() => {
|
||||
if (!currentDesignHash || !lastValidatedHash) return "unvalidated";
|
||||
if (currentDesignHash !== lastValidatedHash) return "drift";
|
||||
return "validated";
|
||||
}, [currentDesignHash, lastValidatedHash]);
|
||||
|
||||
/* ------------------------------- Step Ops -------------------------------- */
|
||||
const createNewStep = useCallback(() => {
|
||||
const newStep: ExperimentStep = {
|
||||
@@ -364,7 +353,7 @@ export function DesignerRoot({
|
||||
actionDefinitions: actionRegistry.getAllActions(),
|
||||
});
|
||||
// Debug: log validation results for troubleshooting
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.debug("[DesignerRoot] validation", {
|
||||
valid: result.valid,
|
||||
errors: result.errorCount,
|
||||
@@ -689,7 +678,7 @@ export function DesignerRoot({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-6rem)] flex-col gap-3">
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title={designMeta.name}
|
||||
description="Compose ordered steps with provenance-aware actions."
|
||||
@@ -718,7 +707,7 @@ export function DesignerRoot({
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border">
|
||||
<div className="flex h-[calc(100vh-12rem)] w-full max-w-full flex-col overflow-hidden rounded-md border">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={pointerWithin}
|
||||
@@ -727,23 +716,22 @@ export function DesignerRoot({
|
||||
onDragCancel={() => toggleLibraryScrollLock(false)}
|
||||
>
|
||||
<PanelsContainer
|
||||
showDividers
|
||||
className="min-h-0 flex-1"
|
||||
left={
|
||||
<div ref={libraryRootRef} data-library-root>
|
||||
<div ref={libraryRootRef} data-library-root className="h-full">
|
||||
<ActionLibraryPanel />
|
||||
</div>
|
||||
}
|
||||
center={<FlowWorkspace />}
|
||||
right={
|
||||
<InspectorPanel
|
||||
activeTab={inspectorTab}
|
||||
onTabChange={setInspectorTab}
|
||||
/>
|
||||
<div className="h-full">
|
||||
<InspectorPanel
|
||||
activeTab={inspectorTab}
|
||||
onTabChange={setInspectorTab}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
initialLeftWidth={260}
|
||||
initialRightWidth={260}
|
||||
minRightWidth={240}
|
||||
maxRightWidth={300}
|
||||
className="flex-1"
|
||||
/>
|
||||
<DragOverlay>
|
||||
{dragOverlayAction ? (
|
||||
@@ -753,15 +741,17 @@ export function DesignerRoot({
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<BottomStatusBar
|
||||
onSave={() => persist()}
|
||||
onValidate={() => validateDesign()}
|
||||
onExport={() => handleExport()}
|
||||
lastSavedAt={lastSavedAt}
|
||||
saving={isSaving}
|
||||
validating={isValidating}
|
||||
exporting={isExporting}
|
||||
/>
|
||||
<div className="flex-shrink-0 border-t">
|
||||
<BottomStatusBar
|
||||
onSave={() => persist()}
|
||||
onValidate={() => validateDesign()}
|
||||
onExport={() => handleExport()}
|
||||
lastSavedAt={lastSavedAt}
|
||||
saving={isSaving}
|
||||
validating={isValidating}
|
||||
exporting={isExporting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,734 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DesignerShell
|
||||
*
|
||||
* High-level orchestration component for the Experiment Designer redesign.
|
||||
* Replaces prior monolithic `BlockDesigner` responsibilities and delegates:
|
||||
* - Data loading (experiment + study plugins)
|
||||
* - Store initialization (steps, persisted/validated hashes)
|
||||
* - Hash & drift status display
|
||||
* - Save / validate / export actions (callback props)
|
||||
* - Layout composition (Action Library | Step Flow | Properties Panel)
|
||||
*
|
||||
* This file intentionally does NOT contain:
|
||||
* - Raw drag & drop logic (belongs to StepFlow & related internal modules)
|
||||
* - Parameter field rendering logic (PropertiesPanel / ParameterFieldFactory)
|
||||
* - Action registry loading internals (ActionRegistry singleton)
|
||||
*
|
||||
* Future Extensions:
|
||||
* - Conflict modal
|
||||
* - Bulk drift reconciliation
|
||||
* - Command palette (action insertion)
|
||||
* - Auto-save throttle controls
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Play, Save, Download, RefreshCw } from "lucide-react";
|
||||
import { DndContext, closestCenter } from "@dnd-kit/core";
|
||||
import type { DragEndEvent, DragOverEvent } from "@dnd-kit/core";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { PageHeader, ActionButton } from "~/components/ui/page-header";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import type {
|
||||
ExperimentDesign,
|
||||
ExperimentStep,
|
||||
ExperimentAction,
|
||||
ActionDefinition,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
|
||||
import { useDesignerStore } from "./state/store";
|
||||
import { computeDesignHash } from "./state/hashing";
|
||||
|
||||
import { actionRegistry } from "./ActionRegistry";
|
||||
import { ActionLibrary } from "./ActionLibrary";
|
||||
import { StepFlow } from "./StepFlow";
|
||||
import { PropertiesPanel } from "./PropertiesPanel";
|
||||
import { ValidationPanel } from "./ValidationPanel";
|
||||
import { DependencyInspector } from "./DependencyInspector";
|
||||
import { validateExperimentDesign } from "./state/validators";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface DesignerShellProps {
|
||||
experimentId: string;
|
||||
initialDesign?: ExperimentDesign;
|
||||
/**
|
||||
* Called after a successful persisted save (server acknowledged).
|
||||
*/
|
||||
onPersist?: (design: ExperimentDesign) => void;
|
||||
/**
|
||||
* Whether to auto-run compilation on save.
|
||||
*/
|
||||
autoCompile?: boolean;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Utility */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function buildEmptyDesign(
|
||||
experimentId: string,
|
||||
name?: string,
|
||||
description?: string | null,
|
||||
): ExperimentDesign {
|
||||
return {
|
||||
id: experimentId,
|
||||
name: name?.trim().length ? name : "Untitled Experiment",
|
||||
description: description ?? "",
|
||||
version: 1,
|
||||
steps: [],
|
||||
lastSaved: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
function adaptExistingDesign(experiment: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
visualDesign: unknown;
|
||||
}): ExperimentDesign | undefined {
|
||||
if (
|
||||
!experiment?.visualDesign ||
|
||||
typeof experiment.visualDesign !== "object" ||
|
||||
!("steps" in (experiment.visualDesign as Record<string, unknown>))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const vd = experiment.visualDesign as {
|
||||
steps?: ExperimentStep[];
|
||||
version?: number;
|
||||
lastSaved?: string;
|
||||
};
|
||||
if (!vd.steps || !Array.isArray(vd.steps)) return undefined;
|
||||
return {
|
||||
id: experiment.id,
|
||||
name: experiment.name,
|
||||
description: experiment.description ?? "",
|
||||
steps: vd.steps,
|
||||
version: vd.version ?? 1,
|
||||
lastSaved:
|
||||
vd.lastSaved && typeof vd.lastSaved === "string"
|
||||
? new Date(vd.lastSaved)
|
||||
: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* DesignerShell */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function DesignerShell({
|
||||
experimentId,
|
||||
initialDesign,
|
||||
onPersist,
|
||||
autoCompile = true,
|
||||
}: DesignerShellProps) {
|
||||
/* ---------------------------- Remote Experiment --------------------------- */
|
||||
const {
|
||||
data: experiment,
|
||||
isLoading: loadingExperiment,
|
||||
refetch: refetchExperiment,
|
||||
} = api.experiments.get.useQuery({ id: experimentId });
|
||||
|
||||
/* ------------------------------ Store Access ------------------------------ */
|
||||
const steps = useDesignerStore((s) => s.steps);
|
||||
const setSteps = useDesignerStore((s) => s.setSteps);
|
||||
const recomputeHash = useDesignerStore((s) => s.recomputeHash);
|
||||
const currentDesignHash = useDesignerStore((s) => s.currentDesignHash);
|
||||
const lastPersistedHash = useDesignerStore((s) => s.lastPersistedHash);
|
||||
const lastValidatedHash = useDesignerStore((s) => s.lastValidatedHash);
|
||||
const setPersistedHash = useDesignerStore((s) => s.setPersistedHash);
|
||||
const setValidatedHash = useDesignerStore((s) => s.setValidatedHash);
|
||||
const selectedActionId = useDesignerStore((s) => s.selectedActionId);
|
||||
const selectedStepId = useDesignerStore((s) => s.selectedStepId);
|
||||
const selectStep = useDesignerStore((s) => s.selectStep);
|
||||
const selectAction = useDesignerStore((s) => s.selectAction);
|
||||
const validationIssues = useDesignerStore((s) => s.validationIssues);
|
||||
const actionSignatureDrift = useDesignerStore((s) => s.actionSignatureDrift);
|
||||
const upsertStep = useDesignerStore((s) => s.upsertStep);
|
||||
const removeStep = useDesignerStore((s) => s.removeStep);
|
||||
const upsertAction = useDesignerStore((s) => s.upsertAction);
|
||||
const removeAction = useDesignerStore((s) => s.removeAction);
|
||||
|
||||
/* ------------------------------ Step Creation ------------------------------ */
|
||||
const createNewStep = useCallback(() => {
|
||||
const newStep: ExperimentStep = {
|
||||
id: `step-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: `Step ${steps.length + 1}`,
|
||||
description: "",
|
||||
type: "sequential",
|
||||
order: steps.length,
|
||||
trigger: {
|
||||
type: "trial_start",
|
||||
conditions: {},
|
||||
},
|
||||
actions: [],
|
||||
expanded: true,
|
||||
};
|
||||
upsertStep(newStep);
|
||||
selectStep(newStep.id);
|
||||
toast.success(`Created ${newStep.name}`);
|
||||
}, [steps.length, upsertStep, selectStep]);
|
||||
|
||||
/* ------------------------------ DnD Handlers ------------------------------ */
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over) return;
|
||||
|
||||
// Handle action drag to step
|
||||
if (
|
||||
active.id.toString().startsWith("action-") &&
|
||||
over.id.toString().startsWith("step-")
|
||||
) {
|
||||
const actionData = active.data.current?.action as ActionDefinition;
|
||||
const stepId = over.id.toString().replace("step-", "");
|
||||
|
||||
if (!actionData) return;
|
||||
|
||||
const step = steps.find((s) => s.id === stepId);
|
||||
if (!step) return;
|
||||
|
||||
// Create new action instance
|
||||
const newAction: ExperimentAction = {
|
||||
id: `action-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: actionData.type,
|
||||
name: actionData.name,
|
||||
category: actionData.category,
|
||||
parameters: {},
|
||||
source: actionData.source,
|
||||
execution: actionData.execution ?? {
|
||||
transport: "internal",
|
||||
retryable: false,
|
||||
},
|
||||
};
|
||||
|
||||
upsertAction(stepId, newAction);
|
||||
selectStep(stepId);
|
||||
selectAction(stepId, newAction.id);
|
||||
toast.success(`Added ${actionData.name} to ${step.name}`);
|
||||
}
|
||||
},
|
||||
[steps, upsertAction, selectStep, selectAction],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((_event: DragOverEvent) => {
|
||||
// This could be used for visual feedback during drag
|
||||
}, []);
|
||||
|
||||
/* ------------------------------- Local State ------------------------------ */
|
||||
const [designMeta, setDesignMeta] = useState<{
|
||||
name: string;
|
||||
description: string;
|
||||
version: number;
|
||||
}>(() => {
|
||||
const init =
|
||||
initialDesign ??
|
||||
(experiment ? adaptExistingDesign(experiment) : undefined) ??
|
||||
buildEmptyDesign(experimentId, experiment?.name, experiment?.description);
|
||||
return {
|
||||
name: init.name,
|
||||
description: init.description,
|
||||
version: init.version,
|
||||
};
|
||||
});
|
||||
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
/* ----------------------------- Experiment Update -------------------------- */
|
||||
const updateExperiment = api.experiments.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast.success("Experiment saved");
|
||||
await refetchExperiment();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(`Save failed: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
/* ------------------------------ Plugin Loading ---------------------------- */
|
||||
const { data: studyPlugins } = api.robots.plugins.getStudyPlugins.useQuery(
|
||||
{ studyId: experiment?.studyId ?? "" },
|
||||
{ enabled: !!experiment?.studyId },
|
||||
);
|
||||
|
||||
// Load core actions once
|
||||
useEffect(() => {
|
||||
actionRegistry
|
||||
.loadCoreActions()
|
||||
.catch((err) => console.error("Core action load failed:", err));
|
||||
}, []);
|
||||
|
||||
// Load study plugin actions when available
|
||||
useEffect(() => {
|
||||
if (!experiment?.studyId) return;
|
||||
if (!studyPlugins || studyPlugins.length === 0) return;
|
||||
actionRegistry.loadPluginActions(
|
||||
experiment.studyId,
|
||||
studyPlugins.map((sp) => ({
|
||||
plugin: {
|
||||
id: sp.plugin.id,
|
||||
robotId: sp.plugin.robotId,
|
||||
version: sp.plugin.version,
|
||||
actionDefinitions: Array.isArray(sp.plugin.actionDefinitions)
|
||||
? sp.plugin.actionDefinitions
|
||||
: undefined,
|
||||
},
|
||||
})),
|
||||
);
|
||||
}, [experiment?.studyId, studyPlugins]);
|
||||
|
||||
/* ------------------------- Initialize Store Steps ------------------------- */
|
||||
useEffect(() => {
|
||||
if (initialized) return;
|
||||
if (loadingExperiment) return;
|
||||
const resolvedInitial =
|
||||
initialDesign ??
|
||||
(experiment ? adaptExistingDesign(experiment) : undefined) ??
|
||||
buildEmptyDesign(experimentId, experiment?.name, experiment?.description);
|
||||
setDesignMeta({
|
||||
name: resolvedInitial.name,
|
||||
description: resolvedInitial.description,
|
||||
version: resolvedInitial.version,
|
||||
});
|
||||
setSteps(resolvedInitial.steps);
|
||||
// Set persisted hash if experiment already has integrityHash
|
||||
if (experiment?.integrityHash) {
|
||||
setPersistedHash(experiment.integrityHash);
|
||||
setValidatedHash(experiment.integrityHash);
|
||||
}
|
||||
setInitialized(true);
|
||||
// Kick off first hash compute
|
||||
void recomputeHash();
|
||||
}, [
|
||||
initialized,
|
||||
loadingExperiment,
|
||||
experiment,
|
||||
initialDesign,
|
||||
experimentId,
|
||||
setSteps,
|
||||
setPersistedHash,
|
||||
setValidatedHash,
|
||||
recomputeHash,
|
||||
]);
|
||||
|
||||
/* ----------------------------- Drift Computation -------------------------- */
|
||||
const driftState = useMemo(() => {
|
||||
if (!lastValidatedHash || !currentDesignHash) {
|
||||
return {
|
||||
status: "unvalidated" as const,
|
||||
drift: false,
|
||||
};
|
||||
}
|
||||
if (currentDesignHash !== lastValidatedHash) {
|
||||
return { status: "drift" as const, drift: true };
|
||||
}
|
||||
return { status: "validated" as const, drift: false };
|
||||
}, [lastValidatedHash, currentDesignHash]);
|
||||
|
||||
/* ------------------------------ Derived Flags ----------------------------- */
|
||||
const hasUnsavedChanges =
|
||||
!!currentDesignHash && lastPersistedHash !== currentDesignHash;
|
||||
|
||||
const totalActions = steps.reduce((sum, s) => sum + s.actions.length, 0);
|
||||
|
||||
/* ------------------------------- Validation ------------------------------- */
|
||||
const validateDesign = useCallback(async () => {
|
||||
if (!experimentId) return;
|
||||
setIsValidating(true);
|
||||
try {
|
||||
// Run local validation
|
||||
const validationResult = validateExperimentDesign(steps, {
|
||||
steps,
|
||||
actionDefinitions: actionRegistry.getAllActions(),
|
||||
});
|
||||
|
||||
// Compute hash for integrity
|
||||
const hash = await computeDesignHash(steps);
|
||||
setValidatedHash(hash);
|
||||
|
||||
if (validationResult.valid) {
|
||||
toast.success(`Validated • ${hash.slice(0, 10)}… • No issues found`);
|
||||
} else {
|
||||
toast.warning(
|
||||
`Validated with ${validationResult.errorCount} errors, ${validationResult.warningCount} warnings`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Validation error: ${
|
||||
err instanceof Error ? err.message : "Unknown error"
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [experimentId, steps, setValidatedHash]);
|
||||
|
||||
/* ---------------------------------- Save ---------------------------------- */
|
||||
const persist = useCallback(async () => {
|
||||
if (!experimentId) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const visualDesign = {
|
||||
steps,
|
||||
version: designMeta.version,
|
||||
lastSaved: new Date().toISOString(),
|
||||
};
|
||||
updateExperiment.mutate({
|
||||
id: experimentId,
|
||||
visualDesign,
|
||||
createSteps: true,
|
||||
compileExecution: autoCompile,
|
||||
});
|
||||
// Optimistic hash recompute to reflect state
|
||||
await recomputeHash();
|
||||
onPersist?.({
|
||||
id: experimentId,
|
||||
name: designMeta.name,
|
||||
description: designMeta.description,
|
||||
version: designMeta.version,
|
||||
steps,
|
||||
lastSaved: new Date(),
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
experimentId,
|
||||
steps,
|
||||
designMeta,
|
||||
recomputeHash,
|
||||
updateExperiment,
|
||||
onPersist,
|
||||
autoCompile,
|
||||
]);
|
||||
|
||||
/* -------------------------------- Export ---------------------------------- */
|
||||
const handleExport = useCallback(async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const designHash = currentDesignHash ?? (await computeDesignHash(steps));
|
||||
const bundle = {
|
||||
format: "hristudio.design.v1",
|
||||
exportedAt: new Date().toISOString(),
|
||||
experiment: {
|
||||
id: experimentId,
|
||||
name: designMeta.name,
|
||||
version: designMeta.version,
|
||||
integrityHash: designHash,
|
||||
steps,
|
||||
pluginDependencies:
|
||||
experiment?.pluginDependencies?.slice().sort() ?? [],
|
||||
},
|
||||
compiled: null, // Will be implemented when execution graph is available
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(bundle, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${designMeta.name
|
||||
.replace(/[^a-z0-9-_]+/gi, "_")
|
||||
.toLowerCase()}_design.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Exported design bundle");
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Export failed: ${
|
||||
err instanceof Error ? err.message : "Unknown error"
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [
|
||||
currentDesignHash,
|
||||
steps,
|
||||
experimentId,
|
||||
designMeta,
|
||||
experiment?.pluginDependencies,
|
||||
]);
|
||||
|
||||
/* ---------------------------- Incremental Hashing ------------------------- */
|
||||
// Optionally re-hash after step mutations (basic heuristic)
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
void recomputeHash();
|
||||
}, [steps.length, initialized, recomputeHash]);
|
||||
|
||||
/* ------------------------------- Header Badges ---------------------------- */
|
||||
const hashBadge =
|
||||
driftState.status === "drift" ? (
|
||||
<Badge variant="destructive" title="Design drift detected">
|
||||
Drift
|
||||
</Badge>
|
||||
) : driftState.status === "validated" ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-green-400 text-green-700 dark:text-green-400"
|
||||
title="Design validated"
|
||||
>
|
||||
Validated
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" title="Not validated">
|
||||
Unvalidated
|
||||
</Badge>
|
||||
);
|
||||
|
||||
/* ------------------------------- Render ----------------------------------- */
|
||||
if (loadingExperiment && !initialized) {
|
||||
return (
|
||||
<div className="py-24 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Loading experiment design…
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title={designMeta.name}
|
||||
description="Design your experiment by composing ordered steps with provenance-aware actions."
|
||||
icon={Play}
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{hashBadge}
|
||||
{experiment?.integrityHash && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Hash: {experiment.integrityHash.slice(0, 10)}…
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{steps.length} steps
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{totalActions} actions
|
||||
</Badge>
|
||||
{hasUnsavedChanges && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-orange-300 text-orange-600"
|
||||
>
|
||||
Unsaved
|
||||
</Badge>
|
||||
)}
|
||||
<ActionButton
|
||||
onClick={persist}
|
||||
disabled={!hasUnsavedChanges || isSaving}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isSaving ? "Saving…" : "Save"}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="outline"
|
||||
onClick={validateDesign}
|
||||
disabled={isValidating}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{isValidating ? "Validating…" : "Validate"}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="outline"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{isExporting ? "Exporting…" : "Export"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{/* Action Library */}
|
||||
<div className="col-span-3">
|
||||
<Card className="h-[calc(100vh-12rem)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
Action Library
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ActionLibrary />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Step Flow */}
|
||||
<div className="col-span-6">
|
||||
<StepFlow
|
||||
steps={steps}
|
||||
selectedStepId={selectedStepId ?? null}
|
||||
selectedActionId={selectedActionId ?? null}
|
||||
onStepSelect={(id: string) => selectStep(id)}
|
||||
onActionSelect={(id: string) =>
|
||||
selectedStepId && id
|
||||
? selectAction(selectedStepId, id)
|
||||
: undefined
|
||||
}
|
||||
onStepDelete={(stepId: string) => {
|
||||
removeStep(stepId);
|
||||
toast.success("Step deleted");
|
||||
}}
|
||||
onStepUpdate={(
|
||||
stepId: string,
|
||||
updates: Partial<ExperimentStep>,
|
||||
) => {
|
||||
const step = steps.find((s) => s.id === stepId);
|
||||
if (!step) return;
|
||||
upsertStep({ ...step, ...updates });
|
||||
}}
|
||||
onActionDelete={(stepId: string, actionId: string) => {
|
||||
removeAction(stepId, actionId);
|
||||
toast.success("Action deleted");
|
||||
}}
|
||||
emptyState={
|
||||
<div className="text-muted-foreground py-10 text-center text-sm">
|
||||
Add your first step to begin designing.
|
||||
</div>
|
||||
}
|
||||
headerRight={
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={createNewStep}
|
||||
>
|
||||
+ Step
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Properties Panel */}
|
||||
<div className="col-span-3">
|
||||
<Tabs defaultValue="properties" className="h-[calc(100vh-12rem)]">
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-2">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="properties" className="text-xs">
|
||||
Properties
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="validation" className="text-xs">
|
||||
Issues
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="dependencies" className="text-xs">
|
||||
Dependencies
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<TabsContent value="properties" className="m-0 h-full">
|
||||
<ScrollArea className="h-full p-3">
|
||||
<PropertiesPanel
|
||||
design={{
|
||||
id: experimentId,
|
||||
name: designMeta.name,
|
||||
description: designMeta.description,
|
||||
version: designMeta.version,
|
||||
steps,
|
||||
lastSaved: new Date(),
|
||||
}}
|
||||
selectedStep={steps.find(
|
||||
(s) => s.id === selectedStepId,
|
||||
)}
|
||||
selectedAction={
|
||||
steps
|
||||
.find(
|
||||
(s: ExperimentStep) => s.id === selectedStepId,
|
||||
)
|
||||
?.actions.find(
|
||||
(a: ExperimentAction) =>
|
||||
a.id === selectedActionId,
|
||||
) ?? undefined
|
||||
}
|
||||
onActionUpdate={(stepId, actionId, updates) => {
|
||||
const step = steps.find((s) => s.id === stepId);
|
||||
if (!step) return;
|
||||
const action = step.actions.find(
|
||||
(a) => a.id === actionId,
|
||||
);
|
||||
if (!action) return;
|
||||
upsertAction(stepId, { ...action, ...updates });
|
||||
}}
|
||||
onStepUpdate={(stepId, updates) => {
|
||||
const step = steps.find((s) => s.id === stepId);
|
||||
if (!step) return;
|
||||
upsertStep({ ...step, ...updates });
|
||||
}}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="validation" className="m-0 h-full">
|
||||
<ValidationPanel
|
||||
issues={validationIssues}
|
||||
onIssueClick={(issue) => {
|
||||
if (issue.stepId) {
|
||||
selectStep(issue.stepId);
|
||||
if (issue.actionId) {
|
||||
selectAction(issue.stepId, issue.actionId);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="dependencies" className="m-0 h-full">
|
||||
<DependencyInspector
|
||||
steps={steps}
|
||||
actionSignatureDrift={actionSignatureDrift}
|
||||
actionDefinitions={actionRegistry.getAllActions()}
|
||||
onReconcileAction={(actionId) => {
|
||||
// TODO: Implement drift reconciliation
|
||||
toast.info(
|
||||
`Reconciliation for action ${actionId} - TODO`,
|
||||
);
|
||||
}}
|
||||
onRefreshDependencies={() => {
|
||||
// TODO: Implement dependency refresh
|
||||
toast.info("Dependency refresh - TODO");
|
||||
}}
|
||||
onInstallPlugin={(pluginId) => {
|
||||
// TODO: Implement plugin installation
|
||||
toast.info(`Install plugin ${pluginId} - TODO`);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DesignerShell;
|
||||
@@ -1,470 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Save,
|
||||
Download,
|
||||
Upload,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
GitBranch,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export type VersionStrategy = "manual" | "auto_minor" | "auto_patch";
|
||||
export type SaveState = "clean" | "dirty" | "saving" | "conflict" | "error";
|
||||
|
||||
export interface SaveBarProps {
|
||||
/**
|
||||
* Current save state
|
||||
*/
|
||||
saveState: SaveState;
|
||||
/**
|
||||
* Whether auto-save is enabled
|
||||
*/
|
||||
autoSaveEnabled: boolean;
|
||||
/**
|
||||
* Current version strategy
|
||||
*/
|
||||
versionStrategy: VersionStrategy;
|
||||
/**
|
||||
* Number of unsaved changes
|
||||
*/
|
||||
dirtyCount: number;
|
||||
/**
|
||||
* Current design hash for integrity
|
||||
*/
|
||||
currentHash?: string;
|
||||
/**
|
||||
* Last persisted hash
|
||||
*/
|
||||
persistedHash?: string;
|
||||
/**
|
||||
* Last save timestamp
|
||||
*/
|
||||
lastSaved?: Date;
|
||||
/**
|
||||
* Whether there's a conflict with server state
|
||||
*/
|
||||
hasConflict?: boolean;
|
||||
/**
|
||||
* Current experiment version
|
||||
*/
|
||||
currentVersion: number;
|
||||
/**
|
||||
* Called when user manually saves
|
||||
*/
|
||||
onSave: () => void;
|
||||
/**
|
||||
* Called when user exports the design
|
||||
*/
|
||||
onExport: () => void;
|
||||
/**
|
||||
* Called when user imports a design
|
||||
*/
|
||||
onImport?: (file: File) => void;
|
||||
/**
|
||||
* Called when auto-save setting changes
|
||||
*/
|
||||
onAutoSaveChange: (enabled: boolean) => void;
|
||||
/**
|
||||
* Called when version strategy changes
|
||||
*/
|
||||
onVersionStrategyChange: (strategy: VersionStrategy) => void;
|
||||
/**
|
||||
* Called when user resolves a conflict
|
||||
*/
|
||||
onResolveConflict?: () => void;
|
||||
/**
|
||||
* Called when user wants to validate the design
|
||||
*/
|
||||
onValidate?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Save State Configuration */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const saveStateConfig = {
|
||||
clean: {
|
||||
icon: CheckCircle,
|
||||
color: "text-green-600 dark:text-green-400",
|
||||
label: "Saved",
|
||||
description: "All changes saved",
|
||||
},
|
||||
dirty: {
|
||||
icon: AlertCircle,
|
||||
color: "text-amber-600 dark:text-amber-400",
|
||||
label: "Unsaved",
|
||||
description: "You have unsaved changes",
|
||||
},
|
||||
saving: {
|
||||
icon: RefreshCw,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
label: "Saving",
|
||||
description: "Saving changes...",
|
||||
},
|
||||
conflict: {
|
||||
icon: AlertTriangle,
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
label: "Conflict",
|
||||
description: "Server conflict detected",
|
||||
},
|
||||
error: {
|
||||
icon: AlertTriangle,
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
label: "Error",
|
||||
description: "Save failed",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Version Strategy Options */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const versionStrategyOptions = [
|
||||
{
|
||||
value: "manual" as const,
|
||||
label: "Manual",
|
||||
description: "Only increment version when explicitly requested",
|
||||
},
|
||||
{
|
||||
value: "auto_minor" as const,
|
||||
label: "Auto Minor",
|
||||
description: "Auto-increment minor version on structural changes",
|
||||
},
|
||||
{
|
||||
value: "auto_patch" as const,
|
||||
label: "Auto Patch",
|
||||
description: "Auto-increment patch version on any save",
|
||||
},
|
||||
];
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Utility Functions */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function formatLastSaved(date?: Date): string {
|
||||
if (!date) return "Never";
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
function getNextVersion(
|
||||
current: number,
|
||||
strategy: VersionStrategy,
|
||||
hasStructuralChanges = false,
|
||||
): number {
|
||||
switch (strategy) {
|
||||
case "manual":
|
||||
return current;
|
||||
case "auto_minor":
|
||||
return hasStructuralChanges ? current + 1 : current;
|
||||
case "auto_patch":
|
||||
return current + 1;
|
||||
default:
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Import Handler */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function ImportButton({ onImport }: { onImport?: (file: File) => void }) {
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file && onImport) {
|
||||
onImport(file);
|
||||
}
|
||||
// Reset input to allow re-selecting the same file
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
if (!onImport) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
id="import-design"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => document.getElementById("import-design")?.click()}
|
||||
>
|
||||
<Upload className="mr-2 h-3 w-3" />
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* SaveBar Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function SaveBar({
|
||||
saveState,
|
||||
autoSaveEnabled,
|
||||
versionStrategy,
|
||||
dirtyCount,
|
||||
currentHash,
|
||||
persistedHash,
|
||||
lastSaved,
|
||||
hasConflict,
|
||||
currentVersion,
|
||||
onSave,
|
||||
onExport,
|
||||
onImport,
|
||||
onAutoSaveChange,
|
||||
onVersionStrategyChange,
|
||||
onResolveConflict,
|
||||
onValidate,
|
||||
className,
|
||||
}: SaveBarProps) {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
const config = saveStateConfig[saveState];
|
||||
const IconComponent = config.icon;
|
||||
|
||||
const hasUnsavedChanges = saveState === "dirty" || dirtyCount > 0;
|
||||
const canSave = hasUnsavedChanges && saveState !== "saving";
|
||||
const hashesMatch =
|
||||
currentHash && persistedHash && currentHash === persistedHash;
|
||||
|
||||
return (
|
||||
<Card className={cn("rounded-t-none border-t-0", className)}>
|
||||
<div className="flex items-center justify-between p-3">
|
||||
{/* Left: Save Status & Info */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Save State Indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
<IconComponent
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
config.color,
|
||||
saveState === "saving" && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{config.label}</span>
|
||||
{dirtyCount > 0 && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({dirtyCount} changes)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
|
||||
{/* Version Info */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<GitBranch className="text-muted-foreground h-3 w-3" />
|
||||
<span className="text-muted-foreground">Version</span>
|
||||
<Badge variant="outline" className="h-5 text-xs">
|
||||
v{currentVersion}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Last Saved */}
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatLastSaved(lastSaved)}</span>
|
||||
</div>
|
||||
|
||||
{/* Hash Status */}
|
||||
{currentHash && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge
|
||||
variant={hashesMatch ? "outline" : "secondary"}
|
||||
className="h-5 font-mono text-[10px]"
|
||||
>
|
||||
{currentHash.slice(0, 8)}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Conflict Resolution */}
|
||||
{hasConflict && onResolveConflict && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={onResolveConflict}
|
||||
>
|
||||
<AlertTriangle className="mr-2 h-3 w-3" />
|
||||
Resolve Conflict
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Validate */}
|
||||
{onValidate && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={onValidate}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-3 w-3" />
|
||||
Validate
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Import */}
|
||||
<ImportButton onImport={onImport} />
|
||||
|
||||
{/* Export */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={onExport}
|
||||
>
|
||||
<Download className="mr-2 h-3 w-3" />
|
||||
Export
|
||||
</Button>
|
||||
|
||||
{/* Save */}
|
||||
<Button
|
||||
variant={canSave ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={onSave}
|
||||
disabled={!canSave}
|
||||
>
|
||||
<Save className="mr-2 h-3 w-3" />
|
||||
{saveState === "saving" ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
|
||||
{/* Settings Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings Panel */}
|
||||
{showSettings && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="bg-muted/30 space-y-3 p-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Auto-Save Toggle */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Auto-Save</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="auto-save"
|
||||
checked={autoSaveEnabled}
|
||||
onCheckedChange={onAutoSaveChange}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="auto-save"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
Save automatically when idle
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version Strategy */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Version Strategy</Label>
|
||||
<Select
|
||||
value={versionStrategy}
|
||||
onValueChange={onVersionStrategyChange}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{versionStrategyOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div>
|
||||
<div className="font-medium">{option.label}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{option.description}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Next Version */}
|
||||
{versionStrategy !== "manual" && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Next save will create version{" "}
|
||||
<Badge variant="outline" className="h-4 text-[10px]">
|
||||
v
|
||||
{getNextVersion(
|
||||
currentVersion,
|
||||
versionStrategy,
|
||||
hasUnsavedChanges,
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Details */}
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{config.description}
|
||||
{hasUnsavedChanges && autoSaveEnabled && (
|
||||
<span> • Auto-save enabled</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,443 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import {
|
||||
useSortable,
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import {
|
||||
GripVertical,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Trash2,
|
||||
Zap,
|
||||
MessageSquare,
|
||||
Hand,
|
||||
Navigation,
|
||||
Volume2,
|
||||
Clock,
|
||||
Eye,
|
||||
Bot,
|
||||
User,
|
||||
Timer,
|
||||
MousePointer,
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
GitBranch,
|
||||
} from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import type {
|
||||
ExperimentStep,
|
||||
ExperimentAction,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
import { actionRegistry } from "./ActionRegistry";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Icon Map (localized to avoid cross-file re-render dependencies) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
MessageSquare,
|
||||
Hand,
|
||||
Navigation,
|
||||
Volume2,
|
||||
Clock,
|
||||
Eye,
|
||||
Bot,
|
||||
User,
|
||||
Zap,
|
||||
Timer,
|
||||
MousePointer,
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
GitBranch,
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* DroppableStep */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface DroppableStepProps {
|
||||
stepId: string;
|
||||
children: React.ReactNode;
|
||||
isEmpty?: boolean;
|
||||
}
|
||||
|
||||
function DroppableStep({ stepId, children, isEmpty }: DroppableStepProps) {
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: `step-${stepId}`,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"min-h-[60px] rounded border-2 border-dashed transition-colors",
|
||||
isOver
|
||||
? "border-blue-500 bg-blue-50 dark:bg-blue-950/20"
|
||||
: "border-transparent",
|
||||
isEmpty && "bg-muted/20",
|
||||
)}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<div className="flex items-center justify-center p-4 text-center">
|
||||
<div className="text-muted-foreground">
|
||||
<Plus className="mx-auto mb-1 h-5 w-5" />
|
||||
<p className="text-xs">Drop actions here</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* SortableAction */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface SortableActionProps {
|
||||
action: ExperimentAction;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function SortableAction({
|
||||
action,
|
||||
index,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
}: SortableActionProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: action.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const def = actionRegistry.getAction(action.type);
|
||||
const IconComponent = iconMap[def?.icon ?? "Zap"] ?? Zap;
|
||||
|
||||
const categoryColors = {
|
||||
wizard: "bg-blue-500",
|
||||
robot: "bg-emerald-500",
|
||||
control: "bg-amber-500",
|
||||
observation: "bg-purple-500",
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
className={cn(
|
||||
"group flex cursor-pointer items-center justify-between rounded border p-2 text-xs transition-colors",
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-950/30"
|
||||
: "hover:bg-accent/50",
|
||||
isDragging && "opacity-50",
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
{...listeners}
|
||||
className="text-muted-foreground/80 hover:text-foreground cursor-grab rounded p-0.5"
|
||||
>
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</div>
|
||||
<Badge variant="outline" className="h-4 text-[10px]">
|
||||
{index + 1}
|
||||
</Badge>
|
||||
{def && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 flex-shrink-0 items-center justify-center rounded text-white",
|
||||
categoryColors[def.category],
|
||||
)}
|
||||
>
|
||||
<IconComponent className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
<span className="flex items-center gap-1 truncate font-medium">
|
||||
{action.source.kind === "plugin" ? (
|
||||
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-emerald-600 text-[8px] font-bold text-white">
|
||||
P
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-slate-500 text-[8px] font-bold text-white">
|
||||
C
|
||||
</span>
|
||||
)}
|
||||
{action.name}
|
||||
</span>
|
||||
<Badge variant="secondary" className="h-4 text-[10px] capitalize">
|
||||
{(action.type ?? "").replace(/_/g, " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* SortableStep */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface SortableStepProps {
|
||||
step: ExperimentStep;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
selectedActionId: string | null;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
onUpdate: (updates: Partial<ExperimentStep>) => void;
|
||||
onActionSelect: (actionId: string) => void;
|
||||
onActionDelete: (actionId: string) => void;
|
||||
}
|
||||
|
||||
function SortableStep({
|
||||
step,
|
||||
index,
|
||||
isSelected,
|
||||
selectedActionId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
onActionSelect,
|
||||
onActionDelete,
|
||||
}: SortableStepProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: step.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const stepTypeColors: Record<ExperimentStep["type"], string> = {
|
||||
sequential: "border-l-blue-500",
|
||||
parallel: "border-l-emerald-500",
|
||||
conditional: "border-l-amber-500",
|
||||
loop: "border-l-purple-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
<Card
|
||||
className={cn(
|
||||
"border-l-4 transition-all",
|
||||
stepTypeColors[step.type],
|
||||
isSelected
|
||||
? "bg-blue-50/50 ring-2 ring-blue-500 dark:bg-blue-950/20 dark:ring-blue-400"
|
||||
: "",
|
||||
isDragging && "rotate-2 opacity-50 shadow-lg",
|
||||
)}
|
||||
>
|
||||
<CardHeader className="cursor-pointer pb-2" onClick={() => onSelect()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUpdate({ expanded: !step.expanded });
|
||||
}}
|
||||
>
|
||||
{step.expanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Badge variant="outline" className="h-5 text-xs">
|
||||
{index + 1}
|
||||
</Badge>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{step.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{step.actions.length} actions • {step.type}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<div {...listeners} className="cursor-grab p-1">
|
||||
<GripVertical className="text-muted-foreground h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{step.expanded && (
|
||||
<CardContent className="pt-0">
|
||||
<DroppableStep stepId={step.id} isEmpty={step.actions.length === 0}>
|
||||
{step.actions.length > 0 && (
|
||||
<SortableContext
|
||||
items={step.actions.map((a) => a.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{step.actions.map((action, actionIndex) => (
|
||||
<SortableAction
|
||||
key={action.id}
|
||||
action={action}
|
||||
index={actionIndex}
|
||||
isSelected={selectedActionId === action.id}
|
||||
onSelect={() => onActionSelect(action.id)}
|
||||
onDelete={() => onActionDelete(action.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
)}
|
||||
</DroppableStep>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* StepFlow (Scrollable Container of Steps) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface StepFlowProps {
|
||||
steps: ExperimentStep[];
|
||||
selectedStepId: string | null;
|
||||
selectedActionId: string | null;
|
||||
onStepSelect: (id: string) => void;
|
||||
onStepDelete: (id: string) => void;
|
||||
onStepUpdate: (id: string, updates: Partial<ExperimentStep>) => void;
|
||||
onActionSelect: (actionId: string) => void;
|
||||
onActionDelete: (stepId: string, actionId: string) => void;
|
||||
onActionUpdate?: (
|
||||
stepId: string,
|
||||
actionId: string,
|
||||
updates: Partial<ExperimentAction>,
|
||||
) => void;
|
||||
emptyState?: React.ReactNode;
|
||||
headerRight?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function StepFlow({
|
||||
steps,
|
||||
selectedStepId,
|
||||
selectedActionId,
|
||||
onStepSelect,
|
||||
onStepDelete,
|
||||
onStepUpdate,
|
||||
onActionSelect,
|
||||
onActionDelete,
|
||||
emptyState,
|
||||
headerRight,
|
||||
}: StepFlowProps) {
|
||||
return (
|
||||
<Card className="h-[calc(100vh-12rem)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Experiment Flow
|
||||
</div>
|
||||
{headerRight}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2">
|
||||
{steps.length === 0 ? (
|
||||
(emptyState ?? (
|
||||
<div className="py-8 text-center">
|
||||
<GitBranch className="text-muted-foreground/50 mx-auto h-8 w-8" />
|
||||
<h3 className="mt-2 text-sm font-medium">No steps yet</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Add your first step to begin designing
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<SortableContext
|
||||
items={steps.map((s) => s.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id}>
|
||||
<SortableStep
|
||||
step={step}
|
||||
index={index}
|
||||
isSelected={selectedStepId === step.id}
|
||||
selectedActionId={selectedActionId}
|
||||
onSelect={() => onStepSelect(step.id)}
|
||||
onDelete={() => onStepDelete(step.id)}
|
||||
onUpdate={(updates) => onStepUpdate(step.id, updates)}
|
||||
onActionSelect={onActionSelect}
|
||||
onActionDelete={(actionId) =>
|
||||
onActionDelete(step.id, actionId)
|
||||
}
|
||||
/>
|
||||
{index < steps.length - 1 && (
|
||||
<div className="flex justify-center py-1">
|
||||
<div className="bg-border h-2 w-px" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
@@ -62,24 +61,24 @@ const severityConfig = {
|
||||
error: {
|
||||
icon: AlertCircle,
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
bgColor: "bg-red-50 dark:bg-red-950/20",
|
||||
borderColor: "border-red-200 dark:border-red-800",
|
||||
bgColor: "bg-red-100 dark:bg-red-950/60",
|
||||
borderColor: "border-red-300 dark:border-red-700",
|
||||
badgeVariant: "destructive" as const,
|
||||
label: "Error",
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
color: "text-amber-600 dark:text-amber-400",
|
||||
bgColor: "bg-amber-50 dark:bg-amber-950/20",
|
||||
borderColor: "border-amber-200 dark:border-amber-800",
|
||||
bgColor: "bg-amber-100 dark:bg-amber-950/60",
|
||||
borderColor: "border-amber-300 dark:border-amber-700",
|
||||
badgeVariant: "secondary" as const,
|
||||
label: "Warning",
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-50 dark:bg-blue-950/20",
|
||||
borderColor: "border-blue-200 dark:border-blue-800",
|
||||
bgColor: "bg-blue-100 dark:bg-blue-950/60",
|
||||
borderColor: "border-blue-300 dark:border-blue-700",
|
||||
badgeVariant: "outline" as const,
|
||||
label: "Info",
|
||||
},
|
||||
@@ -103,15 +102,7 @@ function flattenIssues(issuesMap: Record<string, ValidationIssue[]>) {
|
||||
return flattened;
|
||||
}
|
||||
|
||||
function getEntityDisplayName(entityId: string): string {
|
||||
if (entityId.startsWith("step-")) {
|
||||
return `Step ${entityId.replace("step-", "")}`;
|
||||
}
|
||||
if (entityId.startsWith("action-")) {
|
||||
return `Action ${entityId.replace("action-", "")}`;
|
||||
}
|
||||
return entityId;
|
||||
}
|
||||
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Issue Item Component */
|
||||
@@ -214,7 +205,7 @@ export function ValidationPanel({
|
||||
const [severityFilter, setSeverityFilter] = useState<
|
||||
"all" | "error" | "warning" | "info"
|
||||
>("all");
|
||||
const [categoryFilter, setCategoryFilter] = useState<
|
||||
const [categoryFilter] = useState<
|
||||
"all" | "structural" | "parameter" | "semantic" | "execution"
|
||||
>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -248,18 +239,11 @@ export function ValidationPanel({
|
||||
|
||||
React.useEffect(() => {
|
||||
// Debug: surface validation state to console
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.log("[ValidationPanel] issues", issues, { flatIssues, counts });
|
||||
}, [issues, flatIssues, counts]);
|
||||
|
||||
// Available categories
|
||||
const availableCategories = useMemo(() => {
|
||||
const flat = flattenIssues(issues);
|
||||
const categories = new Set(flat.map((i) => i.category).filter(Boolean));
|
||||
return Array.from(categories) as Array<
|
||||
"structural" | "parameter" | "semantic" | "execution"
|
||||
>;
|
||||
}, [issues]);
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -346,7 +330,7 @@ export function ValidationPanel({
|
||||
</div>
|
||||
|
||||
{/* Issues List */}
|
||||
<ScrollArea className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
|
||||
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
|
||||
<div className="flex min-w-0 flex-col gap-2 p-2 pr-2">
|
||||
{counts.total === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
@@ -382,7 +366,7 @@ export function ValidationPanel({
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { useDesignerStore } from "../state/store";
|
||||
import { StepFlow } from "../StepFlow";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import type {
|
||||
ExperimentAction,
|
||||
ExperimentStep,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
|
||||
/**
|
||||
* Hidden droppable anchors so actions dragged from the ActionLibraryPanel
|
||||
* can land on steps even though StepFlow is still a legacy component.
|
||||
* This avoids having to deeply modify StepFlow during the transitional phase.
|
||||
*/
|
||||
function HiddenDroppableAnchors({ stepIds }: { stepIds: string[] }) {
|
||||
return (
|
||||
<>
|
||||
{stepIds.map((id) => (
|
||||
<SingleAnchor key={id} id={id} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SingleAnchor({ id }: { id: string }) {
|
||||
// Register a droppable area matching the StepFlow internal step id pattern
|
||||
useDroppable({
|
||||
id: `step-${id}`,
|
||||
});
|
||||
// Render nothing (zero-size element) – DnD kit only needs the registration
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* FlowListView (Transitional)
|
||||
*
|
||||
* This component is a TEMPORARY compatibility wrapper around the legacy
|
||||
* StepFlow component while the new virtualized / dual-mode (List vs Graph)
|
||||
* flow workspace is implemented.
|
||||
*
|
||||
* Responsibilities (current):
|
||||
* - Read step + selection state from the designer store
|
||||
* - Provide mutation handlers (upsert, delete, reorder placeholder)
|
||||
* - Emit structured callbacks (reserved for future instrumentation)
|
||||
*
|
||||
* Planned Enhancements:
|
||||
* - Virtualization for large step counts
|
||||
* - Inline step creation affordances between steps
|
||||
* - Multi-select + bulk operations
|
||||
* - Drag reordering at step level (currently delegated to DnD kit)
|
||||
* - Graph mode toggle (will lift state to higher DesignerRoot)
|
||||
* - Performance memoization / fine-grained selectors
|
||||
*
|
||||
* Until the new system is complete, this wrapper allows incremental
|
||||
* replacement without breaking existing behavior.
|
||||
*/
|
||||
|
||||
export interface FlowListViewProps {
|
||||
/**
|
||||
* Optional callbacks for higher-level orchestration (e.g. autosave triggers)
|
||||
*/
|
||||
onStepMutated?: (
|
||||
step: ExperimentStep,
|
||||
kind: "create" | "update" | "delete",
|
||||
) => void;
|
||||
onActionMutated?: (
|
||||
action: ExperimentAction,
|
||||
step: ExperimentStep,
|
||||
kind: "create" | "update" | "delete",
|
||||
) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FlowListView({
|
||||
onStepMutated,
|
||||
onActionMutated,
|
||||
className,
|
||||
}: FlowListViewProps) {
|
||||
/* ----------------------------- Store Selectors ---------------------------- */
|
||||
const steps = useDesignerStore((s) => s.steps);
|
||||
const selectedStepId = useDesignerStore((s) => s.selectedStepId);
|
||||
const selectedActionId = useDesignerStore((s) => s.selectedActionId);
|
||||
|
||||
const selectStep = useDesignerStore((s) => s.selectStep);
|
||||
const selectAction = useDesignerStore((s) => s.selectAction);
|
||||
|
||||
const upsertStep = useDesignerStore((s) => s.upsertStep);
|
||||
const removeStep = useDesignerStore((s) => s.removeStep);
|
||||
const upsertAction = useDesignerStore((s) => s.upsertAction);
|
||||
const removeAction = useDesignerStore((s) => s.removeAction);
|
||||
|
||||
/* ------------------------------- Handlers --------------------------------- */
|
||||
|
||||
const handleStepUpdate = useCallback(
|
||||
(stepId: string, updates: Partial<ExperimentStep>) => {
|
||||
const existing = steps.find((s) => s.id === stepId);
|
||||
if (!existing) return;
|
||||
const next: ExperimentStep = { ...existing, ...updates };
|
||||
upsertStep(next);
|
||||
onStepMutated?.(next, "update");
|
||||
},
|
||||
[steps, upsertStep, onStepMutated],
|
||||
);
|
||||
|
||||
const handleStepDelete = useCallback(
|
||||
(stepId: string) => {
|
||||
const existing = steps.find((s) => s.id === stepId);
|
||||
if (!existing) return;
|
||||
removeStep(stepId);
|
||||
onStepMutated?.(existing, "delete");
|
||||
},
|
||||
[steps, removeStep, onStepMutated],
|
||||
);
|
||||
|
||||
const handleActionDelete = useCallback(
|
||||
(stepId: string, actionId: string) => {
|
||||
const step = steps.find((s) => s.id === stepId);
|
||||
const action = step?.actions.find((a) => a.id === actionId);
|
||||
removeAction(stepId, actionId);
|
||||
if (step && action) {
|
||||
onActionMutated?.(action, step, "delete");
|
||||
}
|
||||
},
|
||||
[steps, removeAction, onActionMutated],
|
||||
);
|
||||
|
||||
const totalActions = useMemo(
|
||||
() => steps.reduce((sum, s) => sum + s.actions.length, 0),
|
||||
[steps],
|
||||
);
|
||||
|
||||
/* ------------------------------- Render ----------------------------------- */
|
||||
|
||||
return (
|
||||
<div className={className} data-flow-mode="list">
|
||||
{/* NOTE: Header / toolbar will be hoisted into the main workspace toolbar in later iterations */}
|
||||
<div className="flex items-center justify-between border-b px-3 py-2 text-xs">
|
||||
<div className="flex items-center gap-3 font-medium">
|
||||
<span className="text-muted-foreground">Flow (List View)</span>
|
||||
<span className="text-muted-foreground/70">
|
||||
{steps.length} steps • {totalActions} actions
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground/60 text-[10px]">
|
||||
Transitional component
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[calc(100%-2.5rem)]">
|
||||
{/* Hidden droppable anchors to enable dropping actions onto steps */}
|
||||
<HiddenDroppableAnchors stepIds={steps.map((s) => s.id)} />
|
||||
<StepFlow
|
||||
steps={steps}
|
||||
selectedStepId={selectedStepId ?? null}
|
||||
selectedActionId={selectedActionId ?? null}
|
||||
onStepSelect={(id) => selectStep(id)}
|
||||
onActionSelect={(actionId) =>
|
||||
selectedStepId && actionId
|
||||
? selectAction(selectedStepId, actionId)
|
||||
: undefined
|
||||
}
|
||||
onStepDelete={handleStepDelete}
|
||||
onStepUpdate={handleStepUpdate}
|
||||
onActionDelete={handleActionDelete}
|
||||
emptyState={
|
||||
<div className="text-muted-foreground py-10 text-center text-sm">
|
||||
No steps yet. Use the + Step button to add your first step.
|
||||
</div>
|
||||
}
|
||||
headerRight={
|
||||
<div className="text-muted-foreground/70 text-[11px]">
|
||||
(Add Step control will move to global toolbar)
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlowListView;
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -27,8 +26,6 @@ import {
|
||||
Plus,
|
||||
Trash2,
|
||||
GitBranch,
|
||||
Sparkles,
|
||||
CircleDot,
|
||||
Edit3,
|
||||
} from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
@@ -88,9 +85,7 @@ function generateStepId(): string {
|
||||
return `step-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
function generateActionId(): string {
|
||||
return `action-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
|
||||
function sortableStepId(stepId: string) {
|
||||
return `s-step-${stepId}`;
|
||||
@@ -165,7 +160,7 @@ function SortableActionChip({
|
||||
className={cn(
|
||||
"group relative flex w-full flex-col items-start gap-1 rounded border px-3 py-2 text-[11px]",
|
||||
"bg-muted/40 hover:bg-accent/40 cursor-pointer",
|
||||
isSelected && "border-blue-500 bg-blue-50 dark:bg-blue-950/30",
|
||||
isSelected && "border-border bg-accent/30",
|
||||
isDragging && "opacity-70 shadow-lg",
|
||||
)}
|
||||
onClick={onSelect}
|
||||
@@ -245,7 +240,7 @@ export function FlowWorkspace({
|
||||
overscan = 400,
|
||||
onStepCreate,
|
||||
onStepDelete,
|
||||
onActionCreate,
|
||||
onActionCreate: _onActionCreate,
|
||||
}: FlowWorkspaceProps) {
|
||||
/* Store selectors */
|
||||
const steps = useDesignerStore((s) => s.steps);
|
||||
@@ -256,7 +251,7 @@ export function FlowWorkspace({
|
||||
|
||||
const upsertStep = useDesignerStore((s) => s.upsertStep);
|
||||
const removeStep = useDesignerStore((s) => s.removeStep);
|
||||
const upsertAction = useDesignerStore((s) => s.upsertAction);
|
||||
|
||||
const removeAction = useDesignerStore((s) => s.removeAction);
|
||||
const reorderStep = useDesignerStore((s) => s.reorderStep);
|
||||
const reorderAction = useDesignerStore((s) => s.reorderAction);
|
||||
@@ -266,12 +261,12 @@ export function FlowWorkspace({
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const measureRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
const pendingHeightsRef = useRef<Map<string, number> | null>(null);
|
||||
const heightsRafRef = useRef<number | null>(null);
|
||||
const [heights, setHeights] = useState<Map<string, number>>(new Map());
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState(600);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [renamingStepId, setRenamingStepId] = useState<string | null>(null);
|
||||
const [isDraggingLibraryAction, setIsDraggingLibraryAction] = useState(false);
|
||||
// dragKind state removed (unused after refactor)
|
||||
|
||||
/* Parent lookup for action reorder */
|
||||
@@ -293,41 +288,47 @@ export function FlowWorkspace({
|
||||
for (const entry of entries) {
|
||||
const cr = entry.contentRect;
|
||||
setViewportHeight(cr.height);
|
||||
setContainerWidth((prev) => {
|
||||
if (Math.abs(prev - cr.width) > 0.5) {
|
||||
// Invalidate cached heights on width change to force re-measure
|
||||
setHeights(new Map());
|
||||
}
|
||||
return cr.width;
|
||||
});
|
||||
// Do not invalidate all heights on width change; per-step observers will update as needed
|
||||
}
|
||||
});
|
||||
observer.observe(el);
|
||||
const cr = el.getBoundingClientRect();
|
||||
|
||||
setViewportHeight(el.clientHeight);
|
||||
setContainerWidth(cr.width);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
/* Per-step measurement observer (attach/detach on ref set) */
|
||||
useLayoutEffect(() => {
|
||||
roRef.current = new ResizeObserver((entries) => {
|
||||
setHeights((prev) => {
|
||||
const next = new Map(prev);
|
||||
let changed = false;
|
||||
for (const entry of entries) {
|
||||
const id = entry.target.getAttribute("data-step-id");
|
||||
if (!id) continue;
|
||||
const h = entry.contentRect.height;
|
||||
if (prev.get(id) !== h) {
|
||||
next.set(id, h);
|
||||
changed = true;
|
||||
pendingHeightsRef.current ??= new Map();
|
||||
for (const entry of entries) {
|
||||
const id = entry.target.getAttribute("data-step-id");
|
||||
if (!id) continue;
|
||||
const h = entry.contentRect.height;
|
||||
pendingHeightsRef.current.set(id, h);
|
||||
}
|
||||
heightsRafRef.current ??= requestAnimationFrame(() => {
|
||||
const pending = pendingHeightsRef.current;
|
||||
heightsRafRef.current = null;
|
||||
pendingHeightsRef.current = null;
|
||||
if (!pending) return;
|
||||
setHeights((prev) => {
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
for (const [id, h] of pending) {
|
||||
if (prev.get(id) !== h) {
|
||||
next.set(id, h);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
return changed ? next : prev;
|
||||
});
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
if (heightsRafRef.current) cancelAnimationFrame(heightsRafRef.current);
|
||||
heightsRafRef.current = null;
|
||||
pendingHeightsRef.current = null;
|
||||
roRef.current?.disconnect();
|
||||
roRef.current = null;
|
||||
};
|
||||
@@ -430,29 +431,6 @@ export function FlowWorkspace({
|
||||
[upsertStep],
|
||||
);
|
||||
|
||||
const addActionToStep = useCallback(
|
||||
(
|
||||
stepId: string,
|
||||
actionDef: { type: string; name: string; category: string },
|
||||
) => {
|
||||
const step = steps.find((s) => s.id === stepId);
|
||||
if (!step) return;
|
||||
const newAction: ExperimentAction = {
|
||||
id: generateActionId(),
|
||||
type: actionDef.type,
|
||||
name: actionDef.name,
|
||||
category: actionDef.category as ExperimentAction["category"],
|
||||
parameters: {},
|
||||
source: { kind: "core" },
|
||||
execution: { transport: "internal" },
|
||||
};
|
||||
upsertAction(stepId, newAction);
|
||||
onActionCreate?.(stepId, newAction);
|
||||
void recomputeHash();
|
||||
},
|
||||
[steps, upsertAction, onActionCreate, recomputeHash],
|
||||
);
|
||||
|
||||
const deleteAction = useCallback(
|
||||
(stepId: string, actionId: string) => {
|
||||
removeAction(stepId, actionId);
|
||||
@@ -469,14 +447,13 @@ export function FlowWorkspace({
|
||||
const handleLocalDragStart = useCallback((e: DragStartEvent) => {
|
||||
const id = e.active.id.toString();
|
||||
if (id.startsWith("action-")) {
|
||||
setIsDraggingLibraryAction(true);
|
||||
// no-op
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLocalDragEnd = useCallback(
|
||||
(e: DragEndEvent) => {
|
||||
const { active, over } = e;
|
||||
setIsDraggingLibraryAction(false);
|
||||
if (!over || !active) {
|
||||
return;
|
||||
}
|
||||
@@ -525,7 +502,7 @@ export function FlowWorkspace({
|
||||
onDragStart: handleLocalDragStart,
|
||||
onDragEnd: handleLocalDragEnd,
|
||||
onDragCancel: () => {
|
||||
setIsDraggingLibraryAction(false);
|
||||
// no-op
|
||||
},
|
||||
});
|
||||
|
||||
@@ -578,9 +555,9 @@ export function FlowWorkspace({
|
||||
<StepDroppableArea stepId={step.id} />
|
||||
<div
|
||||
className={cn(
|
||||
"rounded border shadow-sm transition-colors mb-2",
|
||||
"mb-2 rounded border shadow-sm transition-colors",
|
||||
selectedStepId === step.id
|
||||
? "border-blue-400/60 bg-blue-50/40 dark:bg-blue-950/20"
|
||||
? "border-border bg-accent/30"
|
||||
: "hover:bg-accent/30",
|
||||
isDragging && "opacity-80 ring-1 ring-blue-300",
|
||||
)}
|
||||
@@ -590,7 +567,8 @@ export function FlowWorkspace({
|
||||
onClick={(e) => {
|
||||
// Avoid selecting step when interacting with controls or inputs
|
||||
const tag = (e.target as HTMLElement).tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "button") return;
|
||||
if (tag === "input" || tag === "textarea" || tag === "button")
|
||||
return;
|
||||
selectStep(step.id);
|
||||
selectAction(step.id, undefined);
|
||||
}}
|
||||
@@ -718,7 +696,7 @@ export function FlowWorkspace({
|
||||
</div>
|
||||
{/* Persistent centered bottom drop hint */}
|
||||
<div className="mt-3 flex w-full items-center justify-center">
|
||||
<div className="text-muted-foreground border border-dashed border-muted-foreground/30 rounded px-2 py-1 text-[11px]">
|
||||
<div className="text-muted-foreground border-muted-foreground/30 rounded border border-dashed px-2 py-1 text-[11px]">
|
||||
Drop actions here
|
||||
</div>
|
||||
</div>
|
||||
@@ -734,7 +712,7 @@ export function FlowWorkspace({
|
||||
/* Render */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
<div className={cn("flex h-full min-h-0 flex-col", className)}>
|
||||
<div className="flex items-center justify-between border-b px-3 py-2 text-xs">
|
||||
<div className="flex items-center gap-3 font-medium">
|
||||
<span className="text-muted-foreground flex items-center gap-1">
|
||||
@@ -760,20 +738,24 @@ export function FlowWorkspace({
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex-1 overflow-y-auto"
|
||||
className="relative h-0 min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{steps.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center p-6">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full border">
|
||||
<GitBranch className="h-6 w-6 text-muted-foreground" />
|
||||
<GitBranch className="text-muted-foreground h-6 w-6" />
|
||||
</div>
|
||||
<p className="mb-2 text-sm font-medium">No steps yet</p>
|
||||
<p className="text-muted-foreground mb-3 text-xs">
|
||||
Create your first step to begin designing the flow.
|
||||
</p>
|
||||
<Button size="sm" className="h-7 px-2 text-[11px]" onClick={() => createStep()}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-2 text-[11px]"
|
||||
onClick={() => createStep()}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" /> Add Step
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,382 +1,310 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type Edge = "left" | "right";
|
||||
|
||||
export interface PanelsContainerProps {
|
||||
left?: React.ReactNode;
|
||||
center: React.ReactNode;
|
||||
right?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Draw dividers between panels (applied to center only to avoid double borders).
|
||||
* Defaults to true.
|
||||
*/
|
||||
showDividers?: boolean;
|
||||
|
||||
/** Class applied to the root container */
|
||||
className?: string;
|
||||
|
||||
/** Class applied to each panel wrapper (left/center/right) */
|
||||
panelClassName?: string;
|
||||
|
||||
/** Class applied to each panel's internal scroll container */
|
||||
contentClassName?: string;
|
||||
|
||||
/** Accessible label for the overall layout */
|
||||
"aria-label"?: string;
|
||||
|
||||
/** Min/Max fractional widths for left and right panels (0..1), clamped during drag */
|
||||
minLeftPct?: number;
|
||||
maxLeftPct?: number;
|
||||
minRightPct?: number;
|
||||
maxRightPct?: number;
|
||||
|
||||
/** Keyboard resize step (fractional) per arrow press; Shift increases by 2x */
|
||||
keyboardStepPct?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* PanelsContainer
|
||||
*
|
||||
* Structural layout component for the Experiment Designer refactor.
|
||||
* Provides:
|
||||
* - Optional left + right side panels (resizable + collapsible)
|
||||
* - Central workspace (always present)
|
||||
* - Persistent panel widths (localStorage)
|
||||
* - Keyboard-accessible resize handles
|
||||
* - Minimal DOM repaint during drag (inline styles)
|
||||
* Tailwind-first, grid-based panel layout with:
|
||||
* - Drag-resizable left/right panels (no persistence)
|
||||
* - Strict overflow containment (no page-level x-scroll)
|
||||
* - Internal y-scroll for each panel
|
||||
* - Optional visual dividers on the center panel only (prevents double borders)
|
||||
*
|
||||
* NOT responsible for:
|
||||
* - Business logic or data fetching
|
||||
* - Panel content semantics (passed via props)
|
||||
*
|
||||
* Accessibility:
|
||||
* - Resize handles are <button> elements with aria-label
|
||||
* - Keyboard: ArrowLeft / ArrowRight adjusts width by step
|
||||
* Implementation details:
|
||||
* - Uses CSS variables for column fractions and an explicit grid template:
|
||||
* [minmax(0,var(--col-left)) minmax(0,var(--col-center)) minmax(0,var(--col-right))]
|
||||
* - Resize handles are absolutely positioned over the grid at the left and right boundaries.
|
||||
* - Fractions are clamped with configurable min/max so panels remain usable at all sizes.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "hristudio-designer-panels-v1";
|
||||
|
||||
interface PersistedLayout {
|
||||
left: number;
|
||||
right: number;
|
||||
leftCollapsed: boolean;
|
||||
rightCollapsed: boolean;
|
||||
}
|
||||
|
||||
export interface PanelsContainerProps {
|
||||
left?: ReactNode;
|
||||
center: ReactNode;
|
||||
right?: ReactNode;
|
||||
|
||||
/**
|
||||
* Initial (non-collapsed) widths in pixels.
|
||||
* If panels are omitted, their widths are ignored.
|
||||
*/
|
||||
initialLeftWidth?: number;
|
||||
initialRightWidth?: number;
|
||||
|
||||
/**
|
||||
* Minimum / maximum constraints to avoid unusable panels.
|
||||
*/
|
||||
minLeftWidth?: number;
|
||||
minRightWidth?: number;
|
||||
maxLeftWidth?: number;
|
||||
maxRightWidth?: number;
|
||||
|
||||
/**
|
||||
* Whether persistence to localStorage should be skipped (e.g. SSR preview)
|
||||
*/
|
||||
disablePersistence?: boolean;
|
||||
|
||||
/**
|
||||
* ClassName pass-through for root container
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
edge: "left" | "right";
|
||||
startX: number;
|
||||
startWidth: number;
|
||||
}
|
||||
|
||||
export function PanelsContainer({
|
||||
left,
|
||||
center,
|
||||
right,
|
||||
initialLeftWidth = 280,
|
||||
initialRightWidth = 340,
|
||||
minLeftWidth = 200,
|
||||
minRightWidth = 260,
|
||||
maxLeftWidth = 520,
|
||||
maxRightWidth = 560,
|
||||
disablePersistence = false,
|
||||
showDividers = true,
|
||||
className,
|
||||
panelClassName,
|
||||
contentClassName,
|
||||
"aria-label": ariaLabel = "Designer panel layout",
|
||||
minLeftPct = 0.12,
|
||||
maxLeftPct = 0.33,
|
||||
minRightPct = 0.12,
|
||||
maxRightPct = 0.33,
|
||||
keyboardStepPct = 0.02,
|
||||
}: PanelsContainerProps) {
|
||||
const hasLeft = Boolean(left);
|
||||
const hasRight = Boolean(right);
|
||||
const hasCenter = Boolean(center);
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* State */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
// Fractions for side panels (center is derived as 1 - (left + right))
|
||||
const [leftPct, setLeftPct] = React.useState<number>(hasLeft ? 0.2 : 0);
|
||||
const [rightPct, setRightPct] = React.useState<number>(hasRight ? 0.24 : 0);
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(initialLeftWidth);
|
||||
const [rightWidth, setRightWidth] = useState(initialRightWidth);
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||
const rootRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const dragRef = React.useRef<{
|
||||
edge: Edge;
|
||||
startX: number;
|
||||
startLeft: number;
|
||||
startRight: number;
|
||||
containerWidth: number;
|
||||
} | null>(null);
|
||||
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const frameReq = useRef<number | null>(null);
|
||||
const clamp = (v: number, lo: number, hi: number): number =>
|
||||
Math.max(lo, Math.min(hi, v));
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Persistence */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
const recompute = React.useCallback(
|
||||
(lp: number, rp: number) => {
|
||||
if (!hasCenter) return { l: 0, c: 0, r: 0 };
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (disablePersistence) return;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as PersistedLayout;
|
||||
if (typeof parsed.left === "number") setLeftWidth(parsed.left);
|
||||
if (typeof parsed.right === "number")
|
||||
setRightWidth(Math.max(parsed.right, minRightWidth));
|
||||
if (typeof parsed.leftCollapsed === "boolean") {
|
||||
setLeftCollapsed(parsed.leftCollapsed);
|
||||
if (hasLeft && hasRight) {
|
||||
const l = clamp(lp, minLeftPct, maxLeftPct);
|
||||
const r = clamp(rp, minRightPct, maxRightPct);
|
||||
const c = Math.max(0.1, 1 - (l + r)); // always preserve some center space
|
||||
return { l, c, r };
|
||||
}
|
||||
// Always start with right panel visible to avoid hidden inspector state
|
||||
setRightCollapsed(false);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}, [disablePersistence, minRightWidth]);
|
||||
|
||||
const persist = useCallback(
|
||||
(next?: Partial<PersistedLayout>) => {
|
||||
if (disablePersistence) return;
|
||||
const snapshot: PersistedLayout = {
|
||||
left: leftWidth,
|
||||
right: rightWidth,
|
||||
leftCollapsed,
|
||||
rightCollapsed,
|
||||
...next,
|
||||
};
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
/* noop */
|
||||
if (hasLeft && !hasRight) {
|
||||
const l = clamp(lp, minLeftPct, maxLeftPct);
|
||||
const c = Math.max(0.2, 1 - l);
|
||||
return { l, c, r: 0 };
|
||||
}
|
||||
},
|
||||
[disablePersistence, leftWidth, rightWidth, leftCollapsed, rightCollapsed],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
persist();
|
||||
}, [leftWidth, rightWidth, leftCollapsed, rightCollapsed, persist]);
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Drag Handlers */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: PointerEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const { edge, startX, startWidth } = dragRef.current;
|
||||
const delta = e.clientX - startX;
|
||||
|
||||
if (edge === "left") {
|
||||
let next = startWidth + delta;
|
||||
next = Math.max(minLeftWidth, Math.min(maxLeftWidth, next));
|
||||
if (next !== leftWidth) {
|
||||
if (frameReq.current) cancelAnimationFrame(frameReq.current);
|
||||
frameReq.current = requestAnimationFrame(() => setLeftWidth(next));
|
||||
}
|
||||
} else if (edge === "right") {
|
||||
let next = startWidth - delta;
|
||||
next = Math.max(minRightWidth, Math.min(maxRightWidth, next));
|
||||
if (next !== rightWidth) {
|
||||
if (frameReq.current) cancelAnimationFrame(frameReq.current);
|
||||
frameReq.current = requestAnimationFrame(() => setRightWidth(next));
|
||||
}
|
||||
if (!hasLeft && hasRight) {
|
||||
const r = clamp(rp, minRightPct, maxRightPct);
|
||||
const c = Math.max(0.2, 1 - r);
|
||||
return { l: 0, c, r };
|
||||
}
|
||||
// Center only
|
||||
return { l: 0, c: 1, r: 0 };
|
||||
},
|
||||
[
|
||||
leftWidth,
|
||||
rightWidth,
|
||||
minLeftWidth,
|
||||
maxLeftWidth,
|
||||
minRightWidth,
|
||||
maxRightWidth,
|
||||
hasCenter,
|
||||
hasLeft,
|
||||
hasRight,
|
||||
minLeftPct,
|
||||
maxLeftPct,
|
||||
minRightPct,
|
||||
maxRightPct,
|
||||
],
|
||||
);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
const { l, c, r } = recompute(leftPct, rightPct);
|
||||
|
||||
// Attach/detach global pointer handlers safely
|
||||
const onPointerMove = React.useCallback(
|
||||
(e: PointerEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || d.containerWidth <= 0) return;
|
||||
|
||||
const deltaPx = e.clientX - d.startX;
|
||||
const deltaPct = deltaPx / d.containerWidth;
|
||||
|
||||
if (d.edge === "left" && hasLeft) {
|
||||
const nextLeft = clamp(d.startLeft + deltaPct, minLeftPct, maxLeftPct);
|
||||
setLeftPct(nextLeft);
|
||||
} else if (d.edge === "right" && hasRight) {
|
||||
// Dragging the right edge moves leftwards as delta increases
|
||||
const nextRight = clamp(
|
||||
d.startRight - deltaPct,
|
||||
minRightPct,
|
||||
maxRightPct,
|
||||
);
|
||||
setRightPct(nextRight);
|
||||
}
|
||||
},
|
||||
[hasLeft, hasRight, minLeftPct, maxLeftPct, minRightPct, maxRightPct],
|
||||
);
|
||||
|
||||
const endDrag = React.useCallback(() => {
|
||||
dragRef.current = null;
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
window.removeEventListener("pointerup", endDrag);
|
||||
}, [onPointerMove]);
|
||||
|
||||
const startDrag = useCallback(
|
||||
(edge: "left" | "right", e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const startDrag =
|
||||
(edge: Edge) => (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (!rootRef.current) return;
|
||||
e.preventDefault();
|
||||
if (edge === "left" && leftCollapsed) return;
|
||||
if (edge === "right" && rightCollapsed) return;
|
||||
|
||||
const rect = rootRef.current.getBoundingClientRect();
|
||||
dragRef.current = {
|
||||
edge,
|
||||
startX: e.clientX,
|
||||
startWidth: edge === "left" ? leftWidth : rightWidth,
|
||||
startLeft: leftPct,
|
||||
startRight: rightPct,
|
||||
containerWidth: rect.width,
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", onPointerMove);
|
||||
window.addEventListener("pointerup", endDrag);
|
||||
},
|
||||
[
|
||||
leftWidth,
|
||||
rightWidth,
|
||||
leftCollapsed,
|
||||
rightCollapsed,
|
||||
onPointerMove,
|
||||
endDrag,
|
||||
],
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
// Cleanup if unmounted mid-drag
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
window.removeEventListener("pointerup", endDrag);
|
||||
};
|
||||
}, [onPointerMove, endDrag]);
|
||||
|
||||
// Keyboard resize for handles
|
||||
const onKeyResize =
|
||||
(edge: Edge) => (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||
e.preventDefault();
|
||||
|
||||
const step = (e.shiftKey ? 2 : 1) * keyboardStepPct;
|
||||
|
||||
if (edge === "left" && hasLeft) {
|
||||
const next = clamp(
|
||||
leftPct + (e.key === "ArrowRight" ? step : -step),
|
||||
minLeftPct,
|
||||
maxLeftPct,
|
||||
);
|
||||
setLeftPct(next);
|
||||
} else if (edge === "right" && hasRight) {
|
||||
const next = clamp(
|
||||
rightPct + (e.key === "ArrowLeft" ? step : -step),
|
||||
minRightPct,
|
||||
maxRightPct,
|
||||
);
|
||||
setRightPct(next);
|
||||
}
|
||||
};
|
||||
|
||||
// CSS variables for the grid fractions
|
||||
const styleVars: React.CSSProperties & Record<string, string> = hasCenter
|
||||
? {
|
||||
"--col-left": `${(hasLeft ? l : 0) * 100}%`,
|
||||
"--col-center": `${c * 100}%`,
|
||||
"--col-right": `${(hasRight ? r : 0) * 100}%`,
|
||||
}
|
||||
: {};
|
||||
|
||||
// Explicit grid template depending on which side panels exist
|
||||
const gridCols =
|
||||
hasLeft && hasRight
|
||||
? "[grid-template-columns:minmax(0,var(--col-left))_minmax(0,var(--col-center))_minmax(0,var(--col-right))]"
|
||||
: hasLeft && !hasRight
|
||||
? "[grid-template-columns:minmax(0,var(--col-left))_minmax(0,var(--col-center))]"
|
||||
: !hasLeft && hasRight
|
||||
? "[grid-template-columns:minmax(0,var(--col-center))_minmax(0,var(--col-right))]"
|
||||
: "[grid-template-columns:minmax(0,1fr)]";
|
||||
|
||||
// Dividers on the center panel only (prevents double borders if children have their own borders)
|
||||
const centerDividers =
|
||||
showDividers && hasCenter
|
||||
? cn({
|
||||
"border-l": hasLeft,
|
||||
"border-r": hasRight,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const Panel: React.FC<React.PropsWithChildren<{ className?: string }>> = ({
|
||||
className: panelCls,
|
||||
children,
|
||||
}) => (
|
||||
<section
|
||||
className={cn("min-w-0 overflow-hidden", panelCls, panelClassName)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Collapse / Expand */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
const toggleLeft = useCallback(() => {
|
||||
if (!hasLeft) return;
|
||||
setLeftCollapsed((c) => {
|
||||
const next = !c;
|
||||
if (next === false && leftWidth < minLeftWidth) {
|
||||
setLeftWidth(initialLeftWidth);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [hasLeft, leftWidth, minLeftWidth, initialLeftWidth]);
|
||||
|
||||
const toggleRight = useCallback(() => {
|
||||
if (!hasRight) return;
|
||||
setRightCollapsed((c) => {
|
||||
const next = !c;
|
||||
if (next === false && rightWidth < minRightWidth) {
|
||||
setRightWidth(initialRightWidth);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [hasRight, rightWidth, minRightWidth, initialRightWidth]);
|
||||
|
||||
/* Keyboard resizing (focused handle) */
|
||||
const handleKeyResize = useCallback(
|
||||
(edge: "left" | "right", e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
const step = e.shiftKey ? 24 : 12;
|
||||
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
if (edge === "left" && !leftCollapsed) {
|
||||
setLeftWidth((w) => {
|
||||
const delta = e.key === "ArrowLeft" ? -step : step;
|
||||
return Math.max(minLeftWidth, Math.min(maxLeftWidth, w + delta));
|
||||
});
|
||||
} else if (edge === "right" && !rightCollapsed) {
|
||||
setRightWidth((w) => {
|
||||
const delta = e.key === "ArrowLeft" ? -step : step;
|
||||
return Math.max(minRightWidth, Math.min(maxRightWidth, w + delta));
|
||||
});
|
||||
}
|
||||
} else if (e.key === "Enter" || e.key === " ") {
|
||||
if (edge === "left") toggleLeft();
|
||||
else toggleRight();
|
||||
}
|
||||
},
|
||||
[
|
||||
leftCollapsed,
|
||||
rightCollapsed,
|
||||
minLeftWidth,
|
||||
maxLeftWidth,
|
||||
minRightWidth,
|
||||
maxRightWidth,
|
||||
toggleLeft,
|
||||
toggleRight,
|
||||
],
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Render */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
aria-label={ariaLabel}
|
||||
style={styleVars}
|
||||
className={cn(
|
||||
"flex h-full w-full overflow-hidden select-none",
|
||||
"relative grid h-full min-h-0 w-full overflow-hidden select-none",
|
||||
gridCols,
|
||||
className,
|
||||
)}
|
||||
aria-label="Designer panel layout"
|
||||
>
|
||||
{/* Left Panel */}
|
||||
{hasLeft && (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background/50 relative flex h-full flex-shrink-0 flex-col border-r transition-[width] duration-150",
|
||||
leftCollapsed ? "w-0 border-r-0" : "w-[var(--panel-left-width)]",
|
||||
)}
|
||||
style={
|
||||
leftCollapsed
|
||||
? undefined
|
||||
: ({
|
||||
["--panel-left-width" as string]: `${leftWidth}px`,
|
||||
} as React.CSSProperties)
|
||||
}
|
||||
>
|
||||
{!leftCollapsed && (
|
||||
<div className="flex-1 overflow-hidden">{left}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hasLeft && <Panel>{left}</Panel>}
|
||||
|
||||
{/* Left Resize Handle */}
|
||||
{hasLeft && !leftCollapsed && (
|
||||
{hasCenter && <Panel className={centerDividers}>{center}</Panel>}
|
||||
|
||||
{hasRight && <Panel>{right}</Panel>}
|
||||
|
||||
{/* Resize handles (only render where applicable) */}
|
||||
{hasCenter && hasLeft && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Resize left panel (Enter to toggle collapse)"
|
||||
onPointerDown={(e) => startDrag("left", e)}
|
||||
onDoubleClick={toggleLeft}
|
||||
onKeyDown={(e) => handleKeyResize("left", e)}
|
||||
className="hover:bg-accent/40 focus-visible:ring-ring relative z-10 h-full w-0 cursor-col-resize px-1 outline-none focus-visible:ring-2"
|
||||
role="separator"
|
||||
aria-label="Resize left panel"
|
||||
aria-orientation="vertical"
|
||||
onPointerDown={startDrag("left")}
|
||||
onKeyDown={onKeyResize("left")}
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-10 w-1 cursor-col-resize outline-none",
|
||||
"focus-visible:ring-ring focus-visible:ring-2",
|
||||
)}
|
||||
// Position at the boundary between left and center
|
||||
style={{ left: "var(--col-left)", transform: "translateX(-0.5px)" }}
|
||||
tabIndex={0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Left collapse toggle removed to prevent breadcrumb overlap */}
|
||||
|
||||
{/* Center (Workspace) */}
|
||||
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-hidden">{center}</div>
|
||||
</div>
|
||||
|
||||
{/* Right Resize Handle */}
|
||||
{hasRight && !rightCollapsed && (
|
||||
{hasCenter && hasRight && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Resize right panel (Enter to toggle collapse)"
|
||||
onPointerDown={(e) => startDrag("right", e)}
|
||||
onDoubleClick={toggleRight}
|
||||
onKeyDown={(e) => handleKeyResize("right", e)}
|
||||
className="hover:bg-accent/40 focus-visible:ring-ring relative z-10 h-full w-1 cursor-col-resize outline-none focus-visible:ring-2"
|
||||
role="separator"
|
||||
aria-label="Resize right panel"
|
||||
aria-orientation="vertical"
|
||||
onPointerDown={startDrag("right")}
|
||||
onKeyDown={onKeyResize("right")}
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-10 w-1 cursor-col-resize outline-none",
|
||||
"focus-visible:ring-ring focus-visible:ring-2",
|
||||
)}
|
||||
// Position at the boundary between center and right (offset from the right)
|
||||
style={{ right: "var(--col-right)", transform: "translateX(0.5px)" }}
|
||||
tabIndex={0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Right Panel */}
|
||||
{hasRight && (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background/50 relative flex h-full flex-shrink-0 flex-col transition-[width] duration-150",
|
||||
rightCollapsed ? "w-0" : "w-[var(--panel-right-width)]",
|
||||
)}
|
||||
style={
|
||||
rightCollapsed
|
||||
? undefined
|
||||
: ({
|
||||
["--panel-right-width" as string]: `${rightWidth}px`,
|
||||
} as React.CSSProperties)
|
||||
}
|
||||
>
|
||||
{!rightCollapsed && (
|
||||
<div className="min-w-0 flex-1 overflow-hidden">{right}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Minimal Right Toggle (top-right), non-intrusive like VSCode */}
|
||||
{hasRight && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
rightCollapsed ? "Expand inspector" : "Collapse inspector"
|
||||
}
|
||||
onClick={toggleRight}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground absolute top-1 z-20 p-1 text-[10px]",
|
||||
rightCollapsed ? "right-1" : "right-1",
|
||||
)}
|
||||
title={rightCollapsed ? "Show inspector" : "Hide inspector"}
|
||||
>
|
||||
{rightCollapsed ? "◀" : "▶"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useActionRegistry } from "../ActionRegistry";
|
||||
import type { ActionDefinition } from "~/lib/experiment-designer/types";
|
||||
@@ -79,14 +80,17 @@ function DraggableAction({
|
||||
onToggleFavorite,
|
||||
highlight,
|
||||
}: DraggableActionProps) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `action-${action.id}`,
|
||||
data: { action },
|
||||
});
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useDraggable({
|
||||
id: `action-${action.id}`,
|
||||
data: { action },
|
||||
});
|
||||
|
||||
// Disable visual translation during drag so the list does not shift items.
|
||||
// We still let dnd-kit manage the drag overlay internally (no manual transform).
|
||||
const style: React.CSSProperties = {};
|
||||
const style: React.CSSProperties = transform
|
||||
? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
}
|
||||
: {};
|
||||
|
||||
const IconComponent = iconMap[action.icon] ?? Sparkles;
|
||||
|
||||
@@ -104,12 +108,12 @@ function DraggableAction({
|
||||
{...listeners}
|
||||
style={style}
|
||||
className={cn(
|
||||
"group bg-background/60 hover:bg-accent/50 relative flex w-full cursor-grab touch-none flex-col gap-1 rounded border px-2 transition-colors select-none",
|
||||
"group bg-background/60 hover:bg-accent/50 relative flex w-full cursor-grab touch-none flex-col gap-1 rounded border px-2 text-left transition-colors select-none",
|
||||
compact ? "py-1.5 text-[11px]" : "py-2 text-[12px]",
|
||||
isDragging && "ring-border opacity-60 ring-1",
|
||||
isDragging && "opacity-50",
|
||||
)}
|
||||
draggable={false}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
title={action.description ?? ""}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -127,7 +131,7 @@ function DraggableAction({
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-2 select-none">
|
||||
<div className="flex min-w-0 items-start gap-2 select-none">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-white",
|
||||
@@ -331,8 +335,8 @@ export function ActionLibraryPanel() {
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="flex h-full max-w-[240px] flex-col overflow-hidden">
|
||||
<div className="bg-background/60 border-b p-2">
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="bg-background/60 flex-shrink-0 border-b p-2">
|
||||
<div className="relative mb-2">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
@@ -359,10 +363,11 @@ export function ActionLibraryPanel() {
|
||||
)}
|
||||
onClick={() => toggleCategory(cat.key)}
|
||||
aria-pressed={active}
|
||||
aria-label={cat.label}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{cat.label}
|
||||
<span className="ml-auto text-[10px] font-normal opacity-80">
|
||||
<span className="hidden md:inline">{cat.label}</span>
|
||||
<span className="ml-auto hidden text-[10px] font-normal opacity-80 lg:inline">
|
||||
{countsByCategory[cat.key]}
|
||||
</span>
|
||||
</Button>
|
||||
@@ -374,17 +379,17 @@ export function ActionLibraryPanel() {
|
||||
<Button
|
||||
variant={showOnlyFavorites ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-7 min-w-[80px] flex-1"
|
||||
className="h-7 flex-1"
|
||||
onClick={() => setShowOnlyFavorites((s) => !s)}
|
||||
aria-pressed={showOnlyFavorites}
|
||||
aria-label="Toggle favorites filter"
|
||||
>
|
||||
<Star className="mr-1 h-3 w-3" />
|
||||
Fav
|
||||
<Star className="h-3 w-3" />
|
||||
<span className="ml-1 hidden sm:inline">Fav</span>
|
||||
{showOnlyFavorites && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1 h-4 px-1 text-[10px]"
|
||||
className="ml-1 hidden h-4 px-1 text-[10px] sm:inline"
|
||||
title="Visible favorites"
|
||||
>
|
||||
{visibleFavoritesCount}
|
||||
@@ -394,7 +399,7 @@ export function ActionLibraryPanel() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 min-w-[80px] flex-1"
|
||||
className="h-7 flex-1"
|
||||
onClick={() =>
|
||||
setDensity((d) =>
|
||||
d === "comfortable" ? "compact" : "comfortable",
|
||||
@@ -402,18 +407,20 @@ export function ActionLibraryPanel() {
|
||||
}
|
||||
aria-label="Toggle density"
|
||||
>
|
||||
<SlidersHorizontal className="mr-1 h-3 w-3" />
|
||||
{density === "comfortable" ? "Dense" : "Relax"}
|
||||
<SlidersHorizontal className="h-3 w-3" />
|
||||
<span className="ml-1 hidden sm:inline">
|
||||
{density === "comfortable" ? "Dense" : "Relax"}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 min-w-[60px] flex-1"
|
||||
className="h-7 flex-1"
|
||||
onClick={clearFilters}
|
||||
aria-label="Clear filters"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
Clear
|
||||
<span className="ml-1 hidden sm:inline">Clear</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -432,8 +439,8 @@ export function ActionLibraryPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 overflow-x-hidden overflow-y-auto">
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<ScrollArea className="flex-1 overflow-hidden">
|
||||
<div className="grid grid-cols-1 gap-2 p-2">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-muted-foreground/70 flex flex-col items-center gap-2 py-10 text-center text-xs">
|
||||
<Filter className="h-6 w-6" />
|
||||
@@ -454,7 +461,7 @@ export function ActionLibraryPanel() {
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="bg-background/60 border-t p-2">
|
||||
<div className="bg-background/60 flex-shrink-0 border-t p-2">
|
||||
<div className="flex items-center justify-between text-[10px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="h-4 px-1 text-[10px]">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useMemo, useState, useCallback } from "react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useDesignerStore } from "../state/store";
|
||||
import { actionRegistry } from "../ActionRegistry";
|
||||
@@ -200,7 +200,7 @@ export function InspectorPanel({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background/40 border-border relative flex h-full min-w-0 flex-col overflow-hidden border-l backdrop-blur-sm",
|
||||
"bg-background/40 border-border relative flex h-full min-w-0 flex-col overflow-hidden break-words whitespace-normal backdrop-blur-sm",
|
||||
className,
|
||||
)}
|
||||
style={{ contain: "layout paint size" }}
|
||||
@@ -208,62 +208,51 @@ export function InspectorPanel({
|
||||
aria-label="Inspector panel"
|
||||
>
|
||||
{/* Tab Header */}
|
||||
<div className="border-b px-2 py-1.5">
|
||||
<Tabs
|
||||
value={effectiveTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="flex h-9 w-full items-center gap-1 overflow-hidden">
|
||||
<TabsTrigger
|
||||
value="properties"
|
||||
className="flex min-w-0 flex-1 items-center justify-center gap-1 truncate text-[11px]"
|
||||
title="Properties (Step / Action)"
|
||||
>
|
||||
<Tabs
|
||||
value={effectiveTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="flex min-h-0 w-full flex-1 flex-col"
|
||||
>
|
||||
<div className="px-2 py-1.5">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="properties" title="Properties (Step / Action)">
|
||||
<Settings className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="hidden sm:inline">Props</span>
|
||||
<span className="hidden md:inline">Props</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="issues"
|
||||
className="flex min-w-0 flex-1 items-center justify-center gap-1 truncate text-[11px]"
|
||||
title="Validation Issues"
|
||||
>
|
||||
|
||||
<TabsTrigger value="issues" title="Validation Issues">
|
||||
<AlertTriangle className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="hidden sm:inline">
|
||||
<span className="hidden md:inline">
|
||||
Issues{issueCount > 0 ? ` (${issueCount})` : ""}
|
||||
</span>
|
||||
{issueCount > 0 && (
|
||||
<span className="xs:hidden text-amber-600 dark:text-amber-400">
|
||||
<span className="text-amber-600 md:hidden dark:text-amber-400">
|
||||
{issueCount}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="dependencies"
|
||||
className="flex min-w-0 flex-1 items-center justify-center gap-1 truncate text-[11px]"
|
||||
title="Dependencies / Drift"
|
||||
>
|
||||
|
||||
<TabsTrigger value="dependencies" title="Dependencies / Drift">
|
||||
<PackageSearch className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="hidden sm:inline">
|
||||
<span className="hidden md:inline">
|
||||
Deps{driftCount > 0 ? ` (${driftCount})` : ""}
|
||||
</span>
|
||||
{driftCount > 0 && (
|
||||
<span className="xs:hidden text-purple-600 dark:text-purple-400">
|
||||
<span className="text-purple-600 md:hidden dark:text-purple-400">
|
||||
{driftCount}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/*
|
||||
{/* Content */}
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{/*
|
||||
Force consistent width for tab bodies to prevent reflow when
|
||||
switching between content with different intrinsic widths.
|
||||
*/}
|
||||
<Tabs value={effectiveTab}>
|
||||
|
||||
{/* Properties */}
|
||||
<TabsContent
|
||||
value="properties"
|
||||
@@ -282,8 +271,8 @@ export function InspectorPanel({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="w-full px-3 py-3">
|
||||
<div className="flex-1 overflow-x-hidden overflow-y-auto">
|
||||
<div className="w-full px-0 py-2 break-words whitespace-normal">
|
||||
<PropertiesPanel
|
||||
design={{
|
||||
id: "design",
|
||||
@@ -299,7 +288,7 @@ export function InspectorPanel({
|
||||
onStepUpdate={handleStepUpdate}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
@@ -344,8 +333,8 @@ export function InspectorPanel({
|
||||
value="dependencies"
|
||||
className="m-0 flex min-h-0 flex-1 flex-col data-[state=inactive]:hidden"
|
||||
>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="w-full px-3 py-3">
|
||||
<div className="flex-1 overflow-x-hidden overflow-y-auto">
|
||||
<div className="w-full px-3 py-3 break-words whitespace-normal">
|
||||
<DependencyInspector
|
||||
steps={steps}
|
||||
actionSignatureDrift={actionSignatureDrift}
|
||||
@@ -363,10 +352,10 @@ export function InspectorPanel({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
{/* Footer (lightweight) */}
|
||||
<div className="text-muted-foreground border-t px-3 py-1.5 text-[10px]">
|
||||
|
||||
@@ -70,8 +70,8 @@ function canonicalize(value: unknown): CanonicalValue {
|
||||
function bufferToHex(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let hex = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const b = bytes[i]?.toString(16).padStart(2, "0");
|
||||
for (const byte of bytes) {
|
||||
const b = byte.toString(16).padStart(2, "0");
|
||||
hex += b;
|
||||
}
|
||||
return hex;
|
||||
@@ -90,8 +90,9 @@ async function hashString(input: string): Promise<string> {
|
||||
|
||||
// Fallback to Node (should not execute in Edge runtime)
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const nodeCrypto: typeof import("crypto") = require("crypto");
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
||||
const nodeCrypto = require("crypto");
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
return nodeCrypto.createHash("sha256").update(input).digest("hex");
|
||||
} catch {
|
||||
throw new Error("No suitable crypto implementation available for hashing.");
|
||||
|
||||
@@ -434,7 +434,7 @@ export function validateParameters(
|
||||
// Unknown parameter type
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message: `Unknown parameter type '${paramDef.type}' for '${paramDef.name}'`,
|
||||
message: `Unknown parameter type '${String(paramDef.type)}' for '${paramDef.name}'`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
@@ -723,9 +723,7 @@ export function groupIssuesByEntity(
|
||||
|
||||
issues.forEach((issue) => {
|
||||
const entityId = issue.actionId ?? issue.stepId ?? "experiment";
|
||||
if (!grouped[entityId]) {
|
||||
grouped[entityId] = [];
|
||||
}
|
||||
grouped[entityId] ??= [];
|
||||
grouped[entityId].push(issue);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user