mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-02-05 07:56:30 -05:00
feat: Redesign Landing, Auth, and Dashboard Pages
Also fixed schema type exports and seed script errors.
This commit is contained in:
56
src/components/experiments/ExperimentForm.tsx
Normal file → Executable file
56
src/components/experiments/ExperimentForm.tsx
Normal file → Executable file
@@ -87,33 +87,33 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
|
||||
{ label: "Studies", href: "/studies" },
|
||||
...(selectedStudyId
|
||||
? [
|
||||
{
|
||||
label: experiment?.study?.name ?? "Study",
|
||||
href: `/studies/${selectedStudyId}`,
|
||||
},
|
||||
{ label: "Experiments", href: "/experiments" },
|
||||
...(mode === "edit" && experiment
|
||||
? [
|
||||
{
|
||||
label: experiment.name,
|
||||
href: `/experiments/${experiment.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Experiment" }]),
|
||||
]
|
||||
{
|
||||
label: experiment?.study?.name ?? "Study",
|
||||
href: `/studies/${selectedStudyId}`,
|
||||
},
|
||||
{ label: "Experiments", href: "/experiments" },
|
||||
...(mode === "edit" && experiment
|
||||
? [
|
||||
{
|
||||
label: experiment.name,
|
||||
href: `/studies/${selectedStudyId}/experiments/${experiment.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Experiment" }]),
|
||||
]
|
||||
: [
|
||||
{ label: "Experiments", href: "/experiments" },
|
||||
...(mode === "edit" && experiment
|
||||
? [
|
||||
{
|
||||
label: experiment.name,
|
||||
href: `/experiments/${experiment.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Experiment" }]),
|
||||
]),
|
||||
{ label: "Experiments", href: "/experiments" },
|
||||
...(mode === "edit" && experiment
|
||||
? [
|
||||
{
|
||||
label: experiment.name,
|
||||
href: `/studies/${experiment.studyId}/experiments/${experiment.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Experiment" }]),
|
||||
]),
|
||||
];
|
||||
|
||||
useBreadcrumbsEffect(breadcrumbs);
|
||||
@@ -153,14 +153,14 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
|
||||
...data,
|
||||
estimatedDuration: data.estimatedDuration ?? undefined,
|
||||
});
|
||||
router.push(`/experiments/${newExperiment.id}/designer`);
|
||||
router.push(`/studies/${data.studyId}/experiments/${newExperiment.id}/designer`);
|
||||
} else {
|
||||
const updatedExperiment = await updateExperimentMutation.mutateAsync({
|
||||
id: experimentId!,
|
||||
...data,
|
||||
estimatedDuration: data.estimatedDuration ?? undefined,
|
||||
});
|
||||
router.push(`/experiments/${updatedExperiment.id}`);
|
||||
router.push(`/studies/${experiment?.studyId ?? data.studyId}/experiments/${updatedExperiment.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(
|
||||
|
||||
6
src/components/experiments/ExperimentsGrid.tsx
Normal file → Executable file
6
src/components/experiments/ExperimentsGrid.tsx
Normal file → Executable file
@@ -78,7 +78,7 @@ function ExperimentCard({ experiment }: ExperimentCardProps) {
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="truncate text-lg font-semibold text-slate-900 transition-colors group-hover:text-blue-600">
|
||||
<Link
|
||||
href={`/experiments/${experiment.id}`}
|
||||
href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{experiment.name}
|
||||
@@ -158,10 +158,10 @@ function ExperimentCard({ experiment }: ExperimentCardProps) {
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button asChild size="sm" className="flex-1">
|
||||
<Link href={`/experiments/${experiment.id}`}>View Details</Link>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>View Details</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="outline" className="flex-1">
|
||||
<Link href={`/experiments/${experiment.id}/designer`}>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
|
||||
<Settings className="mr-1 h-3 w-3" />
|
||||
Design
|
||||
</Link>
|
||||
|
||||
32
src/components/experiments/ExperimentsTable.tsx
Normal file → Executable file
32
src/components/experiments/ExperimentsTable.tsx
Normal file → Executable file
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, LayoutTemplate, PlayCircle, Archive } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
@@ -103,7 +103,7 @@ export const columns: ColumnDef<Experiment>[] = [
|
||||
<div className="max-w-[200px]">
|
||||
<div className="truncate font-medium">
|
||||
<Link
|
||||
href={`/experiments/${row.original.id}`}
|
||||
href={`/studies/${row.original.studyId}/experiments/${row.original.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{String(name)}
|
||||
@@ -259,20 +259,26 @@ export const columns: ColumnDef<Experiment>[] = [
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(experiment.id)}
|
||||
>
|
||||
Copy experiment ID
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/experiments/${experiment.id}`}>View details</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/experiments/${experiment.id}/edit`}>
|
||||
Edit experiment
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/experiments/${experiment.id}/designer`}>
|
||||
Open designer
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
|
||||
<LayoutTemplate className="mr-2 h-4 w-4" />
|
||||
Designer
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -280,12 +286,14 @@ export const columns: ColumnDef<Experiment>[] = [
|
||||
<Link
|
||||
href={`/studies/${experiment.studyId}/trials/new?experimentId=${experiment.id}`}
|
||||
>
|
||||
Create trial
|
||||
<PlayCircle className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
Archive experiment
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
82
src/components/experiments/designer/ActionRegistry.ts
Normal file → Executable file
82
src/components/experiments/designer/ActionRegistry.ts
Normal file → Executable 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}`,
|
||||
|
||||
36
src/components/experiments/designer/DependencyInspector.tsx
Normal file → Executable file
36
src/components/experiments/designer/DependencyInspector.tsx
Normal file → Executable file
@@ -57,6 +57,15 @@ export interface DependencyInspectorProps {
|
||||
* Available action definitions from registry
|
||||
*/
|
||||
actionDefinitions: ActionDefinition[];
|
||||
/**
|
||||
* Study plugins with name and metadata
|
||||
*/
|
||||
studyPlugins?: Array<{
|
||||
id: string;
|
||||
robotId: string;
|
||||
name: string;
|
||||
version: string;
|
||||
}>;
|
||||
/**
|
||||
* Called when user wants to reconcile a drifted action
|
||||
*/
|
||||
@@ -80,6 +89,12 @@ function extractPluginDependencies(
|
||||
steps: ExperimentStep[],
|
||||
actionDefinitions: ActionDefinition[],
|
||||
driftedActions: Set<string>,
|
||||
studyPlugins?: Array<{
|
||||
id: string;
|
||||
robotId: string;
|
||||
name: string;
|
||||
version: string;
|
||||
}>,
|
||||
): PluginDependency[] {
|
||||
const dependencyMap = new Map<string, PluginDependency>();
|
||||
|
||||
@@ -134,9 +149,12 @@ function extractPluginDependencies(
|
||||
dep.installedVersion = dep.version;
|
||||
}
|
||||
|
||||
// Set plugin name from first available definition
|
||||
// Set plugin name from studyPlugins if available
|
||||
if (availableActions[0]) {
|
||||
dep.name = availableActions[0].source.pluginId; // Could be enhanced with actual plugin name
|
||||
const pluginMeta = studyPlugins?.find(
|
||||
(p) => p.robotId === dep.pluginId,
|
||||
);
|
||||
dep.name = pluginMeta?.name ?? dep.pluginId;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -247,7 +265,9 @@ function PluginDependencyItem({
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{dependency.pluginId}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{dependency.name ?? dependency.pluginId}
|
||||
</span>
|
||||
<Badge
|
||||
variant={config.badgeVariant}
|
||||
className={cn("h-4 text-[10px]", config.badgeColor)}
|
||||
@@ -382,6 +402,7 @@ export function DependencyInspector({
|
||||
steps,
|
||||
actionSignatureDrift,
|
||||
actionDefinitions,
|
||||
studyPlugins,
|
||||
onReconcileAction,
|
||||
onRefreshDependencies,
|
||||
onInstallPlugin,
|
||||
@@ -389,8 +410,13 @@ export function DependencyInspector({
|
||||
}: DependencyInspectorProps) {
|
||||
const dependencies = useMemo(
|
||||
() =>
|
||||
extractPluginDependencies(steps, actionDefinitions, actionSignatureDrift),
|
||||
[steps, actionDefinitions, actionSignatureDrift],
|
||||
extractPluginDependencies(
|
||||
steps,
|
||||
actionDefinitions,
|
||||
actionSignatureDrift,
|
||||
studyPlugins,
|
||||
),
|
||||
[steps, actionDefinitions, actionSignatureDrift, studyPlugins],
|
||||
);
|
||||
|
||||
const drifts = useMemo(
|
||||
|
||||
606
src/components/experiments/designer/DesignerRoot.tsx
Normal file → Executable file
606
src/components/experiments/designer/DesignerRoot.tsx
Normal file → Executable file
@@ -1,9 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Play } from "lucide-react";
|
||||
import { Play, RefreshCw } from "lucide-react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
@@ -19,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";
|
||||
@@ -150,20 +159,28 @@ export function DesignerRoot({
|
||||
} = api.experiments.get.useQuery({ id: experimentId });
|
||||
|
||||
const updateExperiment = api.experiments.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast.success("Experiment saved");
|
||||
await refetchExperiment();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(`Save failed: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const { data: studyPlugins } = api.robots.plugins.getStudyPlugins.useQuery(
|
||||
const { data: studyPluginsRaw } = api.robots.plugins.getStudyPlugins.useQuery(
|
||||
{ studyId: experiment?.studyId ?? "" },
|
||||
{ enabled: !!experiment?.studyId },
|
||||
);
|
||||
|
||||
// Map studyPlugins to format expected by DependencyInspector
|
||||
const studyPlugins = useMemo(
|
||||
() =>
|
||||
studyPluginsRaw?.map((sp) => ({
|
||||
id: sp.plugin.id,
|
||||
robotId: sp.plugin.robotId ?? "",
|
||||
name: sp.plugin.name,
|
||||
version: sp.plugin.version,
|
||||
})),
|
||||
[studyPluginsRaw],
|
||||
);
|
||||
|
||||
/* ------------------------------ Store Access ----------------------------- */
|
||||
const steps = useDesignerStore((s) => s.steps);
|
||||
const setSteps = useDesignerStore((s) => s.setSteps);
|
||||
@@ -230,6 +247,7 @@ export function DesignerRoot({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false); // Track when everything is loaded
|
||||
|
||||
const [lastSavedAt, setLastSavedAt] = useState<Date | undefined>(undefined);
|
||||
const [inspectorTab, setInspectorTab] = useState<
|
||||
@@ -250,6 +268,13 @@ export function DesignerRoot({
|
||||
useEffect(() => {
|
||||
if (initialized) return;
|
||||
if (loadingExperiment && !initialDesign) return;
|
||||
|
||||
console.log('[DesignerRoot] 🚀 INITIALIZING', {
|
||||
hasExperiment: !!experiment,
|
||||
hasInitialDesign: !!initialDesign,
|
||||
loadingExperiment,
|
||||
});
|
||||
|
||||
const adapted =
|
||||
initialDesign ??
|
||||
(experiment
|
||||
@@ -274,8 +299,9 @@ export function DesignerRoot({
|
||||
setValidatedHash(ih);
|
||||
}
|
||||
setInitialized(true);
|
||||
// Kick initial hash
|
||||
void recomputeHash();
|
||||
// NOTE: We don't call recomputeHash() here because the automatic
|
||||
// hash recomputation useEffect will trigger when setSteps() updates the steps array
|
||||
console.log('[DesignerRoot] 🚀 Initialization complete, steps set');
|
||||
}, [
|
||||
initialized,
|
||||
loadingExperiment,
|
||||
@@ -299,26 +325,69 @@ export function DesignerRoot({
|
||||
// Load plugin actions when study plugins available
|
||||
useEffect(() => {
|
||||
if (!experiment?.studyId) return;
|
||||
if (!studyPlugins || studyPlugins.length === 0) return;
|
||||
actionRegistry.loadPluginActions(
|
||||
experiment.studyId,
|
||||
studyPlugins.map((sp) => ({
|
||||
plugin: {
|
||||
id: sp.plugin.id,
|
||||
robotId: sp.plugin.robotId,
|
||||
version: sp.plugin.version,
|
||||
actionDefinitions: Array.isArray(sp.plugin.actionDefinitions)
|
||||
? sp.plugin.actionDefinitions
|
||||
: undefined,
|
||||
},
|
||||
})),
|
||||
);
|
||||
}, [experiment?.studyId, studyPlugins]);
|
||||
if (!studyPluginsRaw) return;
|
||||
// @ts-expect-error - studyPluginsRaw type from tRPC is compatible but TypeScript can't infer it
|
||||
actionRegistry.loadPluginActions(experiment.studyId, studyPluginsRaw);
|
||||
}, [experiment?.studyId, studyPluginsRaw]);
|
||||
|
||||
/* ------------------------- Ready State Management ------------------------ */
|
||||
// Mark as ready once initialized and plugins are loaded
|
||||
useEffect(() => {
|
||||
if (!initialized || isReady) return;
|
||||
|
||||
// Check if plugins are loaded by verifying the action registry has plugin actions
|
||||
const debugInfo = actionRegistry.getDebugInfo();
|
||||
const hasPlugins = debugInfo.pluginActionsLoaded;
|
||||
|
||||
if (hasPlugins) {
|
||||
// Small delay to ensure all components have rendered
|
||||
const timer = setTimeout(() => {
|
||||
setIsReady(true);
|
||||
console.log('[DesignerRoot] ✅ Designer ready (plugins loaded), fading in');
|
||||
}, 150);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [initialized, isReady, studyPluginsRaw]);
|
||||
|
||||
/* ----------------------- Automatic Hash Recomputation -------------------- */
|
||||
// Automatically recompute hash when steps change (debounced to avoid excessive computation)
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
|
||||
console.log('[DesignerRoot] Steps changed, scheduling hash recomputation', {
|
||||
stepsCount: steps.length,
|
||||
actionsCount: steps.reduce((sum, s) => sum + s.actions.length, 0),
|
||||
});
|
||||
|
||||
const timeoutId = setTimeout(async () => {
|
||||
console.log('[DesignerRoot] Executing debounced hash recomputation');
|
||||
const result = await recomputeHash();
|
||||
if (result) {
|
||||
console.log('[DesignerRoot] Hash recomputed:', {
|
||||
newHash: result.designHash.slice(0, 16),
|
||||
fullHash: result.designHash,
|
||||
});
|
||||
}
|
||||
}, 300); // Debounce 300ms
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [steps, initialized, recomputeHash]);
|
||||
|
||||
|
||||
/* ----------------------------- Derived State ----------------------------- */
|
||||
const hasUnsavedChanges =
|
||||
!!currentDesignHash && lastPersistedHash !== currentDesignHash;
|
||||
|
||||
// Debug logging to track hash updates and save button state
|
||||
useEffect(() => {
|
||||
console.log('[DesignerRoot] Hash State:', {
|
||||
currentDesignHash: currentDesignHash?.slice(0, 10),
|
||||
lastPersistedHash: lastPersistedHash?.slice(0, 10),
|
||||
hasUnsavedChanges,
|
||||
stepsCount: steps.length,
|
||||
});
|
||||
}, [currentDesignHash, lastPersistedHash, hasUnsavedChanges, steps.length]);
|
||||
|
||||
/* ------------------------------- Step Ops -------------------------------- */
|
||||
const createNewStep = useCallback(() => {
|
||||
const newStep: ExperimentStep = {
|
||||
@@ -386,8 +455,7 @@ export function DesignerRoot({
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Validation error: ${
|
||||
err instanceof Error ? err.message : "Unknown error"
|
||||
`Validation error: ${err instanceof Error ? err.message : "Unknown error"
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
@@ -404,6 +472,14 @@ export function DesignerRoot({
|
||||
/* --------------------------------- Save ---------------------------------- */
|
||||
const persist = useCallback(async () => {
|
||||
if (!initialized) return;
|
||||
|
||||
console.log('[DesignerRoot] 💾 SAVE initiated', {
|
||||
stepsCount: steps.length,
|
||||
actionsCount: steps.reduce((sum, s) => sum + s.actions.length, 0),
|
||||
currentHash: currentDesignHash?.slice(0, 16),
|
||||
lastPersistedHash: lastPersistedHash?.slice(0, 16),
|
||||
});
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const visualDesign = {
|
||||
@@ -411,15 +487,43 @@ export function DesignerRoot({
|
||||
version: designMeta.version,
|
||||
lastSaved: new Date().toISOString(),
|
||||
};
|
||||
updateExperiment.mutate({
|
||||
|
||||
console.log('[DesignerRoot] 💾 Sending to server...', {
|
||||
experimentId,
|
||||
stepsCount: steps.length,
|
||||
version: designMeta.version,
|
||||
});
|
||||
|
||||
// Wait for mutation to complete
|
||||
await updateExperiment.mutateAsync({
|
||||
id: experimentId,
|
||||
visualDesign,
|
||||
createSteps: true,
|
||||
compileExecution: autoCompile,
|
||||
});
|
||||
// Optimistic hash recompute
|
||||
await recomputeHash();
|
||||
|
||||
console.log('[DesignerRoot] 💾 Server save successful');
|
||||
|
||||
// NOTE: We do NOT refetch here because it would reset the local steps state
|
||||
// to the server state, which would cause the hash to match the persisted hash,
|
||||
// preventing the save button from re-enabling on subsequent changes.
|
||||
// The local state is already the source of truth after a successful save.
|
||||
|
||||
// Recompute hash and update persisted hash
|
||||
const hashResult = await recomputeHash();
|
||||
if (hashResult?.designHash) {
|
||||
console.log('[DesignerRoot] 💾 Updated persisted hash:', {
|
||||
newPersistedHash: hashResult.designHash.slice(0, 16),
|
||||
fullHash: hashResult.designHash,
|
||||
});
|
||||
setPersistedHash(hashResult.designHash);
|
||||
}
|
||||
|
||||
setLastSavedAt(new Date());
|
||||
toast.success("Experiment saved");
|
||||
|
||||
console.log('[DesignerRoot] 💾 SAVE complete');
|
||||
|
||||
onPersist?.({
|
||||
id: experimentId,
|
||||
name: designMeta.name,
|
||||
@@ -428,16 +532,22 @@ export function DesignerRoot({
|
||||
version: designMeta.version,
|
||||
lastSaved: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[DesignerRoot] 💾 SAVE failed:', error);
|
||||
// Error already handled by mutation onError
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
initialized,
|
||||
steps,
|
||||
designMeta,
|
||||
experimentId,
|
||||
updateExperiment,
|
||||
recomputeHash,
|
||||
currentDesignHash,
|
||||
setPersistedHash,
|
||||
refetchExperiment,
|
||||
onPersist,
|
||||
autoCompile,
|
||||
]);
|
||||
@@ -479,8 +589,7 @@ export function DesignerRoot({
|
||||
toast.success("Exported design bundle");
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Export failed: ${
|
||||
err instanceof Error ? err.message : "Unknown error"
|
||||
`Export failed: ${err instanceof Error ? err.message : "Unknown error"
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
@@ -489,10 +598,11 @@ export function DesignerRoot({
|
||||
}, [currentDesignHash, steps, experimentId, designMeta, experiment]);
|
||||
|
||||
/* ---------------------------- Incremental Hash --------------------------- */
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
void recomputeHash();
|
||||
}, [steps.length, initialized, recomputeHash]);
|
||||
// Serialize steps for stable comparison
|
||||
const stepsHash = useMemo(() => JSON.stringify(steps), [steps]);
|
||||
|
||||
// Intentionally removed redundant recomputeHash useEffect that was causing excessive refreshes
|
||||
// The debounced useEffect (lines 352-372) handles this correctly.
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedStepId || selectedActionId) {
|
||||
@@ -517,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(() => {
|
||||
@@ -576,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;
|
||||
@@ -622,51 +844,82 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const execution: ExperimentAction["execution"] =
|
||||
actionDef.execution &&
|
||||
(actionDef.execution.transport === "internal" ||
|
||||
actionDef.execution.transport === "rest" ||
|
||||
actionDef.execution.transport === "ros2")
|
||||
(actionDef.execution.transport === "internal" ||
|
||||
actionDef.execution.transport === "rest" ||
|
||||
actionDef.execution.transport === "ros2")
|
||||
? {
|
||||
transport: actionDef.execution.transport,
|
||||
retryable: actionDef.execution.retryable ?? false,
|
||||
}
|
||||
: {
|
||||
transport: "internal",
|
||||
retryable: false,
|
||||
};
|
||||
transport: actionDef.execution.transport,
|
||||
retryable: actionDef.execution.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"],
|
||||
parameters: {},
|
||||
source: actionDef.source as ExperimentAction["source"],
|
||||
category: actionDef.category as any,
|
||||
description: "",
|
||||
parameters: defaultParams,
|
||||
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) {
|
||||
@@ -677,80 +930,105 @@ export function DesignerRoot({
|
||||
);
|
||||
}
|
||||
|
||||
const actions = (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => validateDesign()}
|
||||
disabled={isValidating}
|
||||
>
|
||||
Validate
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => persist()}
|
||||
disabled={!hasUnsavedChanges || isSaving}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<PageHeader
|
||||
title={designMeta.name}
|
||||
description="Compose ordered steps with provenance-aware actions."
|
||||
description={designMeta.description || "No description"}
|
||||
icon={Play}
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => validateDesign()}
|
||||
disabled={isValidating}
|
||||
>
|
||||
Validate
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => persist()}
|
||||
disabled={!hasUnsavedChanges || isSaving}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
actions={actions}
|
||||
className="pb-6"
|
||||
/>
|
||||
|
||||
<div className="flex h-[calc(100vh-12rem)] w-full max-w-full flex-col overflow-hidden rounded-md border">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={pointerWithin}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={() => toggleLibraryScrollLock(false)}
|
||||
<div className="relative flex flex-1 flex-col overflow-hidden">
|
||||
{/* Loading Overlay */}
|
||||
{!isReady && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground text-sm">Loading designer...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content - Fade in when ready */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 flex-col overflow-hidden transition-opacity duration-500",
|
||||
isReady ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<DragOverlay>
|
||||
{dragOverlayAction ? (
|
||||
<div className="bg-background pointer-events-none rounded border px-2 py-1 text-xs shadow-lg select-none">
|
||||
{dragOverlayAction.name}
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<div className="flex-shrink-0 border-t">
|
||||
<BottomStatusBar
|
||||
onSave={() => persist()}
|
||||
onValidate={() => validateDesign()}
|
||||
onExport={() => handleExport()}
|
||||
lastSavedAt={lastSavedAt}
|
||||
saving={isSaving}
|
||||
validating={isValidating}
|
||||
exporting={isExporting}
|
||||
/>
|
||||
<div className="flex h-[calc(100vh-12rem)] w-full max-w-full flex-col overflow-hidden rounded-md border">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={() => toggleLibraryScrollLock(false)}
|
||||
>
|
||||
<PanelsContainer
|
||||
showDividers
|
||||
className="min-h-0 flex-1"
|
||||
left={leftPanel}
|
||||
center={centerPanel}
|
||||
right={rightPanel}
|
||||
/>
|
||||
<DragOverlay>
|
||||
{dragOverlayAction ? (
|
||||
<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}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<div className="flex-shrink-0 border-t">
|
||||
<BottomStatusBar
|
||||
onSave={() => persist()}
|
||||
onValidate={() => validateDesign()}
|
||||
onExport={() => handleExport()}
|
||||
onRecalculateHash={() => recomputeHash()}
|
||||
lastSavedAt={lastSavedAt}
|
||||
saving={isSaving}
|
||||
validating={isValidating}
|
||||
exporting={isExporting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
471
src/components/experiments/designer/PropertiesPanel.tsx
Normal file → Executable file
471
src/components/experiments/designer/PropertiesPanel.tsx
Normal file → Executable file
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
@@ -70,7 +70,7 @@ export interface PropertiesPanelProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PropertiesPanel({
|
||||
export function PropertiesPanelBase({
|
||||
design,
|
||||
selectedStep,
|
||||
selectedAction,
|
||||
@@ -80,6 +80,85 @@ export function PropertiesPanel({
|
||||
}: PropertiesPanelProps) {
|
||||
const registry = actionRegistry;
|
||||
|
||||
// Local state for controlled inputs
|
||||
const [localActionName, setLocalActionName] = useState("");
|
||||
const [localStepName, setLocalStepName] = useState("");
|
||||
const [localStepDescription, setLocalStepDescription] = useState("");
|
||||
const [localParams, setLocalParams] = useState<Record<string, unknown>>({});
|
||||
|
||||
// Debounce timers
|
||||
const actionUpdateTimer = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
const stepUpdateTimer = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
const paramUpdateTimers = useRef(new Map<string, NodeJS.Timeout>());
|
||||
|
||||
// Sync local state when selection ID changes (not on every object recreation)
|
||||
useEffect(() => {
|
||||
if (selectedAction) {
|
||||
setLocalActionName(selectedAction.name);
|
||||
setLocalParams(selectedAction.parameters);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedAction?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedStep) {
|
||||
setLocalStepName(selectedStep.name);
|
||||
setLocalStepDescription(selectedStep.description ?? "");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedStep?.id]);
|
||||
|
||||
// Cleanup timers on unmount
|
||||
useEffect(() => {
|
||||
const timersMap = paramUpdateTimers.current;
|
||||
return () => {
|
||||
if (actionUpdateTimer.current) clearTimeout(actionUpdateTimer.current);
|
||||
if (stepUpdateTimer.current) clearTimeout(stepUpdateTimer.current);
|
||||
timersMap.forEach((timer) => clearTimeout(timer));
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Debounced update handlers
|
||||
const debouncedActionUpdate = useCallback(
|
||||
(stepId: string, actionId: string, updates: Partial<ExperimentAction>) => {
|
||||
if (actionUpdateTimer.current) clearTimeout(actionUpdateTimer.current);
|
||||
actionUpdateTimer.current = setTimeout(() => {
|
||||
onActionUpdate(stepId, actionId, updates);
|
||||
}, 300);
|
||||
},
|
||||
[onActionUpdate],
|
||||
);
|
||||
|
||||
const debouncedStepUpdate = useCallback(
|
||||
(stepId: string, updates: Partial<ExperimentStep>) => {
|
||||
if (stepUpdateTimer.current) clearTimeout(stepUpdateTimer.current);
|
||||
stepUpdateTimer.current = setTimeout(() => {
|
||||
onStepUpdate(stepId, updates);
|
||||
}, 300);
|
||||
},
|
||||
[onStepUpdate],
|
||||
);
|
||||
|
||||
const debouncedParamUpdate = useCallback(
|
||||
(stepId: string, actionId: string, paramId: string, value: unknown) => {
|
||||
const existing = paramUpdateTimers.current.get(paramId);
|
||||
if (existing) clearTimeout(existing);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
onActionUpdate(stepId, actionId, {
|
||||
parameters: {
|
||||
...selectedAction?.parameters,
|
||||
[paramId]: value,
|
||||
},
|
||||
});
|
||||
paramUpdateTimers.current.delete(paramId);
|
||||
}, 300);
|
||||
|
||||
paramUpdateTimers.current.set(paramId, timer);
|
||||
},
|
||||
[onActionUpdate, selectedAction?.parameters],
|
||||
);
|
||||
|
||||
// Find containing step for selected action (if any)
|
||||
const containingStep =
|
||||
selectedAction &&
|
||||
@@ -119,8 +198,8 @@ export function PropertiesPanel({
|
||||
const ResolvedIcon: React.ComponentType<{ className?: string }> =
|
||||
def?.icon && iconComponents[def.icon]
|
||||
? (iconComponents[def.icon] as React.ComponentType<{
|
||||
className?: string;
|
||||
}>)
|
||||
className?: string;
|
||||
}>)
|
||||
: Zap;
|
||||
|
||||
return (
|
||||
@@ -176,12 +255,21 @@ export function PropertiesPanel({
|
||||
<div>
|
||||
<Label className="text-xs">Display Name</Label>
|
||||
<Input
|
||||
value={selectedAction.name}
|
||||
onChange={(e) =>
|
||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||
name: e.target.value,
|
||||
})
|
||||
}
|
||||
value={localActionName}
|
||||
onChange={(e) => {
|
||||
const newName = e.target.value;
|
||||
setLocalActionName(newName);
|
||||
debouncedActionUpdate(containingStep.id, selectedAction.id, {
|
||||
name: newName,
|
||||
});
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (localActionName !== selectedAction.name) {
|
||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||
name: localActionName,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="mt-1 h-7 w-full text-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -194,148 +282,22 @@ export function PropertiesPanel({
|
||||
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) => {
|
||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||
parameters: {
|
||||
...selectedAction.parameters,
|
||||
[param.id]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/* ---- Control Rendering ---- */
|
||||
let control: React.ReactNode = null;
|
||||
|
||||
if (param.type === "text") {
|
||||
control = (
|
||||
<Input
|
||||
value={(rawValue as string) ?? ""}
|
||||
placeholder={param.placeholder}
|
||||
onChange={(e) => updateParamValue(e.target.value)}
|
||||
className="mt-1 h-7 w-full text-xs"
|
||||
/>
|
||||
);
|
||||
} else if (param.type === "select") {
|
||||
control = (
|
||||
<Select
|
||||
value={(rawValue as string) ?? ""}
|
||||
onValueChange={(val) => updateParamValue(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") {
|
||||
control = (
|
||||
<div className="mt-1 flex h-7 items-center">
|
||||
<Switch
|
||||
checked={Boolean(rawValue)}
|
||||
onCheckedChange={(val) => updateParamValue(val)}
|
||||
aria-label={param.name}
|
||||
/>
|
||||
<span className="text-muted-foreground ml-2 text-[11px]">
|
||||
{Boolean(rawValue) ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
} else if (param.type === "number") {
|
||||
const numericVal =
|
||||
typeof rawValue === "number"
|
||||
? rawValue
|
||||
: 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[]) =>
|
||||
updateParamValue(vals[0])
|
||||
}
|
||||
/>
|
||||
<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)
|
||||
}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
{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]: val,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onCommit={() => { }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -373,23 +335,41 @@ export function PropertiesPanel({
|
||||
<div>
|
||||
<Label className="text-xs">Name</Label>
|
||||
<Input
|
||||
value={selectedStep.name}
|
||||
onChange={(e) =>
|
||||
onStepUpdate(selectedStep.id, { name: e.target.value })
|
||||
}
|
||||
value={localStepName}
|
||||
onChange={(e) => {
|
||||
const newName = e.target.value;
|
||||
setLocalStepName(newName);
|
||||
debouncedStepUpdate(selectedStep.id, { name: newName });
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (localStepName !== selectedStep.name) {
|
||||
onStepUpdate(selectedStep.id, { name: localStepName });
|
||||
}
|
||||
}}
|
||||
className="mt-1 h-7 w-full text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Description</Label>
|
||||
<Input
|
||||
value={selectedStep.description ?? ""}
|
||||
value={localStepDescription}
|
||||
placeholder="Optional step description"
|
||||
onChange={(e) =>
|
||||
onStepUpdate(selectedStep.id, {
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
onChange={(e) => {
|
||||
const newDesc = e.target.value;
|
||||
setLocalStepDescription(newDesc);
|
||||
debouncedStepUpdate(selectedStep.id, {
|
||||
description: newDesc,
|
||||
});
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (
|
||||
localStepDescription !== (selectedStep.description ?? "")
|
||||
) {
|
||||
onStepUpdate(selectedStep.id, {
|
||||
description: localStepDescription,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="mt-1 h-7 w-full text-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -405,9 +385,9 @@ export function PropertiesPanel({
|
||||
<Label className="text-xs">Type</Label>
|
||||
<Select
|
||||
value={selectedStep.type}
|
||||
onValueChange={(val) =>
|
||||
onStepUpdate(selectedStep.id, { type: val as StepType })
|
||||
}
|
||||
onValueChange={(val) => {
|
||||
onStepUpdate(selectedStep.id, { type: val as StepType });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-7 w-full text-xs">
|
||||
<SelectValue />
|
||||
@@ -424,14 +404,14 @@ export function PropertiesPanel({
|
||||
<Label className="text-xs">Trigger</Label>
|
||||
<Select
|
||||
value={selectedStep.trigger.type}
|
||||
onValueChange={(val) =>
|
||||
onValueChange={(val) => {
|
||||
onStepUpdate(selectedStep.id, {
|
||||
trigger: {
|
||||
...selectedStep.trigger,
|
||||
type: val as TriggerType,
|
||||
},
|
||||
})
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-7 w-full text-xs">
|
||||
<SelectValue />
|
||||
@@ -470,3 +450,158 @@ export function PropertiesPanel({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
0
src/components/experiments/designer/StepPreview.tsx
Normal file → Executable file
0
src/components/experiments/designer/StepPreview.tsx
Normal file → Executable file
0
src/components/experiments/designer/ValidationPanel.tsx
Normal file → Executable file
0
src/components/experiments/designer/ValidationPanel.tsx
Normal file → Executable file
711
src/components/experiments/designer/flow/FlowWorkspace.tsx
Normal file → Executable file
711
src/components/experiments/designer/flow/FlowWorkspace.tsx
Normal file → Executable 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 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -111,7 +338,7 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 rounded-md transition-colors",
|
||||
isOver &&
|
||||
"bg-blue-50/40 ring-2 ring-blue-400/60 ring-offset-0 dark:bg-blue-950/20",
|
||||
"bg-blue-50/40 ring-2 ring-blue-400/60 ring-offset-0 dark:bg-blue-950/20",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
@@ -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}
|
||||
@@ -182,11 +502,11 @@ function SortableActionChip({
|
||||
"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]
|
||||
wizard: "bg-blue-500",
|
||||
robot: "bg-emerald-500",
|
||||
control: "bg-amber-500",
|
||||
observation: "bg-purple-500",
|
||||
}[def.category]
|
||||
: "bg-slate-400",
|
||||
)}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -777,4 +1014,6 @@ export function FlowWorkspace({
|
||||
);
|
||||
}
|
||||
|
||||
export default FlowWorkspace;
|
||||
// Wrap in React.memo to prevent unnecessary re-renders causing flashing
|
||||
export default React.memo(FlowWorkspace);
|
||||
|
||||
|
||||
29
src/components/experiments/designer/layout/BottomStatusBar.tsx
Normal file → Executable file
29
src/components/experiments/designer/layout/BottomStatusBar.tsx
Normal file → Executable file
@@ -40,7 +40,7 @@ export interface BottomStatusBarProps {
|
||||
onValidate?: () => void;
|
||||
onExport?: () => void;
|
||||
onOpenCommandPalette?: () => void;
|
||||
onToggleVersionStrategy?: () => void;
|
||||
onRecalculateHash?: () => void;
|
||||
className?: string;
|
||||
saving?: boolean;
|
||||
validating?: boolean;
|
||||
@@ -56,7 +56,7 @@ export function BottomStatusBar({
|
||||
onValidate,
|
||||
onExport,
|
||||
onOpenCommandPalette,
|
||||
onToggleVersionStrategy,
|
||||
onRecalculateHash,
|
||||
className,
|
||||
saving,
|
||||
validating,
|
||||
@@ -198,9 +198,9 @@ export function BottomStatusBar({
|
||||
if (onOpenCommandPalette) onOpenCommandPalette();
|
||||
}, [onOpenCommandPalette]);
|
||||
|
||||
const handleToggleVersionStrategy = useCallback(() => {
|
||||
if (onToggleVersionStrategy) onToggleVersionStrategy();
|
||||
}, [onToggleVersionStrategy]);
|
||||
const handleRecalculateHash = useCallback(() => {
|
||||
if (onRecalculateHash) onRecalculateHash();
|
||||
}, [onRecalculateHash]);
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Render */
|
||||
@@ -265,12 +265,21 @@ export function BottomStatusBar({
|
||||
{autoSaveEnabled ? "auto-save on" : "auto-save off"}
|
||||
</div>
|
||||
<div
|
||||
className="hidden cursor-pointer items-center gap-1 sm:flex"
|
||||
title={`Version strategy: ${versionStrategy}`}
|
||||
onClick={handleToggleVersionStrategy}
|
||||
className="hidden items-center gap-1 font-mono text-[10px] sm:flex"
|
||||
title={`Current design hash: ${currentDesignHash ?? 'Not computed'}`}
|
||||
>
|
||||
<Wand2 className="h-3 w-3" />
|
||||
{versionStrategy.replace(/_/g, " ")}
|
||||
<Hash className="h-3 w-3" />
|
||||
{currentDesignHash?.slice(0, 16) ?? '—'}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1 ml-1"
|
||||
onClick={handleRecalculateHash}
|
||||
aria-label="Recalculate hash"
|
||||
title="Recalculate hash"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground/80 hidden items-center gap-1 text-[10px] font-normal tracking-wide md:flex"
|
||||
|
||||
84
src/components/experiments/designer/layout/PanelsContainer.tsx
Normal file → Executable file
84
src/components/experiments/designer/layout/PanelsContainer.tsx
Normal file → Executable 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 && (
|
||||
|
||||
14
src/components/experiments/designer/panels/ActionLibraryPanel.tsx
Normal file → Executable file
14
src/components/experiments/designer/panels/ActionLibraryPanel.tsx
Normal file → Executable file
@@ -88,8 +88,8 @@ function DraggableAction({
|
||||
|
||||
const style: React.CSSProperties = transform
|
||||
? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
}
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
}
|
||||
: {};
|
||||
|
||||
const IconComponent = iconMap[action.icon] ?? Sparkles;
|
||||
@@ -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;
|
||||
@@ -487,4 +485,6 @@ export function ActionLibraryPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
export default ActionLibraryPanel;
|
||||
// Wrap in React.memo to prevent unnecessary re-renders causing flashing in categories
|
||||
export default React.memo(ActionLibraryPanel);
|
||||
|
||||
|
||||
32
src/components/experiments/designer/panels/InspectorPanel.tsx
Normal file → Executable file
32
src/components/experiments/designer/panels/InspectorPanel.tsx
Normal file → Executable file
@@ -48,9 +48,18 @@ export interface InspectorPanelProps {
|
||||
*/
|
||||
onTabChange?: (tab: "properties" | "issues" | "dependencies") => void;
|
||||
/**
|
||||
* Whether to auto-switch to properties tab when selection changes.
|
||||
* If true, auto-switch to "properties" when a selection occurs.
|
||||
*/
|
||||
autoFocusOnSelection?: boolean;
|
||||
/**
|
||||
* Study plugins with name and metadata
|
||||
*/
|
||||
studyPlugins?: Array<{
|
||||
id: string;
|
||||
robotId: string;
|
||||
name: string;
|
||||
version: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function InspectorPanel({
|
||||
@@ -58,6 +67,7 @@ export function InspectorPanel({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
autoFocusOnSelection = true,
|
||||
studyPlugins,
|
||||
}: InspectorPanelProps) {
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Store Selectors */
|
||||
@@ -274,14 +284,17 @@ export function InspectorPanel({
|
||||
<div className="flex-1 overflow-x-hidden overflow-y-auto">
|
||||
<div className="w-full px-0 py-2 break-words whitespace-normal">
|
||||
<PropertiesPanel
|
||||
design={{
|
||||
id: "design",
|
||||
name: "Design",
|
||||
description: "",
|
||||
version: 1,
|
||||
steps,
|
||||
lastSaved: new Date(),
|
||||
}}
|
||||
design={useMemo(
|
||||
() => ({
|
||||
id: "design",
|
||||
name: "Design",
|
||||
description: "",
|
||||
version: 1,
|
||||
steps,
|
||||
lastSaved: new Date(),
|
||||
}),
|
||||
[steps],
|
||||
)}
|
||||
selectedStep={selectedStep}
|
||||
selectedAction={selectedAction}
|
||||
onActionUpdate={handleActionUpdate}
|
||||
@@ -339,6 +352,7 @@ export function InspectorPanel({
|
||||
steps={steps}
|
||||
actionSignatureDrift={actionSignatureDrift}
|
||||
actionDefinitions={actionRegistry.getAllActions()}
|
||||
studyPlugins={studyPlugins}
|
||||
onReconcileAction={(actionId) => {
|
||||
// Placeholder: future diff modal / signature update
|
||||
|
||||
|
||||
41
src/components/experiments/designer/state/hashing.ts
Normal file → Executable file
41
src/components/experiments/designer/state/hashing.ts
Normal file → Executable file
@@ -130,7 +130,7 @@ export interface DesignHashOptions {
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<DesignHashOptions> = {
|
||||
includeParameterValues: false,
|
||||
includeParameterValues: true, // Changed to true so parameter changes trigger hash updates
|
||||
includeActionNames: true,
|
||||
includeStepNames: true,
|
||||
};
|
||||
@@ -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) {
|
||||
@@ -175,16 +176,16 @@ function projectExecutionDescriptor(
|
||||
timeoutMs: exec.timeoutMs ?? null,
|
||||
ros2: exec.ros2
|
||||
? {
|
||||
topic: exec.ros2.topic ?? null,
|
||||
service: exec.ros2.service ?? null,
|
||||
action: exec.ros2.action ?? null,
|
||||
}
|
||||
topic: exec.ros2.topic ?? null,
|
||||
service: exec.ros2.service ?? null,
|
||||
action: exec.ros2.action ?? null,
|
||||
}
|
||||
: null,
|
||||
rest: exec.rest
|
||||
? {
|
||||
method: exec.rest.method,
|
||||
path: exec.rest.path,
|
||||
}
|
||||
method: exec.rest.method,
|
||||
path: exec.rest.path,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -244,10 +245,10 @@ export async function computeActionSignature(
|
||||
baseActionId: def.baseActionId ?? null,
|
||||
execution: def.execution
|
||||
? {
|
||||
transport: def.execution.transport,
|
||||
retryable: def.execution.retryable ?? false,
|
||||
timeoutMs: def.execution.timeoutMs ?? null,
|
||||
}
|
||||
transport: def.execution.transport,
|
||||
retryable: def.execution.retryable ?? false,
|
||||
timeoutMs: def.execution.timeoutMs ?? null,
|
||||
}
|
||||
: null,
|
||||
schema: def.parameterSchemaRaw ? canonicalize(def.parameterSchemaRaw) : null,
|
||||
};
|
||||
@@ -301,7 +302,12 @@ export async function computeIncrementalDesignHash(
|
||||
// First compute per-action hashes
|
||||
for (const step of steps) {
|
||||
for (const action of step.actions) {
|
||||
const existing = previous?.actionHashes.get(action.id);
|
||||
// Only reuse cached hash if we're NOT including parameter values
|
||||
// (because parameter values can change without changing the action ID)
|
||||
const existing = !options.includeParameterValues
|
||||
? previous?.actionHashes.get(action.id)
|
||||
: undefined;
|
||||
|
||||
if (existing) {
|
||||
// Simple heuristic: if shallow structural keys unchanged, reuse
|
||||
// (We still project to confirm minimal structure; deeper diff omitted for performance.)
|
||||
@@ -316,7 +322,12 @@ export async function computeIncrementalDesignHash(
|
||||
|
||||
// Then compute step hashes (including ordered list of action hashes)
|
||||
for (const step of steps) {
|
||||
const existing = previous?.stepHashes.get(step.id);
|
||||
// Only reuse cached hash if we're NOT including parameter values
|
||||
// (because parameter values in actions can change without changing the step ID)
|
||||
const existing = !options.includeParameterValues
|
||||
? previous?.stepHashes.get(step.id)
|
||||
: undefined;
|
||||
|
||||
if (existing) {
|
||||
stepHashes.set(step.id, existing);
|
||||
continue;
|
||||
|
||||
164
src/components/experiments/designer/state/store.ts
Normal file → Executable file
164
src/components/experiments/designer/state/store.ts
Normal file → Executable 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) => ({
|
||||
|
||||
6
src/components/experiments/designer/state/validators.ts
Normal file → Executable file
6
src/components/experiments/designer/state/validators.ts
Normal file → Executable 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 with trial_start trigger may cause execution conflicts",
|
||||
"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",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
8
src/components/experiments/experiments-columns.tsx
Normal file → Executable file
8
src/components/experiments/experiments-columns.tsx
Normal file → Executable file
@@ -114,14 +114,14 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/experiments/${experiment.id}`}>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/experiments/${experiment.id}/designer`}>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
|
||||
<FlaskConical className="mr-2 h-4 w-4" />
|
||||
Open Designer
|
||||
</Link>
|
||||
@@ -129,7 +129,7 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
|
||||
|
||||
{experiment.canEdit && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/experiments/${experiment.id}/edit`}>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Experiment
|
||||
</Link>
|
||||
@@ -202,7 +202,7 @@ export const experimentsColumns: ColumnDef<Experiment>[] = [
|
||||
return (
|
||||
<div className="max-w-[200px] min-w-0 space-y-1">
|
||||
<Link
|
||||
href={`/experiments/${experiment.id}`}
|
||||
href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}
|
||||
className="block truncate font-medium hover:underline"
|
||||
title={experiment.name}
|
||||
>
|
||||
|
||||
0
src/components/experiments/experiments-data-table.tsx
Normal file → Executable file
0
src/components/experiments/experiments-data-table.tsx
Normal file → Executable file
Reference in New Issue
Block a user