diff --git a/src/components/experiments/designer/DesignerRoot.tsx b/src/components/experiments/designer/DesignerRoot.tsx
index b725d37..073d370 100644
--- a/src/components/experiments/designer/DesignerRoot.tsx
+++ b/src/components/experiments/designer/DesignerRoot.tsx
@@ -10,6 +10,7 @@ import { Button } from "~/components/ui/button";
import { api } from "~/trpc/react";
import { PanelsContainer } from "./layout/PanelsContainer";
+import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
import { BottomStatusBar } from "./layout/BottomStatusBar";
import { ActionLibraryPanel } from "./panels/ActionLibraryPanel";
import { InspectorPanel } from "./panels/InspectorPanel";
@@ -18,6 +19,7 @@ import { FlowListView } from "./flow/FlowListView";
import {
type ExperimentDesign,
type ExperimentStep,
+ type ExperimentAction,
} from "~/lib/experiment-designer/types";
import { useDesignerStore } from "./state/store";
@@ -158,6 +160,7 @@ export function DesignerRoot({
const setPersistedHash = useDesignerStore((s) => s.setPersistedHash);
const setValidatedHash = useDesignerStore((s) => s.setValidatedHash);
const upsertStep = useDesignerStore((s) => s.upsertStep);
+ const upsertAction = useDesignerStore((s) => s.upsertAction);
/* ------------------------------- Local Meta ------------------------------ */
const [designMeta, setDesignMeta] = useState<{
@@ -444,6 +447,66 @@ export function DesignerRoot({
}, [keyHandler]);
/* ------------------------------ Header Badges ---------------------------- */
+
+ /* ----------------------------- Drag Handlers ----------------------------- */
+ const handleDragEnd = useCallback(
+ (event: DragEndEvent) => {
+ const { active, over } = event;
+ if (!over) return;
+
+ // Expect dragged action (library) onto a step droppable
+ const activeId = active.id.toString();
+ const overId = over.id.toString();
+
+ if (
+ activeId.startsWith("action-") &&
+ overId.startsWith("step-") &&
+ active.data.current?.action
+ ) {
+ const actionDef = active.data.current.action as {
+ id: string;
+ type: string;
+ name: string;
+ category: string;
+ description?: string;
+ source: { kind: string; pluginId?: string; pluginVersion?: string };
+ execution?: { transport: string; retryable?: boolean };
+ parameters: Array<{ id: string; name: string }>;
+ };
+
+ const stepId = overId.replace("step-", "");
+ const targetStep = steps.find((s) => s.id === stepId);
+ if (!targetStep) return;
+
+ const execution: ExperimentAction["execution"] =
+ actionDef.execution &&
+ (actionDef.execution.transport === "internal" ||
+ actionDef.execution.transport === "rest" ||
+ actionDef.execution.transport === "ros2")
+ ? {
+ transport: actionDef.execution.transport,
+ retryable: actionDef.execution.retryable ?? false,
+ }
+ : {
+ transport: "internal",
+ retryable: false,
+ };
+ const newAction: ExperimentAction = {
+ id: `action-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ type: actionDef.type,
+ name: actionDef.name,
+ category: actionDef.category as ExperimentAction["category"],
+ parameters: {},
+ source: actionDef.source as ExperimentAction["source"],
+ execution,
+ };
+
+ upsertAction(stepId, newAction);
+ toast.success(`Added ${actionDef.name} to ${targetStep.name}`);
+ }
+ },
+ [steps, upsertAction],
+ );
const validationBadge =
driftStatus === "drift" ? (
Drift
@@ -529,14 +592,19 @@ export function DesignerRoot({
/>
-
}
- center={
}
- right={
}
- initialLeftWidth={300}
- initialRightWidth={360}
- className="flex-1"
- />
+
+ }
+ center={}
+ right={}
+ initialLeftWidth={260}
+ initialRightWidth={360}
+ className="flex-1"
+ />
+
persist()}
onValidate={() => validateDesign()}
diff --git a/src/components/experiments/designer/flow/FlowListView.tsx b/src/components/experiments/designer/flow/FlowListView.tsx
index 2b2b96c..6714c3c 100644
--- a/src/components/experiments/designer/flow/FlowListView.tsx
+++ b/src/components/experiments/designer/flow/FlowListView.tsx
@@ -1,11 +1,36 @@
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) => (
+
+ ))}
+ >
+ );
+}
+
+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)
*
@@ -34,7 +59,10 @@ export interface FlowListViewProps {
/**
* Optional callbacks for higher-level orchestration (e.g. autosave triggers)
*/
- onStepMutated?: (step: ExperimentStep, kind: "create" | "update" | "delete") => void;
+ onStepMutated?: (
+ step: ExperimentStep,
+ kind: "create" | "update" | "delete",
+ ) => void;
onActionMutated?: (
action: ExperimentAction,
step: ExperimentStep,
@@ -118,10 +146,12 @@ export function FlowListView({
+ {/* Hidden droppable anchors to enable dropping actions onto steps */}
+
s.id)} />
selectStep(id)}
onActionSelect={(actionId) =>
selectedStepId && actionId
diff --git a/src/components/experiments/designer/panels/ActionLibraryPanel.tsx b/src/components/experiments/designer/panels/ActionLibraryPanel.tsx
index fd59e3d..a59eed8 100644
--- a/src/components/experiments/designer/panels/ActionLibraryPanel.tsx
+++ b/src/components/experiments/designer/panels/ActionLibraryPanel.tsx
@@ -125,8 +125,8 @@ function DraggableAction({
{...listeners}
style={style}
className={cn(
- "group relative flex cursor-grab items-center gap-2 rounded border bg-background/60 px-2 transition-colors hover:bg-accent/50",
- compact ? "py-1 text-[11px]" : "py-2 text-xs",
+ "group bg-background/60 hover:bg-accent/50 relative flex w-full cursor-grab flex-col items-start gap-1 rounded border px-2 transition-colors",
+ compact ? "py-2 text-[11px]" : "py-3 text-[12px]",
isDragging && "opacity-50",
)}
draggable={false}
@@ -218,19 +218,16 @@ export function ActionLibraryPanel() {
}
}, []);
- const persistFavorites = useCallback(
- (next: Set) => {
- try {
- localStorage.setItem(
- FAVORITES_STORAGE_KEY,
- JSON.stringify({ favorites: Array.from(next) }),
- );
- } catch {
- /* noop */
- }
- },
- [],
- );
+ const persistFavorites = useCallback((next: Set) => {
+ try {
+ localStorage.setItem(
+ FAVORITES_STORAGE_KEY,
+ JSON.stringify({ favorites: Array.from(next) }),
+ );
+ } catch {
+ /* noop */
+ }
+ }, []);
const toggleFavorite = useCallback(
(id: string) => {
@@ -254,7 +251,12 @@ export function ActionLibraryPanel() {
}> = [
{ key: "wizard", label: "Wizard", icon: User, color: "bg-blue-500" },
{ key: "robot", label: "Robot", icon: Bot, color: "bg-emerald-600" },
- { key: "control", label: "Control", icon: GitBranch, color: "bg-amber-500" },
+ {
+ key: "control",
+ label: "Control",
+ icon: GitBranch,
+ color: "bg-amber-500",
+ },
{ key: "observation", label: "Observe", icon: Eye, color: "bg-purple-600" },
];
@@ -329,10 +331,10 @@ export function ActionLibraryPanel() {
return (
{/* Toolbar */}
-
+
-
+
setSearch(e.target.value)}
@@ -413,20 +415,22 @@ export function ActionLibraryPanel() {
})}
-
+
{filtered.length} shown / {allActions.length} total
- Plugins: {registry.getDebugInfo().pluginActionsLoaded ? "✓" : "…"}
+
+ Plugins: {registry.getDebugInfo().pluginActionsLoaded ? "✓" : "…"}
+
{/* Actions List */}
-
+
{filtered.length === 0 ? (
@@ -448,7 +452,7 @@ export function ActionLibraryPanel() {
{/* Footer Summary */}
-
+
@@ -460,7 +464,7 @@ export function ActionLibraryPanel() {
)}
-
+
Core: {registry.getDebugInfo().coreActionsLoaded ? "✓" : "…"}
diff --git a/src/components/experiments/designer/panels/InspectorPanel.tsx b/src/components/experiments/designer/panels/InspectorPanel.tsx
index aa14b05..8b906c4 100644
--- a/src/components/experiments/designer/panels/InspectorPanel.tsx
+++ b/src/components/experiments/designer/panels/InspectorPanel.tsx
@@ -17,7 +17,6 @@ import {
Settings,
AlertTriangle,
GitBranch,
- ListChecks,
PackageSearch,
} from "lucide-react";
@@ -83,10 +82,7 @@ export function InspectorPanel({
);
const selectedAction: ExperimentAction | undefined = useMemo(
- () =>
- selectedStep?.actions.find(
- (a) => a.id === selectedActionId,
- ) as ExperimentAction | undefined,
+ () => selectedStep?.actions.find((a) => a.id === selectedActionId),
[selectedStep, selectedActionId],
);
@@ -112,11 +108,7 @@ export function InspectorPanel({
const handleTabChange = useCallback(
(val: string) => {
- if (
- val === "properties" ||
- val === "issues" ||
- val === "dependencies"
- ) {
+ if (val === "properties" || val === "issues" || val === "dependencies") {
if (activeTab) {
onTabChange?.(val);
} else {
@@ -131,11 +123,7 @@ export function InspectorPanel({
/* Mutation Handlers (pass-through to store) */
/* ------------------------------------------------------------------------ */
const handleActionUpdate = useCallback(
- (
- stepId: string,
- actionId: string,
- updates: Partial
,
- ) => {
+ (stepId: string, actionId: string, updates: Partial) => {
const step = steps.find((s) => s.id === stepId);
if (!step) return;
const action = step.actions.find((a) => a.id === actionId);
@@ -159,10 +147,7 @@ export function InspectorPanel({
/* ------------------------------------------------------------------------ */
const issueCount = useMemo(
() =>
- Object.values(validationIssues).reduce(
- (sum, arr) => sum + arr.length,
- 0,
- ),
+ Object.values(validationIssues).reduce((sum, arr) => sum + arr.length, 0),
[validationIssues],
);
@@ -179,7 +164,7 @@ export function InspectorPanel({
return (
@@ -209,7 +194,7 @@ export function InspectorPanel({
Issues{issueCount > 0 ? ` (${issueCount})` : ""}
{issueCount > 0 && (
-
+
{issueCount}
)}
@@ -224,7 +209,7 @@ export function InspectorPanel({
Deps{driftCount > 0 ? ` (${driftCount})` : ""}
{driftCount > 0 && (
-
+
{driftCount}
)}
@@ -237,45 +222,43 @@ export function InspectorPanel({
{/* Properties */}
-
- {propertiesEmpty ? (
-
-
-
-
-
-
- Select a Step or Action
-
-
- Click within the flow to edit its properties here.
-
-
+
+ {propertiesEmpty ? (
+
+
+
- ) : (
-
-
-
- )}
-
+
+
Select a Step or Action
+
+ Click within the flow to edit its properties here.
+
+
+
+ ) : (
+
+
+
+ )}
+
{/* Issues */}
{
// Placeholder: future diff modal / signature update
- // eslint-disable-next-line no-console
+
console.log("Reconcile TODO for action:", actionId);
}}
onRefreshDependencies={() => {
- // eslint-disable-next-line no-console
console.log("Refresh dependencies TODO");
}}
onInstallPlugin={(pluginId) => {
- // eslint-disable-next-line no-console
console.log("Install plugin TODO:", pluginId);
}}
/>
@@ -334,7 +315,7 @@ export function InspectorPanel({
{/* Footer (lightweight) */}
-
+
Inspector • {selectedStep ? "Step" : selectedAction ? "Action" : "None"}{" "}
• {issueCount} issues • {driftCount} drift