From 11b6ec89e780776c623f5d7d3f2af6f5a641c87d Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Mon, 11 Aug 2025 16:48:30 -0400 Subject: [PATCH] feat(designer): enable drag-drop v1 and compact tile layout for action library --- .../experiments/designer/DesignerRoot.tsx | 84 ++++++++++++-- .../designer/flow/FlowListView.tsx | 36 +++++- .../designer/panels/ActionLibraryPanel.tsx | 50 ++++---- .../designer/panels/InspectorPanel.tsx | 109 ++++++++---------- 4 files changed, 181 insertions(+), 98 deletions(-) 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