Pre-conf work 2025

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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