feat: Redesign Landing, Auth, and Dashboard Pages

Also fixed schema type exports and seed script errors.
This commit is contained in:
2026-02-01 22:28:19 -05:00
parent 816b2b9e31
commit dbfdd91dea
300 changed files with 17239 additions and 5952 deletions

56
src/components/experiments/ExperimentForm.tsx Normal file → Executable file
View 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
View 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
View 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
View File

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

View File

@@ -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
View 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
View 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
View File

View File

View File

@@ -12,6 +12,7 @@ import {
useDndMonitor,
type DragEndEvent,
type DragStartEvent,
type DragOverEvent,
} from "@dnd-kit/core";
import {
useSortable,
@@ -68,7 +69,7 @@ interface FlowWorkspaceProps {
onActionCreate?: (stepId: string, action: ExperimentAction) => void;
}
interface VirtualItem {
export interface VirtualItem {
index: number;
top: number;
height: number;
@@ -77,6 +78,232 @@ interface VirtualItem {
visible: boolean;
}
interface StepRowProps {
item: VirtualItem;
selectedStepId: string | null | undefined;
selectedActionId: string | null | undefined;
renamingStepId: string | null;
onSelectStep: (id: string | undefined) => void;
onSelectAction: (stepId: string, actionId: string | undefined) => void;
onToggleExpanded: (step: ExperimentStep) => void;
onRenameStep: (step: ExperimentStep, name: string) => void;
onDeleteStep: (step: ExperimentStep) => void;
onDeleteAction: (stepId: string, actionId: string) => void;
setRenamingStepId: (id: string | null) => void;
registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void;
}
const StepRow = React.memo(function StepRow({
item,
selectedStepId,
selectedActionId,
renamingStepId,
onSelectStep,
onSelectAction,
onToggleExpanded,
onRenameStep,
onDeleteStep,
onDeleteAction,
setRenamingStepId,
registerMeasureRef,
}: StepRowProps) {
const step = item.step;
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
const displayActions = useMemo(() => {
if (
insertionProjection?.stepId === step.id &&
insertionProjection.parentId === null
) {
const copy = [...step.actions];
// Insert placeholder action
// Ensure specific ID doesn't crash keys if collision (collision unlikely for library items)
// Actually, standard array key is action.id.
copy.splice(insertionProjection.index, 0, insertionProjection.action);
return copy;
}
return step.actions;
}, [step.actions, step.id, insertionProjection]);
const {
setNodeRef,
transform,
transition,
attributes,
listeners,
isDragging,
} = useSortable({
id: sortableStepId(step.id),
data: {
type: "step",
step: step,
},
});
const style: React.CSSProperties = {
position: "absolute",
top: item.top,
left: 0,
right: 0,
width: "100%",
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 25 : undefined,
};
return (
<div ref={setNodeRef} style={style} data-step-id={step.id}>
<div
ref={(el) => registerMeasureRef(step.id, el)}
className="relative px-3 py-4"
data-step-id={step.id}
>
<StepDroppableArea stepId={step.id} />
<div
className={cn(
"mb-2 rounded border shadow-sm transition-colors",
selectedStepId === step.id
? "border-border bg-accent/30"
: "hover:bg-accent/30",
isDragging && "opacity-80 ring-1 ring-blue-300",
)}
>
<div
className="flex items-center justify-between gap-2 border-b px-2 py-1.5"
onClick={(e) => {
const tag = (e.target as HTMLElement).tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "button")
return;
onSelectStep(step.id);
onSelectAction(step.id, undefined);
}}
role="button"
tabIndex={0}
>
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleExpanded(step);
}}
className="text-muted-foreground hover:bg-accent/60 hover:text-foreground rounded p-1"
aria-label={step.expanded ? "Collapse step" : "Expand step"}
>
{step.expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] font-normal"
>
{step.order + 1}
</Badge>
{renamingStepId === step.id ? (
<Input
autoFocus
defaultValue={step.name}
className="h-7 w-40 text-xs"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter") {
onRenameStep(
step,
(e.target as HTMLInputElement).value.trim() ||
step.name,
);
setRenamingStepId(null);
} else if (e.key === "Escape") {
setRenamingStepId(null);
}
}}
onBlur={(e) => {
onRenameStep(step, e.target.value.trim() || step.name);
setRenamingStepId(null);
}}
/>
) : (
<div className="flex items-center gap-1">
<span className="text-sm font-medium">{step.name}</span>
<button
type="button"
className="text-muted-foreground hover:text-foreground p-1 opacity-0 group-hover:opacity-100"
aria-label="Rename step"
onClick={(e) => {
e.stopPropagation();
setRenamingStepId(step.id);
}}
>
<Edit3 className="h-3.5 w-3.5" />
</button>
</div>
)}
<span className="text-muted-foreground hidden text-[11px] md:inline">
{step.actions.length} actions
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-[11px] text-red-500 hover:text-red-600"
onClick={(e) => {
e.stopPropagation();
onDeleteStep(step);
}}
aria-label="Delete step"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<div
className="text-muted-foreground cursor-grab p-1"
aria-label="Drag step"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</div>
</div>
</div>
{/* Action List (Collapsible/Virtual content) */}
{step.expanded && (
<div className="bg-background/40 min-h-[3rem] space-y-2 p-2 pb-8">
<SortableContext
items={displayActions.map((a) => sortableActionId(a.id))}
strategy={verticalListSortingStrategy}
>
<div className="flex w-full flex-col gap-2">
{displayActions.length === 0 ? (
<div className="flex h-12 items-center justify-center rounded border border-dashed text-xs text-muted-foreground">
Drop actions here
</div>
) : (
displayActions.map((action) => (
<SortableActionChip
key={action.id}
stepId={step.id}
action={action}
parentId={null}
selectedActionId={selectedActionId}
onSelectAction={onSelectAction}
onDeleteAction={onDeleteAction}
/>
))
)}
</div>
</SortableContext>
</div>
)}
</div>
</div>
</div>
);
});
/* -------------------------------------------------------------------------- */
/* Utility */
/* -------------------------------------------------------------------------- */
@@ -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);

View 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"

View File

@@ -53,6 +53,30 @@ export interface PanelsContainerProps {
* - Resize handles are absolutely positioned over the grid at the left and right boundaries.
* - Fractions are clamped with configurable min/max so panels remain usable at all sizes.
*/
const Panel: React.FC<React.PropsWithChildren<{
className?: string;
panelClassName?: string;
contentClassName?: string;
}>> = ({
className: panelCls,
panelClassName,
contentClassName,
children,
}) => (
<section
className={cn("min-w-0 overflow-hidden", panelCls, panelClassName)}
>
<div
className={cn(
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto",
contentClassName,
)}
>
{children}
</div>
</section>
);
export function PanelsContainer({
left,
center,
@@ -209,10 +233,10 @@ export function PanelsContainer({
// CSS variables for the grid fractions
const styleVars: React.CSSProperties & Record<string, string> = hasCenter
? {
"--col-left": `${(hasLeft ? l : 0) * 100}%`,
"--col-center": `${c * 100}%`,
"--col-right": `${(hasRight ? r : 0) * 100}%`,
}
"--col-left": `${(hasLeft ? l : 0) * 100}%`,
"--col-center": `${c * 100}%`,
"--col-right": `${(hasRight ? r : 0) * 100}%`,
}
: {};
// Explicit grid template depending on which side panels exist
@@ -229,28 +253,12 @@ export function PanelsContainer({
const centerDividers =
showDividers && hasCenter
? cn({
"border-l": hasLeft,
"border-r": hasRight,
})
"border-l": hasLeft,
"border-r": hasRight,
})
: undefined;
const Panel: React.FC<React.PropsWithChildren<{ className?: string }>> = ({
className: panelCls,
children,
}) => (
<section
className={cn("min-w-0 overflow-hidden", panelCls, panelClassName)}
>
<div
className={cn(
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto",
contentClassName,
)}
>
{children}
</div>
</section>
);
return (
<div
@@ -263,11 +271,33 @@ export function PanelsContainer({
className,
)}
>
{hasLeft && <Panel>{left}</Panel>}
{hasLeft && (
<Panel
panelClassName={panelClassName}
contentClassName={contentClassName}
>
{left}
</Panel>
)}
{hasCenter && <Panel className={centerDividers}>{center}</Panel>}
{hasCenter && (
<Panel
className={centerDividers}
panelClassName={panelClassName}
contentClassName={contentClassName}
>
{center}
</Panel>
)}
{hasRight && <Panel>{right}</Panel>}
{hasRight && (
<Panel
panelClassName={panelClassName}
contentClassName={contentClassName}
>
{right}
</Panel>
)}
{/* Resize handles (only render where applicable) */}
{hasCenter && hasLeft && (

View File

@@ -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);

View 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
View 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
View File

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

View File

@@ -643,13 +643,13 @@ export function validateExecution(
if (trialStartSteps.length > 1) {
trialStartSteps.slice(1).forEach((step) => {
issues.push({
severity: "warning",
severity: "info",
message:
"Multiple steps 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
View 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
View File