Break work

This commit is contained in:
2026-01-20 09:38:07 -05:00
parent d83c02759a
commit 4fbd3be324
36 changed files with 3117 additions and 2770 deletions

View File

@@ -78,6 +78,7 @@ export class ActionRegistry {
parameters?: CoreBlockParam[];
timeoutMs?: number;
retryable?: boolean;
nestable?: boolean;
}
try {
@@ -139,6 +140,7 @@ export class ActionRegistry {
parameterSchemaRaw: {
parameters: block.parameters ?? [],
},
nestable: block.nestable,
};
this.actions.set(actionDef.id, actionDef);
@@ -180,31 +182,33 @@ export class ActionRegistry {
private loadFallbackActions(): void {
const fallbackActions: ActionDefinition[] = [
{
id: "wizard_speak",
type: "wizard_speak",
id: "wizard_say",
type: "wizard_say",
name: "Wizard Says",
description: "Wizard speaks to participant",
category: "wizard",
icon: "MessageSquare",
color: "#3b82f6",
color: "#a855f7",
parameters: [
{
id: "text",
name: "Text to say",
id: "message",
name: "Message",
type: "text",
placeholder: "Hello, participant!",
required: true,
},
],
source: { kind: "core", baseActionId: "wizard_speak" },
execution: { transport: "internal", timeoutMs: 30000 },
parameterSchemaRaw: {
type: "object",
properties: {
text: { type: "string" },
{
id: "tone",
name: "Tone",
type: "select",
options: ["neutral", "friendly", "encouraging"],
value: "neutral",
},
required: ["text"],
},
],
source: { kind: "core", baseActionId: "wizard_say" },
execution: { transport: "internal", timeoutMs: 30000 },
parameterSchemaRaw: {},
nestable: false,
},
{
id: "wait",
@@ -366,34 +370,34 @@ export class ActionRegistry {
const execution = action.ros2
? {
transport: "ros2" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
ros2: {
topic: action.ros2.topic,
messageType: action.ros2.messageType,
service: action.ros2.service,
action: action.ros2.action,
qos: action.ros2.qos,
payloadMapping: action.ros2.payloadMapping,
},
}
transport: "ros2" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
ros2: {
topic: action.ros2.topic,
messageType: action.ros2.messageType,
service: action.ros2.service,
action: action.ros2.action,
qos: action.ros2.qos,
payloadMapping: action.ros2.payloadMapping,
},
}
: action.rest
? {
transport: "rest" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
rest: {
method: action.rest.method,
path: action.rest.path,
headers: action.rest.headers,
},
}
transport: "rest" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
rest: {
method: action.rest.method,
path: action.rest.path,
headers: action.rest.headers,
},
}
: {
transport: "internal" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
};
transport: "internal" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
};
const actionDef: ActionDefinition = {
id: `${plugin.robotId ?? plugin.id}.${action.id}`,

View File

@@ -26,8 +26,10 @@ import {
MouseSensor,
TouchSensor,
KeyboardSensor,
closestCorners,
type DragEndEvent,
type DragStartEvent,
type DragOverEvent,
} from "@dnd-kit/core";
import { BottomStatusBar } from "./layout/BottomStatusBar";
import { ActionLibraryPanel } from "./panels/ActionLibraryPanel";
@@ -599,11 +601,8 @@ export function DesignerRoot({
// Serialize steps for stable comparison
const stepsHash = useMemo(() => JSON.stringify(steps), [steps]);
useEffect(() => {
if (!initialized) return;
void recomputeHash();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stepsHash, initialized]);
// Intentionally removed redundant recomputeHash useEffect that was causing excessive refreshes
// The debounced useEffect (lines 352-372) handles this correctly.
useEffect(() => {
if (selectedStepId || selectedActionId) {
@@ -628,18 +627,10 @@ export function DesignerRoot({
) {
e.preventDefault();
void persist();
} else if (e.key === "v" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
void validateDesign();
} else if (e.key === "e" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
void handleExport();
} else if (e.key === "n" && e.shiftKey) {
e.preventDefault();
createNewStep();
}
// 'v' (validate), 'e' (export), 'Shift+N' (new step) shortcuts removed to prevent accidents
},
[hasUnsavedChanges, persist, validateDesign, handleExport, createNewStep],
[hasUnsavedChanges, persist],
);
useEffect(() => {
@@ -687,43 +678,163 @@ export function DesignerRoot({
[toggleLibraryScrollLock],
);
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event;
console.debug("[DesignerRoot] dragEnd", {
active: active?.id,
over: over?.id ?? null,
});
// Clear overlay immediately
toggleLibraryScrollLock(false);
setDragOverlayAction(null);
if (!over) {
console.debug("[DesignerRoot] dragEnd: no drop target (ignored)");
const handleDragOver = useCallback((event: DragOverEvent) => {
const { active, over } = event;
const store = useDesignerStore.getState();
// Only handle Library -> Flow projection
if (!active.id.toString().startsWith("action-")) {
if (store.insertionProjection) {
store.setInsertionProjection(null);
}
return;
}
if (!over) {
if (store.insertionProjection) {
store.setInsertionProjection(null);
}
return;
}
const overId = over.id.toString();
const activeDef = active.data.current?.action;
if (!activeDef) return;
let stepId: string | null = null;
let parentId: string | null = null;
let index = 0;
// Detect target based on over id
if (overId.startsWith("s-act-")) {
const data = over.data.current;
if (data && data.stepId) {
stepId = data.stepId;
parentId = data.parentId ?? null; // Use parentId from the action we are hovering over
// Use sortable index (insertion point provided by dnd-kit sortable strategy)
index = data.sortable?.index ?? 0;
}
} else if (overId.startsWith("container-")) {
// Dropping into a container (e.g. Loop)
const data = over.data.current;
if (data && data.stepId) {
stepId = data.stepId;
parentId = data.parentId ?? overId.slice("container-".length);
// If dropping into container, appending is a safe default if specific index logic is missing
// But actually we can find length if we want. For now, 0 or append logic?
// If container is empty, index 0 is correct.
// If not empty, we are hitting the container *background*, so append?
// The projection logic will insert at 'index'. If index is past length, it appends.
// Let's set a large index to ensure append, or look up length.
// Lookup requires finding the action in store. Expensive?
// Let's assume index 0 for now (prepend) or implement lookup.
// Better: lookup action -> children length.
const actionId = parentId;
const step = store.steps.find(s => s.id === stepId);
// Find action recursive? Store has `findActionById` helper but it is not exported/accessible easily here?
// Actually, `store.steps` is available.
// We can implement a quick BFS/DFS or just assume 0.
// If dragging over the container *background* (empty space), append is usually expected.
// Let's try 9999?
index = 9999;
}
} else if (overId.startsWith("s-step-") || overId.startsWith("step-")) {
// Container drop (Step)
stepId = overId.startsWith("s-step-")
? overId.slice("s-step-".length)
: overId.slice("step-".length);
const step = store.steps.find((s) => s.id === stepId);
index = step ? step.actions.length : 0;
} else if (overId === "projection-placeholder") {
// Hovering over our own projection placeholder -> keep current state
return;
}
if (stepId) {
const current = store.insertionProjection;
// Optimization: avoid redundant updates if projection matches
if (
current &&
current.stepId === stepId &&
current.parentId === parentId &&
current.index === index
) {
return;
}
// Expect dragged action (library) onto a step droppable
const activeId = active.id.toString();
const overId = over.id.toString();
store.setInsertionProjection({
stepId,
parentId,
index,
action: {
id: "projection-placeholder",
type: activeDef.type,
name: activeDef.name,
category: activeDef.category,
description: "Drop here",
source: activeDef.source || { kind: "library" },
parameters: {},
execution: activeDef.execution,
} as any,
});
} else {
if (store.insertionProjection) store.setInsertionProjection(null);
}
}, []);
if (activeId.startsWith("action-") && active.data.current?.action) {
// Resolve stepId from possible over ids: step-<id>, s-step-<id>, or s-act-<actionId>
let stepId: string | null = null;
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event;
// Clear overlay immediately
toggleLibraryScrollLock(false);
setDragOverlayAction(null);
// Capture and clear projection
const store = useDesignerStore.getState();
const projection = store.insertionProjection;
store.setInsertionProjection(null);
if (!over) {
return;
}
// 1. Determine Target (Step, Parent, Index)
let stepId: string | null = null;
let parentId: string | null = null;
let index: number | undefined = undefined;
if (projection) {
stepId = projection.stepId;
parentId = projection.parentId;
index = projection.index;
} else {
// Fallback: resolution from overId (if projection failed or raced)
const overId = over.id.toString();
if (overId.startsWith("step-")) {
stepId = overId.slice("step-".length);
} else if (overId.startsWith("s-step-")) {
stepId = overId.slice("s-step-".length);
} else if (overId.startsWith("s-act-")) {
// This might fail if s-act-projection, but that should have covered by projection check above
const actionId = overId.slice("s-act-".length);
const parent = steps.find((s) =>
s.actions.some((a) => a.id === actionId),
);
stepId = parent?.id ?? null;
}
if (!stepId) return;
}
if (!stepId) return;
const targetStep = steps.find((s) => s.id === stepId);
if (!targetStep) return;
// 2. Instantiate Action
if (active.id.toString().startsWith("action-") && active.data.current?.action) {
const actionDef = active.data.current.action as {
id: string;
id: string; // type
type: string;
name: string;
category: string;
@@ -733,14 +844,13 @@ export function DesignerRoot({
parameters: Array<{ id: string; name: string }>;
};
const targetStep = steps.find((s) => s.id === stepId);
if (!targetStep) return;
const fullDef = actionRegistry.getAction(actionDef.type);
const defaultParams: Record<string, unknown> = {};
if (fullDef?.parameters) {
for (const param of fullDef.parameters) {
// @ts-expect-error - 'default' property access
if (param.default !== undefined) {
// @ts-expect-error - 'default' property access
defaultParams[param.id] = param.default;
}
}
@@ -755,39 +865,61 @@ export function DesignerRoot({
transport: actionDef.execution.transport,
retryable: actionDef.execution.retryable ?? false,
}
: {
transport: "internal",
retryable: false,
};
: undefined;
const newAction: ExperimentAction = {
id: `action-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
type: actionDef.type,
id: crypto.randomUUID(),
type: actionDef.type, // this is the 'type' key
name: actionDef.name,
category: actionDef.category as ExperimentAction["category"],
category: actionDef.category as any,
description: "",
parameters: defaultParams,
source: actionDef.source as ExperimentAction["source"],
source: actionDef.source ? {
kind: actionDef.source.kind as any,
pluginId: actionDef.source.pluginId,
pluginVersion: actionDef.source.pluginVersion,
baseActionId: actionDef.id
} : { kind: "core" },
execution,
children: [],
};
upsertAction(stepId, newAction);
// Select the newly added action and open properties
selectStep(stepId);
// 3. Commit
upsertAction(stepId, newAction, parentId, index);
// Auto-select
selectAction(stepId, newAction.id);
setInspectorTab("properties");
await recomputeHash();
toast.success(`Added ${actionDef.name} to ${targetStep.name}`);
void recomputeHash();
}
},
[
steps,
upsertAction,
recomputeHash,
selectStep,
selectAction,
toggleLibraryScrollLock,
],
[steps, upsertAction, selectAction, recomputeHash, toggleLibraryScrollLock],
);
// validation status badges removed (unused)
/* ------------------------------- Panels ---------------------------------- */
const leftPanel = useMemo(
() => (
<div ref={libraryRootRef} data-library-root className="h-full">
<ActionLibraryPanel />
</div>
),
[],
);
const centerPanel = useMemo(() => <FlowWorkspace />, []);
const rightPanel = useMemo(
() => (
<div className="h-full">
<InspectorPanel
activeTab={inspectorTab}
onTabChange={setInspectorTab}
studyPlugins={studyPlugins}
/>
</div>
),
[inspectorTab, studyPlugins],
);
/* ------------------------------- Render ---------------------------------- */
if (loadingExperiment && !initialized) {
@@ -852,33 +984,33 @@ export function DesignerRoot({
<div className="flex h-[calc(100vh-12rem)] w-full max-w-full flex-col overflow-hidden rounded-md border">
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={() => toggleLibraryScrollLock(false)}
>
<PanelsContainer
showDividers
className="min-h-0 flex-1"
left={
<div ref={libraryRootRef} data-library-root className="h-full">
<ActionLibraryPanel />
</div>
}
center={<FlowWorkspace />}
right={
<div className="h-full">
<InspectorPanel
activeTab={inspectorTab}
onTabChange={setInspectorTab}
studyPlugins={studyPlugins}
/>
</div>
}
left={leftPanel}
center={centerPanel}
right={rightPanel}
/>
<DragOverlay>
{dragOverlayAction ? (
<div className="bg-background pointer-events-none rounded border px-2 py-1 text-xs shadow-lg select-none">
<div className="bg-background flex items-center gap-2 rounded border px-3 py-2 text-xs font-medium shadow-lg select-none">
<span
className={cn(
"h-2.5 w-2.5 rounded-full",
{
wizard: "bg-blue-500",
robot: "bg-emerald-600",
control: "bg-amber-500",
observation: "bg-purple-600",
}[dragOverlayAction.category] || "bg-slate-400",
)}
/>
{dragOverlayAction.name}
</div>
) : null}

View File

@@ -282,205 +282,22 @@ export function PropertiesPanelBase({
Parameters
</div>
<div className="space-y-3">
{def.parameters.map((param) => {
const rawValue = selectedAction.parameters[param.id];
const commonLabel = (
<Label className="flex items-center gap-2 text-xs">
{param.name}
<span className="text-muted-foreground font-normal">
{param.type === "number" &&
(param.min !== undefined || param.max !== undefined) &&
typeof rawValue === "number" &&
`( ${rawValue} )`}
</span>
</Label>
);
/* ---- Handlers ---- */
const updateParamValue = (value: unknown) => {
setLocalParams((prev) => ({ ...prev, [param.id]: value }));
debouncedParamUpdate(
containingStep.id,
selectedAction.id,
param.id,
value,
);
};
const updateParamValueImmediate = (value: unknown) => {
setLocalParams((prev) => ({ ...prev, [param.id]: value }));
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: {
...selectedAction.parameters,
[param.id]: value,
},
});
};
const updateParamLocal = (value: unknown) => {
setLocalParams((prev) => ({ ...prev, [param.id]: value }));
};
const commitParamValue = () => {
if (localParams[param.id] !== rawValue) {
{def.parameters.map((param) => (
<ParameterEditor
key={param.id}
param={param}
value={selectedAction.parameters[param.id]}
onUpdate={(val) => {
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: {
...selectedAction.parameters,
[param.id]: localParams[param.id],
[param.id]: val,
},
});
}
};
/* ---- Control Rendering ---- */
let control: React.ReactNode = null;
if (param.type === "text") {
const localValue = localParams[param.id] ?? rawValue ?? "";
control = (
<Input
value={localValue as string}
placeholder={param.placeholder}
onChange={(e) => updateParamValue(e.target.value)}
onBlur={() => {
if (localParams[param.id] !== rawValue) {
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: {
...selectedAction.parameters,
[param.id]: localParams[param.id],
},
});
}
}}
className="mt-1 h-7 w-full text-xs"
/>
);
} else if (param.type === "select") {
const localValue = localParams[param.id] ?? rawValue ?? "";
control = (
<Select
value={localValue as string}
onValueChange={(val) => updateParamValueImmediate(val)}
>
<SelectTrigger className="mt-1 h-7 w-full text-xs">
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent>
{param.options?.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
);
} else if (param.type === "boolean") {
const localValue = localParams[param.id] ?? rawValue ?? false;
control = (
<div className="mt-1 flex h-7 items-center">
<Switch
checked={Boolean(localValue)}
onCheckedChange={(val) =>
updateParamValueImmediate(val)
}
aria-label={param.name}
/>
<span className="text-muted-foreground ml-2 text-[11px]">
{Boolean(localValue) ? "Enabled" : "Disabled"}
</span>
</div>
);
} else if (param.type === "number") {
const localValue = localParams[param.id] ?? rawValue;
const numericVal =
typeof localValue === "number"
? localValue
: typeof param.value === "number"
? param.value
: (param.min ?? 0);
if (param.min !== undefined || param.max !== undefined) {
const min = param.min ?? 0;
const max =
param.max ??
Math.max(
min + 1,
Number.isFinite(numericVal) ? numericVal : min + 1,
);
// Step heuristic
const range = max - min;
const step =
param.step ??
(range <= 5
? 0.1
: range <= 50
? 0.5
: Math.max(1, Math.round(range / 100)));
control = (
<div className="mt-1">
<div className="flex items-center gap-2">
<Slider
min={min}
max={max}
step={step}
value={[Number(numericVal)]}
onValueChange={(vals: number[]) =>
updateParamLocal(vals[0])
}
onPointerUp={commitParamValue}
/>
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
{step < 1
? Number(numericVal).toFixed(2)
: Number(numericVal).toString()}
</span>
</div>
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
<span>{min}</span>
<span>{max}</span>
</div>
</div>
);
} else {
control = (
<Input
type="number"
value={numericVal}
onChange={(e) =>
updateParamValue(parseFloat(e.target.value) || 0)
}
onBlur={() => {
if (localParams[param.id] !== rawValue) {
onActionUpdate(
containingStep.id,
selectedAction.id,
{
parameters: {
...selectedAction.parameters,
[param.id]: localParams[param.id],
},
},
);
}
}}
className="mt-1 h-7 w-full text-xs"
/>
);
}
}
return (
<div key={param.id} className="space-y-1">
{commonLabel}
{param.description && (
<div className="text-muted-foreground text-[10px]">
{param.description}
</div>
)}
{control}
</div>
);
})}
}}
onCommit={() => { }}
/>
))}
</div>
</div>
) : (
@@ -635,3 +452,156 @@ export function PropertiesPanelBase({
}
export const PropertiesPanel = React.memo(PropertiesPanelBase);
/* -------------------------------------------------------------------------- */
/* Isolated Parameter Editor (Optimized) */
/* -------------------------------------------------------------------------- */
interface ParameterEditorProps {
param: any;
value: unknown;
onUpdate: (value: unknown) => void;
onCommit: () => void;
}
const ParameterEditor = React.memo(function ParameterEditor({
param,
value: rawValue,
onUpdate,
onCommit
}: ParameterEditorProps) {
// Local state for immediate feedback
const [localValue, setLocalValue] = useState<unknown>(rawValue);
const debounceRef = useRef<NodeJS.Timeout | undefined>();
// Sync from prop if it changes externally
useEffect(() => {
setLocalValue(rawValue);
}, [rawValue]);
const handleUpdate = useCallback((newVal: unknown, immediate = false) => {
setLocalValue(newVal);
if (debounceRef.current) clearTimeout(debounceRef.current);
if (immediate) {
onUpdate(newVal);
} else {
debounceRef.current = setTimeout(() => {
onUpdate(newVal);
}, 300);
}
}, [onUpdate]);
const handleCommit = useCallback(() => {
if (localValue !== rawValue) {
onUpdate(localValue);
}
}, [localValue, rawValue, onUpdate]);
let control: React.ReactNode = null;
if (param.type === "text") {
control = (
<Input
value={(localValue as string) ?? ""}
placeholder={param.placeholder}
onChange={(e) => handleUpdate(e.target.value)}
onBlur={handleCommit}
className="mt-1 h-7 w-full text-xs"
/>
);
} else if (param.type === "select") {
control = (
<Select
value={(localValue as string) ?? ""}
onValueChange={(val) => handleUpdate(val, true)}
>
<SelectTrigger className="mt-1 h-7 w-full text-xs">
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent>
{param.options?.map((opt: string) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
);
} else if (param.type === "boolean") {
control = (
<div className="mt-1 flex h-7 items-center">
<Switch
checked={Boolean(localValue)}
onCheckedChange={(val) => handleUpdate(val, true)}
aria-label={param.name}
/>
<span className="text-muted-foreground ml-2 text-[11px]">
{Boolean(localValue) ? "Enabled" : "Disabled"}
</span>
</div>
);
} else if (param.type === "number") {
const numericVal = typeof localValue === "number" ? localValue : (param.min ?? 0);
if (param.min !== undefined || param.max !== undefined) {
const min = param.min ?? 0;
const max = param.max ?? Math.max(min + 1, Number.isFinite(numericVal) ? numericVal : min + 1);
const range = max - min;
const step = param.step ?? (range <= 5 ? 0.1 : range <= 50 ? 0.5 : Math.max(1, Math.round(range / 100)));
control = (
<div className="mt-1">
<div className="flex items-center gap-2">
<Slider
min={min}
max={max}
step={step}
value={[Number(numericVal)]}
onValueChange={(vals) => setLocalValue(vals[0])} // Update only local visual
onPointerUp={() => handleUpdate(localValue)} // Commit on release
/>
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
{step < 1 ? Number(numericVal).toFixed(2) : Number(numericVal).toString()}
</span>
</div>
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
<span>{min}</span>
<span>{max}</span>
</div>
</div>
);
} else {
control = (
<Input
type="number"
value={numericVal}
onChange={(e) => handleUpdate(parseFloat(e.target.value) || 0)}
onBlur={handleCommit}
className="mt-1 h-7 w-full text-xs"
/>
);
}
}
return (
<div className="space-y-1">
<Label className="flex items-center gap-2 text-xs">
{param.name}
<span className="text-muted-foreground font-normal">
{param.type === "number" &&
(param.min !== undefined || param.max !== undefined) &&
typeof rawValue === "number" &&
`( ${rawValue} )`}
</span>
</Label>
{param.description && (
<div className="text-muted-foreground text-[10px]">
{param.description}
</div>
)}
{control}
</div>
);
});

View File

@@ -12,6 +12,7 @@ import {
useDndMonitor,
type DragEndEvent,
type DragStartEvent,
type DragOverEvent,
} from "@dnd-kit/core";
import {
useSortable,
@@ -68,7 +69,7 @@ interface FlowWorkspaceProps {
onActionCreate?: (stepId: string, action: ExperimentAction) => void;
}
interface VirtualItem {
export interface VirtualItem {
index: number;
top: number;
height: number;
@@ -77,6 +78,232 @@ interface VirtualItem {
visible: boolean;
}
interface StepRowProps {
item: VirtualItem;
selectedStepId: string | null | undefined;
selectedActionId: string | null | undefined;
renamingStepId: string | null;
onSelectStep: (id: string | undefined) => void;
onSelectAction: (stepId: string, actionId: string | undefined) => void;
onToggleExpanded: (step: ExperimentStep) => void;
onRenameStep: (step: ExperimentStep, name: string) => void;
onDeleteStep: (step: ExperimentStep) => void;
onDeleteAction: (stepId: string, actionId: string) => void;
setRenamingStepId: (id: string | null) => void;
registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void;
}
const StepRow = React.memo(function StepRow({
item,
selectedStepId,
selectedActionId,
renamingStepId,
onSelectStep,
onSelectAction,
onToggleExpanded,
onRenameStep,
onDeleteStep,
onDeleteAction,
setRenamingStepId,
registerMeasureRef,
}: StepRowProps) {
const step = item.step;
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
const displayActions = useMemo(() => {
if (
insertionProjection?.stepId === step.id &&
insertionProjection.parentId === null
) {
const copy = [...step.actions];
// Insert placeholder action
// Ensure specific ID doesn't crash keys if collision (collision unlikely for library items)
// Actually, standard array key is action.id.
copy.splice(insertionProjection.index, 0, insertionProjection.action);
return copy;
}
return step.actions;
}, [step.actions, step.id, insertionProjection]);
const {
setNodeRef,
transform,
transition,
attributes,
listeners,
isDragging,
} = useSortable({
id: sortableStepId(step.id),
data: {
type: "step",
step: step,
},
});
const style: React.CSSProperties = {
position: "absolute",
top: item.top,
left: 0,
right: 0,
width: "100%",
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 25 : undefined,
};
return (
<div ref={setNodeRef} style={style} data-step-id={step.id}>
<div
ref={(el) => registerMeasureRef(step.id, el)}
className="relative px-3 py-4"
data-step-id={step.id}
>
<StepDroppableArea stepId={step.id} />
<div
className={cn(
"mb-2 rounded border shadow-sm transition-colors",
selectedStepId === step.id
? "border-border bg-accent/30"
: "hover:bg-accent/30",
isDragging && "opacity-80 ring-1 ring-blue-300",
)}
>
<div
className="flex items-center justify-between gap-2 border-b px-2 py-1.5"
onClick={(e) => {
const tag = (e.target as HTMLElement).tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "button")
return;
onSelectStep(step.id);
onSelectAction(step.id, undefined);
}}
role="button"
tabIndex={0}
>
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleExpanded(step);
}}
className="text-muted-foreground hover:bg-accent/60 hover:text-foreground rounded p-1"
aria-label={step.expanded ? "Collapse step" : "Expand step"}
>
{step.expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] font-normal"
>
{step.order + 1}
</Badge>
{renamingStepId === step.id ? (
<Input
autoFocus
defaultValue={step.name}
className="h-7 w-40 text-xs"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter") {
onRenameStep(
step,
(e.target as HTMLInputElement).value.trim() ||
step.name,
);
setRenamingStepId(null);
} else if (e.key === "Escape") {
setRenamingStepId(null);
}
}}
onBlur={(e) => {
onRenameStep(step, e.target.value.trim() || step.name);
setRenamingStepId(null);
}}
/>
) : (
<div className="flex items-center gap-1">
<span className="text-sm font-medium">{step.name}</span>
<button
type="button"
className="text-muted-foreground hover:text-foreground p-1 opacity-0 group-hover:opacity-100"
aria-label="Rename step"
onClick={(e) => {
e.stopPropagation();
setRenamingStepId(step.id);
}}
>
<Edit3 className="h-3.5 w-3.5" />
</button>
</div>
)}
<span className="text-muted-foreground hidden text-[11px] md:inline">
{step.actions.length} actions
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-[11px] text-red-500 hover:text-red-600"
onClick={(e) => {
e.stopPropagation();
onDeleteStep(step);
}}
aria-label="Delete step"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<div
className="text-muted-foreground cursor-grab p-1"
aria-label="Drag step"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</div>
</div>
</div>
{/* Action List (Collapsible/Virtual content) */}
{step.expanded && (
<div className="bg-background/40 min-h-[3rem] space-y-2 p-2 pb-8">
<SortableContext
items={displayActions.map((a) => sortableActionId(a.id))}
strategy={verticalListSortingStrategy}
>
<div className="flex w-full flex-col gap-2">
{displayActions.length === 0 ? (
<div className="flex h-12 items-center justify-center rounded border border-dashed text-xs text-muted-foreground">
Drop actions here
</div>
) : (
displayActions.map((action) => (
<SortableActionChip
key={action.id}
stepId={step.id}
action={action}
parentId={null}
selectedActionId={selectedActionId}
onSelectAction={onSelectAction}
onDeleteAction={onDeleteAction}
/>
))
)}
</div>
</SortableContext>
</div>
)}
</div>
</div>
</div>
);
});
/* -------------------------------------------------------------------------- */
/* Utility */
/* -------------------------------------------------------------------------- */
@@ -122,37 +349,125 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
/* -------------------------------------------------------------------------- */
interface ActionChipProps {
stepId: string;
action: ExperimentAction;
isSelected: boolean;
onSelect: () => void;
onDelete: () => void;
parentId: string | null;
selectedActionId: string | null | undefined;
onSelectAction: (stepId: string, actionId: string | undefined) => void;
onDeleteAction: (stepId: string, actionId: string) => void;
dragHandle?: boolean;
}
function SortableActionChip({
stepId,
action,
isSelected,
onSelect,
onDelete,
parentId,
selectedActionId,
onSelectAction,
onDeleteAction,
dragHandle,
}: ActionChipProps) {
const def = actionRegistry.getAction(action.type);
const isSelected = selectedActionId === action.id;
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
const displayChildren = useMemo(() => {
if (
insertionProjection?.stepId === stepId &&
insertionProjection.parentId === action.id
) {
const copy = [...(action.children || [])];
copy.splice(insertionProjection.index, 0, insertionProjection.action);
return copy;
}
return action.children;
}, [action.children, action.id, stepId, insertionProjection]);
/* ------------------------------------------------------------------------ */
/* Main Sortable Logic */
/* ------------------------------------------------------------------------ */
const isPlaceholder = action.id === "projection-placeholder";
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
isDragging: isSortableDragging,
} = useSortable({
id: sortableActionId(action.id),
disabled: isPlaceholder, // Disable sortable for placeholder
data: {
type: "action",
stepId,
parentId,
id: action.id,
},
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
// Use local dragging state or passed prop
const isDragging = isSortableDragging || dragHandle;
const style = {
transform: CSS.Translate.toString(transform),
transition,
zIndex: isDragging ? 30 : undefined,
};
/* ------------------------------------------------------------------------ */
/* Nested Droppable (for control flow containers) */
/* ------------------------------------------------------------------------ */
const nestedDroppableId = `container-${action.id}`;
const {
isOver: isOverNested,
setNodeRef: setNestedNodeRef
} = useDroppable({
id: nestedDroppableId,
disabled: !def?.nestable || isPlaceholder, // Disable droppable for placeholder
data: {
type: "container",
stepId,
parentId: action.id,
action // Pass full action for projection logic
}
});
const shouldRenderChildren = def?.nestable;
if (isPlaceholder) {
const { setNodeRef: setPlaceholderRef } = useDroppable({
id: "projection-placeholder",
data: { type: "placeholder" }
});
// Render simplified placeholder without hooks refs
// We still render the content matching the action type for visual fidelity
return (
<div
ref={setPlaceholderRef}
className="group relative flex w-full flex-col items-start gap-1 rounded border-2 border-dashed border-blue-300 bg-blue-50/50 px-3 py-2 text-[11px] opacity-70"
>
<div className="flex w-full items-center gap-2">
<span className={cn(
"h-2.5 w-2.5 rounded-full",
def ? {
wizard: "bg-blue-500",
robot: "bg-emerald-500",
control: "bg-amber-500",
observation: "bg-purple-500",
}[def.category] : "bg-gray-400"
)} />
<span className="font-medium text-foreground">{def?.name ?? action.name}</span>
</div>
{def?.description && (
<div className="text-muted-foreground line-clamp-3 w-full text-[10px] leading-snug">
{def.description}
</div>
)}
</div>
);
}
return (
<div
ref={setNodeRef}
@@ -162,8 +477,13 @@ function SortableActionChip({
"bg-muted/40 hover:bg-accent/40 cursor-pointer",
isSelected && "border-border bg-accent/30",
isDragging && "opacity-70 shadow-lg",
// Visual feedback for nested drop
isOverNested && !isDragging && "ring-2 ring-blue-400 ring-offset-1 bg-blue-50/50"
)}
onClick={onSelect}
onClick={(e) => {
e.stopPropagation();
onSelectAction(stepId, action.id);
}}
{...attributes}
role="button"
aria-pressed={isSelected}
@@ -197,7 +517,7 @@ function SortableActionChip({
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete();
onDeleteAction(stepId, action.id);
}}
className="text-muted-foreground hover:text-foreground rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
aria-label="Delete action"
@@ -221,12 +541,45 @@ function SortableActionChip({
</span>
))}
{def.parameters.length > 4 && (
<span className="text-muted-foreground text-[9px]">
+{def.parameters.length - 4} more
</span>
<span className="text-[9px] text-muted-foreground">+{def.parameters.length - 4}</span>
)}
</div>
) : null}
{/* Nested Actions Container */}
{shouldRenderChildren && (
<div
ref={setNestedNodeRef}
className={cn(
"mt-2 w-full flex flex-col gap-2 pl-4 border-l-2 border-border/40 transition-all min-h-[0.5rem] pb-4",
)}
>
<SortableContext
items={(displayChildren ?? action.children ?? [])
.filter(c => c.id !== "projection-placeholder")
.map(c => sortableActionId(c.id))}
strategy={verticalListSortingStrategy}
>
{(displayChildren || action.children || []).map((child) => (
<SortableActionChip
key={child.id}
stepId={stepId}
action={child}
parentId={action.id}
selectedActionId={selectedActionId}
onSelectAction={onSelectAction}
onDeleteAction={onDeleteAction}
/>
))}
{(!displayChildren?.length && !action.children?.length) && (
<div className="text-[10px] text-muted-foreground/60 italic py-1">
Drag actions here
</div>
)}
</SortableContext>
</div>
)}
</div>
);
}
@@ -254,7 +607,7 @@ export function FlowWorkspace({
const removeAction = useDesignerStore((s) => s.removeAction);
const reorderStep = useDesignerStore((s) => s.reorderStep);
const reorderAction = useDesignerStore((s) => s.reorderAction);
const moveAction = useDesignerStore((s) => s.moveAction);
const recomputeHash = useDesignerStore((s) => s.recomputeHash);
/* Local state */
@@ -382,7 +735,10 @@ export function FlowWorkspace({
description: "",
type: "sequential",
order: steps.length,
trigger: { type: "trial_start", conditions: {} },
trigger:
steps.length === 0
? { type: "trial_start", conditions: {} }
: { type: "previous_step", conditions: {} },
actions: [],
expanded: true,
};
@@ -472,34 +828,77 @@ export function FlowWorkspace({
}
}
}
// Action reorder (within same parent only)
// Action reorder (supports nesting)
if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) {
const fromActionId = parseSortableAction(activeId);
const toActionId = parseSortableAction(overId);
if (fromActionId && toActionId && fromActionId !== toActionId) {
const fromParent = actionParentMap.get(fromActionId);
const toParent = actionParentMap.get(toActionId);
if (fromParent && toParent && fromParent === toParent) {
const step = steps.find((s) => s.id === fromParent);
if (step) {
const fromIdx = step.actions.findIndex(
(a) => a.id === fromActionId,
);
const toIdx = step.actions.findIndex((a) => a.id === toActionId);
if (fromIdx >= 0 && toIdx >= 0) {
reorderAction(step.id, fromIdx, toIdx);
void recomputeHash();
}
}
const activeData = active.data.current;
const overData = over.data.current;
if (
activeData && overData &&
activeData.stepId === overData.stepId &&
activeData.type === 'action' && overData.type === 'action'
) {
const stepId = activeData.stepId as string;
const activeActionId = activeData.action.id;
const overActionId = overData.action.id;
if (activeActionId !== overActionId) {
const newParentId = overData.parentId as string | null;
const newIndex = overData.sortable.index; // index within that parent's list
moveAction(stepId, activeActionId, newParentId, newIndex);
void recomputeHash();
}
}
}
},
[steps, reorderStep, reorderAction, actionParentMap, recomputeHash],
[steps, reorderStep, moveAction, recomputeHash],
);
/* ------------------------------------------------------------------------ */
/* Drag Over (Live Sorting) */
/* ------------------------------------------------------------------------ */
const handleLocalDragOver = useCallback(
(event: DragOverEvent) => {
const { active, over } = event;
if (!over) return;
const activeId = active.id.toString();
const overId = over.id.toString();
// Only handle action reordering
if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) {
const activeData = active.data.current;
const overData = over.data.current;
if (
activeData &&
overData &&
activeData.type === 'action' &&
overData.type === 'action'
) {
const activeActionId = activeData.action.id;
const overActionId = overData.action.id;
const activeStepId = activeData.stepId;
const overStepId = overData.stepId;
const activeParentId = activeData.parentId;
const overParentId = overData.parentId;
// If moving between different lists (parents/steps), move immediately to visualize snap
if (activeParentId !== overParentId || activeStepId !== overStepId) {
// Determine new index
// verification of safe move handled by store
moveAction(overStepId, activeActionId, overParentId, overData.sortable.index);
}
}
}
},
[moveAction]
);
useDndMonitor({
onDragStart: handleLocalDragStart,
onDragOver: handleLocalDragOver,
onDragEnd: handleLocalDragEnd,
onDragCancel: () => {
// no-op
@@ -509,204 +908,22 @@ export function FlowWorkspace({
/* ------------------------------------------------------------------------ */
/* Step Row (Sortable + Virtualized) */
/* ------------------------------------------------------------------------ */
function StepRow({ item }: { item: VirtualItem }) {
const step = item.step;
const {
setNodeRef,
transform,
transition,
attributes,
listeners,
isDragging,
} = useSortable({
id: sortableStepId(step.id),
});
// StepRow moved outside of component to prevent re-mounting on every render (flashing fix)
const style: React.CSSProperties = {
position: "absolute",
top: item.top,
left: 0,
right: 0,
width: "100%",
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 25 : undefined,
};
const setMeasureRef = (el: HTMLDivElement | null) => {
const prev = measureRefs.current.get(step.id) ?? null;
const registerMeasureRef = useCallback(
(stepId: string, el: HTMLDivElement | null) => {
const prev = measureRefs.current.get(stepId) ?? null;
if (prev && prev !== el) {
roRef.current?.unobserve(prev);
measureRefs.current.delete(step.id);
measureRefs.current.delete(stepId);
}
if (el) {
measureRefs.current.set(step.id, el);
measureRefs.current.set(stepId, el);
roRef.current?.observe(el);
}
};
return (
<div ref={setNodeRef} style={style} data-step-id={step.id}>
<div
ref={setMeasureRef}
className="relative px-3 py-4"
data-step-id={step.id}
>
<StepDroppableArea stepId={step.id} />
<div
className={cn(
"mb-2 rounded border shadow-sm transition-colors",
selectedStepId === step.id
? "border-border bg-accent/30"
: "hover:bg-accent/30",
isDragging && "opacity-80 ring-1 ring-blue-300",
)}
>
<div
className="flex items-center justify-between gap-2 border-b px-2 py-1.5"
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;
selectStep(step.id);
selectAction(step.id, undefined);
}}
role="button"
tabIndex={0}
>
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleExpanded(step);
}}
className="text-muted-foreground hover:bg-accent/60 hover:text-foreground rounded p-1"
aria-label={step.expanded ? "Collapse step" : "Expand step"}
>
{step.expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] font-normal"
>
{step.order + 1}
</Badge>
{renamingStepId === step.id ? (
<Input
autoFocus
defaultValue={step.name}
className="h-7 w-40 text-xs"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter") {
renameStep(
step,
(e.target as HTMLInputElement).value.trim() ||
step.name,
);
setRenamingStepId(null);
void recomputeHash();
} else if (e.key === "Escape") {
setRenamingStepId(null);
}
}}
onBlur={(e) => {
renameStep(step, e.target.value.trim() || step.name);
setRenamingStepId(null);
void recomputeHash();
}}
/>
) : (
<div className="flex items-center gap-1">
<span className="text-sm font-medium">{step.name}</span>
<button
type="button"
className="text-muted-foreground hover:text-foreground p-1 opacity-0 group-hover:opacity-100"
aria-label="Rename step"
onClick={(e) => {
e.stopPropagation();
setRenamingStepId(step.id);
}}
>
<Edit3 className="h-3.5 w-3.5" />
</button>
</div>
)}
<span className="text-muted-foreground hidden text-[11px] md:inline">
{step.actions.length} actions
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-[11px] text-red-500 hover:text-red-600"
onClick={(e) => {
e.stopPropagation();
deleteStep(step);
}}
aria-label="Delete step"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<div
className="text-muted-foreground cursor-grab p-1"
aria-label="Drag step"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</div>
</div>
</div>
{step.expanded && (
<div className="space-y-2 px-3 py-3">
<div className="flex flex-wrap gap-2">
{step.actions.length > 0 && (
<SortableContext
items={step.actions.map((a) => sortableActionId(a.id))}
strategy={verticalListSortingStrategy}
>
<div className="flex w-full flex-col gap-2">
{step.actions.map((action) => (
<SortableActionChip
key={action.id}
action={action}
isSelected={
selectedStepId === step.id &&
selectedActionId === action.id
}
onSelect={() => {
selectStep(step.id);
selectAction(step.id, action.id);
}}
onDelete={() => deleteAction(step.id, action.id)}
/>
))}
</div>
</SortableContext>
)}
</div>
{/* Persistent centered bottom drop hint */}
<div className="mt-3 flex w-full items-center justify-center">
<div className="text-muted-foreground border-muted-foreground/30 rounded border border-dashed px-2 py-1 text-[11px]">
Drop actions here
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}
},
[],
);
/* ------------------------------------------------------------------------ */
/* Render */
@@ -767,7 +984,27 @@ export function FlowWorkspace({
>
<div style={{ height: totalHeight, position: "relative" }}>
{virtualItems.map(
(vi) => vi.visible && <StepRow key={vi.key} item={vi} />,
(vi) =>
vi.visible && (
<StepRow
key={vi.key}
item={vi}
selectedStepId={selectedStepId}
selectedActionId={selectedActionId}
renamingStepId={renamingStepId}
onSelectStep={selectStep}
onSelectAction={selectAction}
onToggleExpanded={toggleExpanded}
onRenameStep={(step, name) => {
renameStep(step, name);
void recomputeHash();
}}
onDeleteStep={deleteStep}
onDeleteAction={deleteAction}
setRenamingStepId={setRenamingStepId}
registerMeasureRef={registerMeasureRef}
/>
),
)}
</div>
</SortableContext>

View File

@@ -53,6 +53,30 @@ export interface PanelsContainerProps {
* - 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 Panel: React.FC<React.PropsWithChildren<{
className?: string;
panelClassName?: string;
contentClassName?: string;
}>> = ({
className: panelCls,
panelClassName,
contentClassName,
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>
);
export function PanelsContainer({
left,
center,
@@ -209,10 +233,10 @@ export function PanelsContainer({
// 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}%`,
}
"--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
@@ -229,28 +253,12 @@ export function PanelsContainer({
const centerDividers =
showDividers && hasCenter
? cn({
"border-l": hasLeft,
"border-r": hasRight,
})
"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>
);
return (
<div
@@ -263,11 +271,33 @@ export function PanelsContainer({
className,
)}
>
{hasLeft && <Panel>{left}</Panel>}
{hasLeft && (
<Panel
panelClassName={panelClassName}
contentClassName={contentClassName}
>
{left}
</Panel>
)}
{hasCenter && <Panel className={centerDividers}>{center}</Panel>}
{hasCenter && (
<Panel
className={centerDividers}
panelClassName={panelClassName}
contentClassName={contentClassName}
>
{center}
</Panel>
)}
{hasRight && <Panel>{right}</Panel>}
{hasRight && (
<Panel
panelClassName={panelClassName}
contentClassName={contentClassName}
>
{right}
</Panel>
)}
{/* Resize handles (only render where applicable) */}
{hasCenter && hasLeft && (

View File

@@ -174,7 +174,7 @@ export function ActionLibraryPanel() {
const [search, setSearch] = useState("");
const [selectedCategories, setSelectedCategories] = useState<
Set<ActionCategory>
>(new Set<ActionCategory>(["wizard"]));
>(new Set<ActionCategory>(["wizard", "robot", "control", "observation"]));
const [favorites, setFavorites] = useState<FavoritesState>({
favorites: new Set<string>(),
});
@@ -293,9 +293,7 @@ export function ActionLibraryPanel() {
setShowOnlyFavorites(false);
}, [categories]);
useEffect(() => {
setSelectedCategories(new Set(categories.map((c) => c.key)));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const filtered = useMemo(() => {
const activeCats = selectedCategories;

View File

@@ -155,8 +155,9 @@ function projectActionForDesign(
pluginVersion: action.source.pluginVersion,
baseActionId: action.source.baseActionId,
},
execution: projectExecutionDescriptor(action.execution),
execution: action.execution ? projectExecutionDescriptor(action.execution) : null,
parameterKeysOrValues: parameterProjection,
children: action.children?.map(c => projectActionForDesign(c, options)) ?? [],
};
if (options.includeActionNames) {

View File

@@ -79,6 +79,23 @@ export interface DesignerState {
busyHashing: boolean;
busyValidating: boolean;
/* ---------------------- DnD Projection (Transient) ----------------------- */
insertionProjection: {
stepId: string;
parentId: string | null;
index: number;
action: ExperimentAction;
} | null;
setInsertionProjection: (
projection: {
stepId: string;
parentId: string | null;
index: number;
action: ExperimentAction;
} | null
) => void;
/* ------------------------------ Mutators --------------------------------- */
// Selection
@@ -92,9 +109,10 @@ export interface DesignerState {
reorderStep: (from: number, to: number) => void;
// Actions
upsertAction: (stepId: string, action: ExperimentAction) => void;
upsertAction: (stepId: string, action: ExperimentAction, parentId?: string | null, index?: number) => void;
removeAction: (stepId: string, actionId: string) => void;
reorderAction: (stepId: string, from: number, to: number) => void;
moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) => void;
// Dirty
markDirty: (id: string) => void;
@@ -159,17 +177,73 @@ function reindexActions(actions: ExperimentAction[]): ExperimentAction[] {
return actions.map((a) => ({ ...a }));
}
function updateActionList(
existing: ExperimentAction[],
function findActionById(
list: ExperimentAction[],
id: string,
): ExperimentAction | null {
for (const action of list) {
if (action.id === id) return action;
if (action.children) {
const found = findActionById(action.children, id);
if (found) return found;
}
}
return null;
}
function updateActionInTree(
list: ExperimentAction[],
action: ExperimentAction,
): ExperimentAction[] {
const idx = existing.findIndex((a) => a.id === action.id);
if (idx >= 0) {
const copy = [...existing];
copy[idx] = { ...action };
return list.map((a) => {
if (a.id === action.id) return { ...action };
if (a.children) {
return { ...a, children: updateActionInTree(a.children, action) };
}
return a;
});
}
// Immutable removal
function removeActionFromTree(
list: ExperimentAction[],
id: string,
): ExperimentAction[] {
return list
.filter((a) => a.id !== id)
.map((a) => ({
...a,
children: a.children ? removeActionFromTree(a.children, id) : undefined,
}));
}
// Immutable insertion
function insertActionIntoTree(
list: ExperimentAction[],
action: ExperimentAction,
parentId: string | null,
index: number,
): ExperimentAction[] {
if (!parentId) {
// Insert at root level
const copy = [...list];
copy.splice(index, 0, action);
return copy;
}
return [...existing, { ...action }];
return list.map((a) => {
if (a.id === parentId) {
const children = a.children ? [...a.children] : [];
children.splice(index, 0, action);
return { ...a, children };
}
if (a.children) {
return {
...a,
children: insertActionIntoTree(a.children, action, parentId, index),
};
}
return a;
});
}
/* -------------------------------------------------------------------------- */
@@ -187,6 +261,7 @@ export const useDesignerStore = create<DesignerState>((set, get) => ({
autoSaveEnabled: true,
busyHashing: false,
busyValidating: false,
insertionProjection: null,
/* ------------------------------ Selection -------------------------------- */
selectStep: (id) =>
@@ -263,16 +338,31 @@ export const useDesignerStore = create<DesignerState>((set, get) => ({
}),
/* ------------------------------- Actions --------------------------------- */
upsertAction: (stepId: string, action: ExperimentAction) =>
upsertAction: (stepId: string, action: ExperimentAction, parentId: string | null = null, index?: number) =>
set((state: DesignerState) => {
const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
s.id === stepId
? {
...s,
actions: reindexActions(updateActionList(s.actions, action)),
}
: s,
);
const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
if (s.id !== stepId) return s;
// Check if exists (update)
const exists = findActionById(s.actions, action.id);
if (exists) {
// If updating, we don't (currently) support moving via upsert.
// Use moveAction for moving.
return {
...s,
actions: updateActionInTree(s.actions, action)
};
}
// Add new
// If index is provided, use it. Otherwise append.
const insertIndex = index ?? s.actions.length;
return {
...s,
actions: insertActionIntoTree(s.actions, action, parentId, insertIndex)
};
});
return {
steps: stepsDraft,
dirtyEntities: new Set<string>([
@@ -288,11 +378,9 @@ export const useDesignerStore = create<DesignerState>((set, get) => ({
const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
s.id === stepId
? {
...s,
actions: reindexActions(
s.actions.filter((a) => a.id !== actionId),
),
}
...s,
actions: removeActionFromTree(s.actions, actionId),
}
: s,
);
const dirty = new Set<string>(state.dirtyEntities);
@@ -308,31 +396,29 @@ export const useDesignerStore = create<DesignerState>((set, get) => ({
};
}),
reorderAction: (stepId: string, from: number, to: number) =>
moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) =>
set((state: DesignerState) => {
const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
const stepsDraft = state.steps.map((s) => {
if (s.id !== stepId) return s;
if (
from < 0 ||
to < 0 ||
from >= s.actions.length ||
to >= s.actions.length ||
from === to
) {
return s;
}
const actionsDraft = [...s.actions];
const [moved] = actionsDraft.splice(from, 1);
if (!moved) return s;
actionsDraft.splice(to, 0, moved);
return { ...s, actions: reindexActions(actionsDraft) };
const actionToMove = findActionById(s.actions, actionId);
if (!actionToMove) return s;
const pruned = removeActionFromTree(s.actions, actionId);
const inserted = insertActionIntoTree(pruned, actionToMove, newParentId, newIndex);
return { ...s, actions: inserted };
});
return {
steps: stepsDraft,
dirtyEntities: new Set<string>([...state.dirtyEntities, stepId]),
dirtyEntities: new Set<string>([...state.dirtyEntities, stepId, actionId]),
};
}),
reorderAction: (stepId: string, from: number, to: number) =>
get().moveAction(stepId, get().steps.find(s => s.id === stepId)?.actions[from]?.id!, null, to), // Legacy compat support (only works for root level reorder)
setInsertionProjection: (projection) => set({ insertionProjection: projection }),
/* -------------------------------- Dirty ---------------------------------- */
markDirty: (id: string) =>
set((state: DesignerState) => ({

View File

@@ -643,13 +643,13 @@ export function validateExecution(
if (trialStartSteps.length > 1) {
trialStartSteps.slice(1).forEach((step) => {
issues.push({
severity: "warning",
severity: "info",
message:
"Multiple steps will start simultaneously. Ensure parallel execution is intended.",
"This step will start immediately at trial start. For sequential flow, use 'Previous Step' trigger.",
category: "execution",
field: "trigger.type",
stepId: step.id,
suggestion: "Consider using sequential triggers for subsequent steps",
suggestion: "Change trigger to 'Previous Step' if this step should follow the previous one",
});
});
}