mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
Break work
This commit is contained in:
@@ -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}`,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user