Redesign experiment designer workspace and seed Bucknell data

- Overhauled designer UI: virtualized flow, slim action panel, improved
drag - Added Bucknell studies, users, and NAO plugin to seed-dev script
- Enhanced validation panel and inspector UX - Updated wizard-actions
plugin options formatting - Removed Minio from docker-compose for local
dev - Numerous UI and code quality improvements for experiment design
This commit is contained in:
2025-08-13 17:56:30 -04:00
parent 488674fca8
commit 550021a18e
17 changed files with 2430 additions and 766 deletions

View File

@@ -1,6 +1,12 @@
import { notFound } from "next/navigation";
import { DesignerRoot } from "~/components/experiments/designer/DesignerRoot";
import type { ExperimentStep } from "~/lib/experiment-designer/types";
import type {
ExperimentStep,
ExperimentAction,
StepType,
ActionCategory,
ExecutionDescriptor,
} from "~/lib/experiment-designer/types";
import { api } from "~/trpc/server";
interface ExperimentDesignerPageProps {
@@ -28,20 +34,209 @@ export default async function ExperimentDesignerPage({
} | null;
// Only pass initialDesign if there's existing visual design data
const initialDesign =
existingDesign?.steps && existingDesign.steps.length > 0
? {
id: experiment.id,
name: experiment.name,
description: experiment.description ?? "",
steps: existingDesign.steps as ExperimentStep[],
version: existingDesign.version ?? 1,
lastSaved:
typeof existingDesign.lastSaved === "string"
? new Date(existingDesign.lastSaved)
: new Date(),
let initialDesign:
| {
id: string;
name: string;
description: string;
steps: ExperimentStep[];
version: number;
lastSaved: Date;
}
| undefined;
if (existingDesign?.steps && existingDesign.steps.length > 0) {
initialDesign = {
id: experiment.id,
name: experiment.name,
description: experiment.description ?? "",
steps: existingDesign.steps as ExperimentStep[],
version: existingDesign.version ?? 1,
lastSaved:
typeof existingDesign.lastSaved === "string"
? new Date(existingDesign.lastSaved)
: new Date(),
};
} else {
// Fallback: hydrate from DB steps/actions if visualDesign is empty
const exec = await api.experiments.getExecutionData({
experimentId: experiment.id,
});
if (exec.steps.length > 0) {
type InstalledStudyPlugin = {
plugin: {
id: string;
name: string;
version: string | null;
actionDefinitions: Array<{ id: string }> | null;
};
};
const rawInstalledPluginsUnknown: unknown =
await api.robots.plugins.getStudyPlugins({
studyId: experiment.study.id,
});
function asRecord(v: unknown): Record<string, unknown> | null {
return v && typeof v === "object"
? (v as Record<string, unknown>)
: null;
}
function narrowActionDefs(v: unknown): Array<{ id: string }> | null {
if (!Array.isArray(v)) return null;
const out: Array<{ id: string }> = [];
for (const item of v) {
const rec = asRecord(item);
const id = rec && typeof rec.id === "string" ? rec.id : null;
if (id) out.push({ id });
}
: undefined;
return out.length ? out : null;
}
const installedPlugins: InstalledStudyPlugin[] = (
Array.isArray(rawInstalledPluginsUnknown)
? (rawInstalledPluginsUnknown as unknown[])
: []
).map((entry) => {
const rec = asRecord(entry);
const pluginRec = rec ? asRecord(rec.plugin) : null;
const id =
pluginRec && typeof pluginRec.id === "string" ? pluginRec.id : "";
const name =
pluginRec && typeof pluginRec.name === "string"
? pluginRec.name
: "";
const version =
pluginRec && typeof pluginRec.version === "string"
? pluginRec.version
: null;
const actionDefinitions = narrowActionDefs(
pluginRec ? pluginRec.actionDefinitions : undefined,
);
return {
plugin: { id, name, version, actionDefinitions },
};
});
const mapped: ExperimentStep[] = exec.steps.map((s, idx) => {
const actions: ExperimentAction[] = s.actions.map((a) => {
// Normalize legacy plugin action ids and provenance
const rawType = a.type ?? "";
// Try to resolve alias-style legacy ids using installed study plugins
const dynamicLegacy = (() => {
if (rawType.includes(".")) {
const [alias, base] = rawType.split(".", 2);
if (alias && base) {
const baseMap: Record<string, string> = {
speak: "say_text",
say: "say_text",
walk: "walk_to_position",
animation: "play_animation",
led: "set_led_color",
leds: "set_led_color",
sit: "sit_down",
stand: "stand_up",
head: "turn_head",
turn_head: "turn_head",
};
const mappedBase = baseMap[base] ?? base;
const candidate =
installedPlugins.find(
(p) =>
p.plugin.id.startsWith(alias) ||
p.plugin.name
.toLowerCase()
.includes(alias.toLowerCase()),
) ?? null;
if (
candidate &&
Array.isArray(candidate.plugin.actionDefinitions) &&
candidate.plugin.actionDefinitions.some(
(ad) => ad.id === mappedBase,
)
) {
return {
pluginId: candidate.plugin.id,
baseId: mappedBase,
pluginVersion: candidate.plugin.version ?? undefined,
};
}
}
}
return null;
})();
const legacy = dynamicLegacy;
const isPluginType = Boolean(legacy) || rawType.includes(".");
const typeOut = legacy
? `${legacy.pluginId}.${legacy.baseId}`
: rawType;
const execution: ExecutionDescriptor = { transport: "internal" };
const categoryOut: ActionCategory = isPluginType
? "robot"
: "wizard";
const sourceKind: "core" | "plugin" = isPluginType
? "plugin"
: "core";
const pluginId = legacy?.pluginId;
const pluginVersion = legacy?.pluginVersion;
return {
id: a.id,
type: typeOut,
name: a.name,
parameters: (a.parameters ?? {}) as Record<string, unknown>,
category: categoryOut,
source: {
kind: sourceKind,
pluginId,
pluginVersion,
robotId: null,
baseActionId: legacy?.baseId,
},
execution,
};
});
return {
id: s.id,
name: s.name,
description: s.description ?? "",
type: ((): StepType => {
const raw = (s.type as string) ?? "sequential";
if (raw === "wizard") return "sequential";
const allowed = [
"sequential",
"parallel",
"conditional",
"loop",
] as const;
return (allowed as readonly string[]).includes(raw)
? (raw as StepType)
: "sequential";
})(),
order: s.orderIndex ?? idx,
trigger: { type: "trial_start", conditions: {} },
actions,
expanded: true,
};
});
initialDesign = {
id: experiment.id,
name: experiment.name,
description: experiment.description ?? "",
steps: mapped,
version: experiment.version ?? 1,
lastSaved: new Date(),
};
}
}
return (
<DesignerRoot

View File

@@ -25,6 +25,7 @@ import type { ActionDefinition } from "~/lib/experiment-designer/types";
export class ActionRegistry {
private static instance: ActionRegistry;
private actions = new Map<string, ActionDefinition>();
private aliasIndex = new Map<string, string>();
private coreActionsLoaded = false;
private pluginActionsLoaded = false;
private loadedStudyId: string | null = null;
@@ -292,6 +293,7 @@ export class ActionRegistry {
icon?: string;
timeout?: number;
retryable?: boolean;
aliases?: string[];
parameterSchema?: unknown;
ros2?: {
topic?: string;
@@ -394,8 +396,8 @@ export class ActionRegistry {
};
const actionDef: ActionDefinition = {
id: `${plugin.id}.${action.id}`,
type: `${plugin.id}.${action.id}`,
id: `${plugin.robotId ?? plugin.id}.${action.id}`,
type: `${plugin.robotId ?? plugin.id}.${action.id}`,
name: action.name,
description: action.description ?? "",
category,
@@ -406,7 +408,7 @@ export class ActionRegistry {
),
source: {
kind: "plugin",
pluginId: plugin.id,
pluginId: plugin.robotId ?? plugin.id,
robotId: plugin.robotId,
pluginVersion: plugin.version ?? undefined,
baseActionId: action.id,
@@ -415,6 +417,17 @@ export class ActionRegistry {
parameterSchemaRaw: action.parameterSchema ?? undefined,
};
this.actions.set(actionDef.id, actionDef);
// Register aliases if provided by plugin metadata
const aliases = Array.isArray(action.aliases)
? action.aliases
: undefined;
if (aliases) {
for (const alias of aliases) {
if (typeof alias === "string" && alias.trim()) {
this.aliasIndex.set(alias, actionDef.id);
}
}
}
totalActionsLoaded++;
});
});
@@ -524,7 +537,10 @@ export class ActionRegistry {
}
getAction(id: string): ActionDefinition | undefined {
return this.actions.get(id);
const direct = this.actions.get(id);
if (direct) return direct;
const mapped = this.aliasIndex.get(id);
return mapped ? this.actions.get(mapped) : undefined;
}
/* ---------------- Debug Helpers ---------------- */

View File

@@ -1,20 +1,37 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { Play, Plus } from "lucide-react";
import { Play } from "lucide-react";
import { PageHeader, ActionButton } from "~/components/ui/page-header";
import { PageHeader } from "~/components/ui/page-header";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { api } from "~/trpc/react";
import { PanelsContainer } from "./layout/PanelsContainer";
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
import {
DndContext,
DragOverlay,
pointerWithin,
useSensor,
useSensors,
MouseSensor,
TouchSensor,
KeyboardSensor,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { BottomStatusBar } from "./layout/BottomStatusBar";
import { ActionLibraryPanel } from "./panels/ActionLibraryPanel";
import { InspectorPanel } from "./panels/InspectorPanel";
import { FlowListView } from "./flow/FlowListView";
import { FlowWorkspace } from "./flow/FlowWorkspace";
import {
type ExperimentDesign,
@@ -25,7 +42,10 @@ import {
import { useDesignerStore } from "./state/store";
import { actionRegistry } from "./ActionRegistry";
import { computeDesignHash } from "./state/hashing";
import { validateExperimentDesign } from "./state/validators";
import {
validateExperimentDesign,
groupIssuesByEntity,
} from "./state/validators";
/**
* DesignerRoot
@@ -161,6 +181,30 @@ export function DesignerRoot({
const setValidatedHash = useDesignerStore((s) => s.setValidatedHash);
const upsertStep = useDesignerStore((s) => s.upsertStep);
const upsertAction = useDesignerStore((s) => s.upsertAction);
const selectStep = useDesignerStore((s) => s.selectStep);
const selectAction = useDesignerStore((s) => s.selectAction);
const setValidationIssues = useDesignerStore((s) => s.setValidationIssues);
const clearAllValidationIssues = useDesignerStore(
(s) => s.clearAllValidationIssues,
);
const selectedStepId = useDesignerStore((s) => s.selectedStepId);
const selectedActionId = useDesignerStore((s) => s.selectedActionId);
const libraryRootRef = useRef<HTMLDivElement | null>(null);
const toggleLibraryScrollLock = useCallback((lock: boolean) => {
const viewport = libraryRootRef.current?.querySelector(
'[data-slot="scroll-area-viewport"]',
) as HTMLElement | null;
if (viewport) {
if (lock) {
viewport.style.overflowY = "hidden";
viewport.style.overscrollBehavior = "contain";
} else {
viewport.style.overflowY = "";
viewport.style.overscrollBehavior = "";
}
}
}, []);
/* ------------------------------- Local Meta ------------------------------ */
const [designMeta, setDesignMeta] = useState<{
@@ -193,10 +237,24 @@ export function DesignerRoot({
const [isValidating, setIsValidating] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [lastSavedAt, setLastSavedAt] = useState<Date | undefined>(undefined);
const [inspectorTab, setInspectorTab] = useState<
"properties" | "issues" | "dependencies"
>("properties");
/**
* Active action being dragged from the Action Library (for DragOverlay rendering).
* Captures a lightweight subset for visual feedback.
*/
const [dragOverlayAction, setDragOverlayAction] = useState<{
id: string;
name: string;
category: string;
description?: string;
} | null>(null);
/* ----------------------------- Initialization ---------------------------- */
useEffect(() => {
if (initialized || loadingExperiment) return;
if (initialized) return;
if (loadingExperiment && !initialDesign) return;
const adapted =
initialDesign ??
(experiment
@@ -288,8 +346,10 @@ export function DesignerRoot({
expanded: true,
};
upsertStep(newStep);
selectStep(newStep.id);
setInspectorTab("properties");
toast.success(`Created ${newStep.name}`);
}, [steps.length, upsertStep]);
}, [steps.length, upsertStep, selectStep]);
/* ------------------------------- Validation ------------------------------ */
const validateDesign = useCallback(async () => {
@@ -297,14 +357,39 @@ export function DesignerRoot({
setIsValidating(true);
try {
const currentSteps = [...steps];
// Ensure core actions are loaded before validating
await actionRegistry.loadCoreActions();
const result = validateExperimentDesign(currentSteps, {
steps: currentSteps,
actionDefinitions: actionRegistry.getAllActions(),
});
// Debug: log validation results for troubleshooting
// eslint-disable-next-line no-console
console.debug("[DesignerRoot] validation", {
valid: result.valid,
errors: result.errorCount,
warnings: result.warningCount,
infos: result.infoCount,
issues: result.issues,
});
// Persist issues to store for inspector rendering
const grouped = groupIssuesByEntity(result.issues);
clearAllValidationIssues();
for (const [entityId, arr] of Object.entries(grouped)) {
setValidationIssues(
entityId,
arr.map((i) => ({
entityId,
severity: i.severity,
message: i.message,
code: undefined,
})),
);
}
const hash = await computeDesignHash(currentSteps);
setValidatedHash(hash);
if (result.valid) {
toast.success(`Validated • ${hash.slice(0, 10)}… • No issues`);
toast.success(`Validated • ${hash.slice(0, 10)}… • 0 errors`);
} else {
toast.warning(
`Validated with ${result.errorCount} errors, ${result.warningCount} warnings`,
@@ -319,7 +404,13 @@ export function DesignerRoot({
} finally {
setIsValidating(false);
}
}, [initialized, steps, setValidatedHash]);
}, [
initialized,
steps,
setValidatedHash,
setValidationIssues,
clearAllValidationIssues,
]);
/* --------------------------------- Save ---------------------------------- */
const persist = useCallback(async () => {
@@ -414,6 +505,19 @@ export function DesignerRoot({
void recomputeHash();
}, [steps.length, initialized, recomputeHash]);
useEffect(() => {
if (selectedStepId || selectedActionId) {
setInspectorTab("properties");
}
}, [selectedStepId, selectedActionId]);
// Auto-open properties tab when a step or action becomes selected
useEffect(() => {
if (selectedStepId || selectedActionId) {
setInspectorTab("properties");
}
}, [selectedStepId, selectedActionId]);
/* -------------------------- Keyboard Shortcuts --------------------------- */
const keyHandler = useCallback(
(e: globalThis.KeyboardEvent) => {
@@ -448,21 +552,76 @@ export function DesignerRoot({
/* ------------------------------ Header Badges ---------------------------- */
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 150, tolerance: 5 },
}),
useSensor(KeyboardSensor),
);
/* ----------------------------- Drag Handlers ----------------------------- */
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const { active } = event;
if (
active.id.toString().startsWith("action-") &&
active.data.current?.action
) {
const a = active.data.current.action as {
id: string;
name: string;
category: string;
description?: string;
};
toggleLibraryScrollLock(true);
setDragOverlayAction({
id: a.id,
// prefer definition name; fallback to id
name: a.name || a.id,
category: a.category,
description: a.description,
});
}
},
[toggleLibraryScrollLock],
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
async (event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
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)");
return;
}
// Expect dragged action (library) onto a step droppable
const activeId = active.id.toString();
const overId = over.id.toString();
if (
activeId.startsWith("action-") &&
overId.startsWith("step-") &&
active.data.current?.action
) {
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;
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-")) {
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;
const actionDef = active.data.current.action as {
id: string;
type: string;
@@ -474,7 +633,6 @@ export function DesignerRoot({
parameters: Array<{ id: string; name: string }>;
};
const stepId = overId.replace("step-", "");
const targetStep = steps.find((s) => s.id === stepId);
if (!targetStep) return;
@@ -502,24 +660,24 @@ export function DesignerRoot({
};
upsertAction(stepId, newAction);
// Select the newly added action and open properties
selectStep(stepId);
selectAction(stepId, newAction.id);
setInspectorTab("properties");
await recomputeHash();
toast.success(`Added ${actionDef.name} to ${targetStep.name}`);
}
},
[steps, upsertAction],
[
steps,
upsertAction,
recomputeHash,
selectStep,
selectAction,
toggleLibraryScrollLock,
],
);
const validationBadge =
driftStatus === "drift" ? (
<Badge variant="destructive">Drift</Badge>
) : driftStatus === "validated" ? (
<Badge
variant="outline"
className="border-green-400 text-green-700 dark:text-green-400"
>
Validated
</Badge>
) : (
<Badge variant="outline">Unvalidated</Badge>
);
// validation status badges removed (unused)
/* ------------------------------- Render ---------------------------------- */
if (loadingExperiment && !initialized) {
@@ -538,54 +696,23 @@ export function DesignerRoot({
icon={Play}
actions={
<div className="flex flex-wrap items-center gap-2">
{validationBadge}
{experiment?.integrityHash && (
<Badge variant="outline" className="text-xs">
Hash: {experiment.integrityHash.slice(0, 10)}
</Badge>
)}
<Badge variant="secondary" className="text-xs">
{steps.length} steps
</Badge>
<Badge variant="secondary" className="text-xs">
{steps.reduce((s, st) => s + st.actions.length, 0)} actions
</Badge>
{hasUnsavedChanges && (
<Badge
variant="outline"
className="border-orange-300 text-orange-600"
>
Unsaved
</Badge>
)}
<ActionButton
onClick={() => persist()}
disabled={!hasUnsavedChanges || isSaving}
>
{isSaving ? "Saving…" : "Save"}
</ActionButton>
<ActionButton
variant="outline"
onClick={() => validateDesign()}
disabled={isValidating}
>
{isValidating ? "Validating…" : "Validate"}
</ActionButton>
<ActionButton
variant="outline"
onClick={() => handleExport()}
disabled={isExporting}
>
{isExporting ? "Exporting…" : "Export"}
</ActionButton>
<Button
size="sm"
variant="default"
className="h-8 text-xs"
onClick={createNewStep}
className="h-8 px-3 text-xs"
onClick={() => validateDesign()}
disabled={isValidating}
>
<Plus className="mr-1 h-4 w-4" />
Step
Validate
</Button>
<Button
size="sm"
variant="secondary"
className="h-8 px-3 text-xs"
onClick={() => persist()}
disabled={!hasUnsavedChanges || isSaving}
>
Save
</Button>
</div>
}
@@ -593,17 +720,38 @@ export function DesignerRoot({
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border">
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
collisionDetection={pointerWithin}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={() => toggleLibraryScrollLock(false)}
>
<PanelsContainer
left={<ActionLibraryPanel />}
center={<FlowListView />}
right={<InspectorPanel />}
left={
<div ref={libraryRootRef} data-library-root>
<ActionLibraryPanel />
</div>
}
center={<FlowWorkspace />}
right={
<InspectorPanel
activeTab={inspectorTab}
onTabChange={setInspectorTab}
/>
}
initialLeftWidth={260}
initialRightWidth={360}
initialRightWidth={260}
minRightWidth={240}
maxRightWidth={300}
className="flex-1"
/>
<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>
<BottomStatusBar
onSave={() => persist()}

View File

@@ -124,7 +124,7 @@ export function PropertiesPanel({
: Zap;
return (
<div className={cn("space-y-3", className)}>
<div className={cn("w-full min-w-0 space-y-3 px-3", className)}>
{/* Header / Metadata */}
<div className="border-b pb-3">
<div className="mb-2 flex items-center gap-2">
@@ -142,8 +142,8 @@ export function PropertiesPanel({
<h3 className="truncate text-sm font-medium">
{selectedAction.name}
</h3>
<p className="text-muted-foreground text-xs">
{def?.category} {selectedAction.type}
<p className="text-muted-foreground text-xs capitalize">
{def?.category}
</p>
</div>
</div>
@@ -151,14 +151,7 @@ export function PropertiesPanel({
<Badge variant="outline" className="h-4 text-[10px]">
{selectedAction.source.kind === "plugin" ? "Plugin" : "Core"}
</Badge>
{selectedAction.source.pluginId && (
<Badge variant="secondary" className="h-4 text-[10px]">
{selectedAction.source.pluginId}
{selectedAction.source.pluginVersion
? `@${selectedAction.source.pluginVersion}`
: ""}
</Badge>
)}
{/* internal plugin identifiers hidden from UI */}
<Badge variant="outline" className="h-4 text-[10px]">
{selectedAction.execution.transport}
</Badge>
@@ -175,8 +168,11 @@ export function PropertiesPanel({
)}
</div>
{/* General Action Fields */}
{/* General */}
<div className="space-y-2">
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
General
</div>
<div>
<Label className="text-xs">Display Name</Label>
<Input
@@ -186,7 +182,7 @@ export function PropertiesPanel({
name: e.target.value,
})
}
className="mt-1 h-7 text-xs"
className="mt-1 h-7 w-full text-xs"
/>
</div>
</div>
@@ -231,7 +227,7 @@ export function PropertiesPanel({
value={(rawValue as string) ?? ""}
placeholder={param.placeholder}
onChange={(e) => updateParamValue(e.target.value)}
className="mt-1 h-7 text-xs"
className="mt-1 h-7 w-full text-xs"
/>
);
} else if (param.type === "select") {
@@ -240,7 +236,7 @@ export function PropertiesPanel({
value={(rawValue as string) ?? ""}
onValueChange={(val) => updateParamValue(val)}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectTrigger className="mt-1 h-7 w-full text-xs">
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent>
@@ -322,7 +318,7 @@ export function PropertiesPanel({
onChange={(e) =>
updateParamValue(parseFloat(e.target.value) || 0)
}
className="mt-1 h-7 text-xs"
className="mt-1 h-7 w-full text-xs"
/>
);
}
@@ -354,7 +350,7 @@ export function PropertiesPanel({
/* --------------------------- Step Properties View --------------------------- */
if (selectedStep) {
return (
<div className={cn("space-y-3", className)}>
<div className={cn("w-full min-w-0 space-y-3 px-3", className)}>
<div className="border-b pb-2">
<h3 className="flex items-center gap-2 text-sm font-medium">
<div
@@ -368,73 +364,88 @@ export function PropertiesPanel({
Step Settings
</h3>
</div>
<div className="space-y-2">
<div className="space-y-3">
<div>
<Label className="text-xs">Name</Label>
<Input
value={selectedStep.name}
onChange={(e) =>
onStepUpdate(selectedStep.id, { name: e.target.value })
}
className="mt-1 h-7 text-xs"
/>
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
General
</div>
<div className="mt-2 space-y-2">
<div>
<Label className="text-xs">Name</Label>
<Input
value={selectedStep.name}
onChange={(e) =>
onStepUpdate(selectedStep.id, { name: e.target.value })
}
className="mt-1 h-7 w-full text-xs"
/>
</div>
<div>
<Label className="text-xs">Description</Label>
<Input
value={selectedStep.description ?? ""}
placeholder="Optional step description"
onChange={(e) =>
onStepUpdate(selectedStep.id, {
description: e.target.value,
})
}
className="mt-1 h-7 w-full text-xs"
/>
</div>
</div>
</div>
<div>
<Label className="text-xs">Description</Label>
<Input
value={selectedStep.description ?? ""}
placeholder="Optional step description"
onChange={(e) =>
onStepUpdate(selectedStep.id, {
description: e.target.value,
})
}
className="mt-1 h-7 text-xs"
/>
</div>
<div>
<Label className="text-xs">Type</Label>
<Select
value={selectedStep.type}
onValueChange={(val) =>
onStepUpdate(selectedStep.id, { type: val as StepType })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sequential">Sequential</SelectItem>
<SelectItem value="parallel">Parallel</SelectItem>
<SelectItem value="conditional">Conditional</SelectItem>
<SelectItem value="loop">Loop</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Trigger</Label>
<Select
value={selectedStep.trigger.type}
onValueChange={(val) =>
onStepUpdate(selectedStep.id, {
trigger: {
...selectedStep.trigger,
type: val as TriggerType,
},
})
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRIGGER_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
Behavior
</div>
<div className="mt-2 space-y-2">
<div>
<Label className="text-xs">Type</Label>
<Select
value={selectedStep.type}
onValueChange={(val) =>
onStepUpdate(selectedStep.id, { type: val as StepType })
}
>
<SelectTrigger className="mt-1 h-7 w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sequential">Sequential</SelectItem>
<SelectItem value="parallel">Parallel</SelectItem>
<SelectItem value="conditional">Conditional</SelectItem>
<SelectItem value="loop">Loop</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Trigger</Label>
<Select
value={selectedStep.trigger.type}
onValueChange={(val) =>
onStepUpdate(selectedStep.id, {
trigger: {
...selectedStep.trigger,
type: val as TriggerType,
},
})
}
>
<SelectTrigger className="mt-1 h-7 w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRIGGER_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
</div>
@@ -451,9 +462,9 @@ export function PropertiesPanel({
>
<div>
<Settings className="text-muted-foreground/50 mx-auto mb-2 h-6 w-6" />
<h3 className="mb-1 text-sm font-medium">Select Step or Action</h3>
<h3 className="mb-1 text-sm font-medium">No selection</h3>
<p className="text-muted-foreground text-xs">
Click in the flow to edit properties
Select a step or action in the flow to edit its properties.
</p>
</div>
</div>

View File

@@ -1,12 +1,20 @@
"use client";
import React, { useState, useMemo } from "react";
import { AlertCircle, AlertTriangle, Info, Filter, X } from "lucide-react";
import {
AlertCircle,
AlertTriangle,
Info,
Filter,
X,
Search,
CheckCircle2,
} from "lucide-react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Separator } from "~/components/ui/separator";
import { Input } from "~/components/ui/input";
import { cn } from "~/lib/utils";
/* -------------------------------------------------------------------------- */
@@ -39,6 +47,10 @@ export interface ValidationPanelProps {
* Called to clear all issues for an entity.
*/
onEntityClear?: (entityId: string) => void;
/**
* Optional function to map entity IDs to human-friendly names (e.g., step/action names).
*/
entityLabelForId?: (entityId: string) => string;
className?: string;
}
@@ -109,16 +121,22 @@ interface IssueItemProps {
issue: ValidationIssue & { entityId: string; index: number };
onIssueClick?: (issue: ValidationIssue) => void;
onIssueClear?: (entityId: string, issueIndex: number) => void;
entityLabelForId?: (entityId: string) => string;
}
function IssueItem({ issue, onIssueClick, onIssueClear }: IssueItemProps) {
function IssueItem({
issue,
onIssueClick,
onIssueClear,
entityLabelForId,
}: IssueItemProps) {
const config = severityConfig[issue.severity];
const IconComponent = config.icon;
return (
<div
className={cn(
"group flex items-start gap-3 rounded-md border p-3 transition-colors",
"group flex w-full max-w-full min-w-0 items-start gap-2 rounded-md border p-2 break-words transition-colors",
config.borderColor,
config.bgColor,
onIssueClick && "cursor-pointer hover:shadow-sm",
@@ -132,25 +150,30 @@ function IssueItem({ issue, onIssueClick, onIssueClear }: IssueItemProps) {
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="text-sm leading-relaxed">{issue.message}</p>
<p className="text-[12px] leading-snug break-words whitespace-normal">
{issue.message}
</p>
<div className="mt-1 flex flex-wrap items-center gap-1">
<Badge variant={config.badgeVariant} className="h-4 text-[10px]">
<Badge variant={config.badgeVariant} className="text-[10px]">
{config.label}
</Badge>
{issue.category && (
<Badge variant="outline" className="h-4 text-[10px] capitalize">
<Badge variant="outline" className="text-[10px] capitalize">
{issue.category}
</Badge>
)}
<Badge variant="secondary" className="h-4 text-[10px]">
{getEntityDisplayName(issue.entityId)}
<Badge
variant="secondary"
className="max-w-full text-[10px] break-words whitespace-normal"
>
{entityLabelForId?.(issue.entityId) ?? "Unknown"}
</Badge>
{issue.field && (
<Badge variant="outline" className="h-4 text-[10px]">
<Badge variant="outline" className="text-[10px]">
{issue.field}
</Badge>
)}
@@ -185,6 +208,7 @@ export function ValidationPanel({
onIssueClick,
onIssueClear,
onEntityClear: _onEntityClear,
entityLabelForId,
className,
}: ValidationPanelProps) {
const [severityFilter, setSeverityFilter] = useState<
@@ -193,21 +217,23 @@ export function ValidationPanel({
const [categoryFilter, setCategoryFilter] = useState<
"all" | "structural" | "parameter" | "semantic" | "execution"
>("all");
const [search, setSearch] = useState("");
// Flatten and filter issues
const flatIssues = useMemo(() => {
const flat = flattenIssues(issues);
const q = search.trim().toLowerCase();
return flat.filter((issue) => {
if (severityFilter !== "all" && issue.severity !== severityFilter) {
if (severityFilter !== "all" && issue.severity !== severityFilter)
return false;
}
if (categoryFilter !== "all" && issue.category !== categoryFilter) {
if (categoryFilter !== "all" && issue.category !== categoryFilter)
return false;
}
return true;
if (!q) return true;
const hay =
`${issue.message} ${issue.field ?? ""} ${issue.category ?? ""} ${issue.entityId}`.toLowerCase();
return hay.includes(q);
});
}, [issues, severityFilter, categoryFilter]);
}, [issues, severityFilter, categoryFilter, search]);
// Count by severity
const counts = useMemo(() => {
@@ -220,6 +246,12 @@ export function ValidationPanel({
};
}, [issues]);
React.useEffect(() => {
// Debug: surface validation state to console
// eslint-disable-next-line no-console
console.log("[ValidationPanel] issues", issues, { flatIssues, counts });
}, [issues, flatIssues, counts]);
// Available categories
const availableCategories = useMemo(() => {
const flat = flattenIssues(issues);
@@ -230,160 +262,127 @@ export function ValidationPanel({
}, [issues]);
return (
<Card className={cn("h-[calc(100vh-12rem)]", className)}>
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
Validation Issues
</div>
<div className="flex items-center gap-1">
{counts.error > 0 && (
<Badge variant="destructive" className="h-4 text-[10px]">
{counts.error}
</Badge>
)}
{counts.warning > 0 && (
<Badge variant="secondary" className="h-4 text-[10px]">
{counts.warning}
</Badge>
)}
{counts.info > 0 && (
<Badge variant="outline" className="h-4 text-[10px]">
{counts.info}
</Badge>
)}
</div>
</CardTitle>
</CardHeader>
<div
className={cn(
"flex h-full min-h-0 min-w-0 flex-col overflow-hidden",
className,
)}
>
{/* Header (emulate ActionLibraryPanel) */}
<div className="bg-background/60 border-b p-2">
<div className="relative mb-2">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search issues"
className="h-8 w-full pl-7 text-xs"
aria-label="Search issues"
/>
</div>
<CardContent className="p-0">
{/* Filters */}
{counts.total > 0 && (
<>
<div className="border-b p-3">
<div className="flex flex-wrap gap-2">
{/* Severity Filter */}
<div className="flex items-center gap-1">
<Filter className="text-muted-foreground h-3 w-3" />
<Button
variant={severityFilter === "all" ? "default" : "ghost"}
size="sm"
className="h-6 px-2 text-xs"
onClick={() => setSeverityFilter("all")}
>
All ({counts.total})
</Button>
{counts.error > 0 && (
<Button
variant={
severityFilter === "error" ? "destructive" : "ghost"
}
size="sm"
className="h-6 px-2 text-xs"
onClick={() => setSeverityFilter("error")}
>
Errors ({counts.error})
</Button>
)}
{counts.warning > 0 && (
<Button
variant={
severityFilter === "warning" ? "secondary" : "ghost"
}
size="sm"
className="h-6 px-2 text-xs"
onClick={() => setSeverityFilter("warning")}
>
Warnings ({counts.warning})
</Button>
)}
{counts.info > 0 && (
<Button
variant={severityFilter === "info" ? "outline" : "ghost"}
size="sm"
className="h-6 px-2 text-xs"
onClick={() => setSeverityFilter("info")}
>
Info ({counts.info})
</Button>
)}
</div>
<div className="mb-2 grid grid-cols-2 gap-1">
<Button
variant={severityFilter === "all" ? "default" : "ghost"}
size="sm"
className="h-7 justify-start gap-1 text-[11px]"
onClick={() => setSeverityFilter("all")}
aria-pressed={severityFilter === "all"}
>
<Filter className="h-3 w-3" /> All
<span className="ml-auto text-[10px] font-normal opacity-80">
{counts.total}
</span>
</Button>
<Button
variant={severityFilter === "error" ? "default" : "ghost"}
size="sm"
className={cn(
"h-7 justify-start gap-1 text-[11px]",
severityFilter === "error" &&
"bg-red-600 text-white hover:opacity-90",
)}
onClick={() => setSeverityFilter("error")}
aria-pressed={severityFilter === "error"}
>
<AlertCircle className="h-3 w-3" /> Errors
<span className="ml-auto text-[10px] font-normal opacity-80">
{counts.error}
</span>
</Button>
<Button
variant={severityFilter === "warning" ? "default" : "ghost"}
size="sm"
className={cn(
"h-7 justify-start gap-1 text-[11px]",
severityFilter === "warning" &&
"bg-amber-500 text-white hover:opacity-90",
)}
onClick={() => setSeverityFilter("warning")}
aria-pressed={severityFilter === "warning"}
>
<AlertTriangle className="h-3 w-3" /> Warn
<span className="ml-auto text-[10px] font-normal opacity-80">
{counts.warning}
</span>
</Button>
<Button
variant={severityFilter === "info" ? "default" : "ghost"}
size="sm"
className={cn(
"h-7 justify-start gap-1 text-[11px]",
severityFilter === "info" &&
"bg-blue-600 text-white hover:opacity-90",
)}
onClick={() => setSeverityFilter("info")}
aria-pressed={severityFilter === "info"}
>
<Info className="h-3 w-3" /> Info
<span className="ml-auto text-[10px] font-normal opacity-80">
{counts.info}
</span>
</Button>
</div>
</div>
{/* Category Filter */}
{availableCategories.length > 0 && (
<>
<Separator orientation="vertical" className="h-6" />
<div className="flex items-center gap-1">
<Button
variant={categoryFilter === "all" ? "default" : "ghost"}
size="sm"
className="h-6 px-2 text-xs"
onClick={() => setCategoryFilter("all")}
>
All Categories
</Button>
{availableCategories.map((category) => (
<Button
key={category}
variant={
categoryFilter === category ? "outline" : "ghost"
}
size="sm"
className="h-6 px-2 text-xs capitalize"
onClick={() => setCategoryFilter(category)}
>
{category}
</Button>
))}
</div>
</>
)}
{/* Issues List */}
<ScrollArea className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
<div className="flex min-w-0 flex-col gap-2 p-2 pr-2">
{counts.total === 0 ? (
<div className="py-8 text-center">
<div className="mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/20">
<CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</div>
<p className="text-sm font-medium text-emerald-700 dark:text-emerald-300">
All clear no issues
</p>
<p className="text-muted-foreground text-xs">
Validate again after changes.
</p>
</div>
</>
)}
{/* Issues List */}
<ScrollArea className="h-full">
<div className="p-3">
{counts.total === 0 ? (
<div className="py-8 text-center">
<div className="mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-950/20">
<Info className="h-4 w-4 text-green-600 dark:text-green-400" />
</div>
<p className="text-sm font-medium text-green-700 dark:text-green-300">
No validation issues
</p>
<p className="text-muted-foreground text-xs">
Your experiment design looks good!
</p>
) : flatIssues.length === 0 ? (
<div className="py-8 text-center">
<div className="bg-muted mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full">
<Filter className="h-4 w-4" />
</div>
) : flatIssues.length === 0 ? (
<div className="py-8 text-center">
<div className="bg-muted mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full">
<Filter className="h-4 w-4" />
</div>
<p className="text-sm font-medium">No issues match filters</p>
<p className="text-muted-foreground text-xs">
Try adjusting your filter criteria
</p>
</div>
) : (
<div className="space-y-2">
{flatIssues.map((issue) => (
<IssueItem
key={`${issue.entityId}-${issue.index}`}
issue={issue}
onIssueClick={onIssueClick}
onIssueClear={onIssueClear}
/>
))}
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
<p className="text-sm font-medium">No issues match filters</p>
<p className="text-muted-foreground text-xs">
Adjust your filters
</p>
</div>
) : (
flatIssues.map((issue) => (
<IssueItem
key={`${issue.entityId}-${issue.index}`}
issue={issue}
onIssueClick={onIssueClick}
onIssueClear={onIssueClear}
entityLabelForId={entityLabelForId}
/>
))
)}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,798 @@
"use client";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import {
useDroppable,
useDndMonitor,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import {
useSortable,
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ChevronDown,
ChevronRight,
GripVertical,
Plus,
Trash2,
GitBranch,
Sparkles,
CircleDot,
Edit3,
} from "lucide-react";
import { cn } from "~/lib/utils";
import {
type ExperimentStep,
type ExperimentAction,
} from "~/lib/experiment-designer/types";
import { useDesignerStore } from "../state/store";
import { actionRegistry } from "../ActionRegistry";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Input } from "~/components/ui/input";
/**
* FlowWorkspace
*
* Virtualized step + action workspace with local (Option B) sortable handling.
* Reordering is processed locally via useDndMonitor (not in DesignerRoot)
* to keep orchestration layer simpler and reduce cross-component coupling.
*
* Features:
* - Virtualized step list (absolute positioned variable heights)
* - Inline step rename
* - Step & action creation / deletion
* - Step and action reordering (drag handles)
* - Drag-from-library action insertion (handled by root DnD; droppables here)
* - Empty step drop affordance + highlight
*
* Sortable ID strategy (to avoid collision with palette action ids):
* - Sortable Step: s-step-<stepId>
* - Sortable Action: s-act-<actionId>
* - Droppable Step: step-<stepId> (kept for root palette drops)
*/
interface FlowWorkspaceProps {
className?: string;
overscan?: number;
onStepCreate?: (step: ExperimentStep) => void;
onStepDelete?: (stepId: string) => void;
onActionCreate?: (stepId: string, action: ExperimentAction) => void;
}
interface VirtualItem {
index: number;
top: number;
height: number;
step: ExperimentStep;
key: string;
visible: boolean;
}
/* -------------------------------------------------------------------------- */
/* Utility */
/* -------------------------------------------------------------------------- */
function generateStepId(): string {
return `step-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function generateActionId(): string {
return `action-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function sortableStepId(stepId: string) {
return `s-step-${stepId}`;
}
function sortableActionId(actionId: string) {
return `s-act-${actionId}`;
}
function parseSortableStep(id: string): string | null {
return id.startsWith("s-step-") ? id.slice("s-step-".length) : null;
}
function parseSortableAction(id: string): string | null {
return id.startsWith("s-act-") ? id.slice("s-act-".length) : null;
}
/* -------------------------------------------------------------------------- */
/* Droppable Overlay (for palette action drops) */
/* -------------------------------------------------------------------------- */
function StepDroppableArea({ stepId }: { stepId: string }) {
const { isOver } = useDroppable({ id: `step-${stepId}` });
return (
<div
data-step-drop
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",
)}
/>
);
}
/* -------------------------------------------------------------------------- */
/* Sortable Action Chip */
/* -------------------------------------------------------------------------- */
interface ActionChipProps {
action: ExperimentAction;
isSelected: boolean;
onSelect: () => void;
onDelete: () => void;
dragHandle?: boolean;
}
function SortableActionChip({
action,
isSelected,
onSelect,
onDelete,
}: ActionChipProps) {
const def = actionRegistry.getAction(action.type);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: sortableActionId(action.id),
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 30 : undefined,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"group relative flex w-full flex-col items-start gap-1 rounded border px-3 py-2 text-[11px]",
"bg-muted/40 hover:bg-accent/40 cursor-pointer",
isSelected && "border-blue-500 bg-blue-50 dark:bg-blue-950/30",
isDragging && "opacity-70 shadow-lg",
)}
onClick={onSelect}
{...attributes}
role="button"
aria-pressed={isSelected}
tabIndex={0}
>
<div className="flex w-full items-center gap-2">
<div
{...listeners}
className="text-muted-foreground/70 hover:text-foreground cursor-grab rounded p-0.5"
aria-label="Drag action"
>
<GripVertical className="h-3.5 w-3.5" />
</div>
<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-slate-400",
)}
/>
<span className="flex-1 leading-snug font-medium break-words">
{action.name}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-muted-foreground hover:text-foreground rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
aria-label="Delete action"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
{def?.description && (
<div className="text-muted-foreground line-clamp-3 w-full text-[10px] leading-snug">
{def.description}
</div>
)}
{def?.parameters.length ? (
<div className="flex flex-wrap gap-1 pt-0.5">
{def.parameters.slice(0, 4).map((p) => (
<span
key={p.id}
className="bg-background/70 text-muted-foreground ring-border rounded px-1 py-0.5 text-[9px] font-medium ring-1"
>
{p.name}
</span>
))}
{def.parameters.length > 4 && (
<span className="text-muted-foreground text-[9px]">
+{def.parameters.length - 4} more
</span>
)}
</div>
) : null}
</div>
);
}
/* -------------------------------------------------------------------------- */
/* FlowWorkspace Component */
/* -------------------------------------------------------------------------- */
export function FlowWorkspace({
className,
overscan = 400,
onStepCreate,
onStepDelete,
onActionCreate,
}: FlowWorkspaceProps) {
/* Store selectors */
const steps = useDesignerStore((s) => s.steps);
const selectStep = useDesignerStore((s) => s.selectStep);
const selectAction = useDesignerStore((s) => s.selectAction);
const selectedStepId = useDesignerStore((s) => s.selectedStepId);
const selectedActionId = useDesignerStore((s) => s.selectedActionId);
const upsertStep = useDesignerStore((s) => s.upsertStep);
const removeStep = useDesignerStore((s) => s.removeStep);
const upsertAction = useDesignerStore((s) => s.upsertAction);
const removeAction = useDesignerStore((s) => s.removeAction);
const reorderStep = useDesignerStore((s) => s.reorderStep);
const reorderAction = useDesignerStore((s) => s.reorderAction);
const recomputeHash = useDesignerStore((s) => s.recomputeHash);
/* Local state */
const containerRef = useRef<HTMLDivElement | null>(null);
const measureRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const roRef = useRef<ResizeObserver | null>(null);
const [heights, setHeights] = useState<Map<string, number>>(new Map());
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(600);
const [containerWidth, setContainerWidth] = useState(0);
const [renamingStepId, setRenamingStepId] = useState<string | null>(null);
const [isDraggingLibraryAction, setIsDraggingLibraryAction] = useState(false);
// dragKind state removed (unused after refactor)
/* Parent lookup for action reorder */
const actionParentMap = useMemo(() => {
const map = new Map<string, string>();
for (const step of steps) {
for (const a of step.actions) {
map.set(a.id, step.id);
}
}
return map;
}, [steps]);
/* Resize observer for viewport and width changes */
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const cr = entry.contentRect;
setViewportHeight(cr.height);
setContainerWidth((prev) => {
if (Math.abs(prev - cr.width) > 0.5) {
// Invalidate cached heights on width change to force re-measure
setHeights(new Map());
}
return cr.width;
});
}
});
observer.observe(el);
const cr = el.getBoundingClientRect();
setViewportHeight(el.clientHeight);
setContainerWidth(cr.width);
return () => observer.disconnect();
}, []);
/* Per-step measurement observer (attach/detach on ref set) */
useLayoutEffect(() => {
roRef.current = new ResizeObserver((entries) => {
setHeights((prev) => {
const next = new Map(prev);
let changed = false;
for (const entry of entries) {
const id = entry.target.getAttribute("data-step-id");
if (!id) continue;
const h = entry.contentRect.height;
if (prev.get(id) !== h) {
next.set(id, h);
changed = true;
}
}
return changed ? next : prev;
});
});
return () => {
roRef.current?.disconnect();
roRef.current = null;
};
}, []);
/* Scroll */
const onScroll = useCallback(() => {
if (!containerRef.current) return;
setScrollTop(containerRef.current.scrollTop);
}, []);
/* Virtual items */
const estimatedBaseHeight = 140;
const virtualItems: VirtualItem[] = useMemo(() => {
const out: VirtualItem[] = [];
let offset = 0;
steps.forEach((step, idx) => {
const h = heights.get(step.id) ?? estimatedBaseHeight;
const top = offset;
const visible =
top + h > scrollTop - overscan &&
top < scrollTop + viewportHeight + overscan;
out.push({
index: idx,
top,
height: h,
step,
key: step.id,
visible,
});
offset += h;
});
return out;
}, [steps, heights, scrollTop, viewportHeight, overscan]);
const totalHeight = useMemo(
() =>
steps.reduce(
(sum, step) => sum + (heights.get(step.id) ?? estimatedBaseHeight),
0,
),
[steps, heights],
);
/* CRUD Helpers */
const createStep = useCallback(
(insertIndex?: number) => {
const newStep: ExperimentStep = {
id: generateStepId(),
name: `Step ${steps.length + 1}`,
description: "",
type: "sequential",
order: steps.length,
trigger: { type: "trial_start", conditions: {} },
actions: [],
expanded: true,
};
if (
typeof insertIndex === "number" &&
insertIndex >= 0 &&
insertIndex < steps.length
) {
// Insert with manual reindex
const reordered = steps
.slice(0, insertIndex + 1)
.concat([newStep], steps.slice(insertIndex + 1))
.map((s, i) => ({ ...s, order: i }));
reordered.forEach((s) => upsertStep(s));
} else {
upsertStep(newStep);
}
selectStep(newStep.id);
onStepCreate?.(newStep);
void recomputeHash();
},
[steps, upsertStep, selectStep, onStepCreate, recomputeHash],
);
const deleteStep = useCallback(
(step: ExperimentStep) => {
removeStep(step.id);
onStepDelete?.(step.id);
if (selectedStepId === step.id) selectStep(undefined);
void recomputeHash();
},
[removeStep, onStepDelete, selectedStepId, selectStep, recomputeHash],
);
const toggleExpanded = useCallback(
(step: ExperimentStep) => {
upsertStep({ ...step, expanded: !step.expanded });
},
[upsertStep],
);
const renameStep = useCallback(
(step: ExperimentStep, name: string) => {
upsertStep({ ...step, name });
},
[upsertStep],
);
const addActionToStep = useCallback(
(
stepId: string,
actionDef: { type: string; name: string; category: string },
) => {
const step = steps.find((s) => s.id === stepId);
if (!step) return;
const newAction: ExperimentAction = {
id: generateActionId(),
type: actionDef.type,
name: actionDef.name,
category: actionDef.category as ExperimentAction["category"],
parameters: {},
source: { kind: "core" },
execution: { transport: "internal" },
};
upsertAction(stepId, newAction);
onActionCreate?.(stepId, newAction);
void recomputeHash();
},
[steps, upsertAction, onActionCreate, recomputeHash],
);
const deleteAction = useCallback(
(stepId: string, actionId: string) => {
removeAction(stepId, actionId);
if (selectedActionId === actionId) selectAction(stepId, undefined);
void recomputeHash();
},
[removeAction, selectedActionId, selectAction, recomputeHash],
);
/* ------------------------------------------------------------------------ */
/* Sortable (Local) DnD Monitoring */
/* ------------------------------------------------------------------------ */
const handleLocalDragStart = useCallback((e: DragStartEvent) => {
const id = e.active.id.toString();
if (id.startsWith("action-")) {
setIsDraggingLibraryAction(true);
}
}, []);
const handleLocalDragEnd = useCallback(
(e: DragEndEvent) => {
const { active, over } = e;
setIsDraggingLibraryAction(false);
if (!over || !active) {
return;
}
const activeId = active.id.toString();
const overId = over.id.toString();
// Step reorder
if (activeId.startsWith("s-step-") && overId.startsWith("s-step-")) {
const fromStepId = parseSortableStep(activeId);
const toStepId = parseSortableStep(overId);
if (fromStepId && toStepId && fromStepId !== toStepId) {
const fromIndex = steps.findIndex((s) => s.id === fromStepId);
const toIndex = steps.findIndex((s) => s.id === toStepId);
if (fromIndex >= 0 && toIndex >= 0) {
reorderStep(fromIndex, toIndex);
void recomputeHash();
}
}
}
// Action reorder (within same parent only)
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();
}
}
}
}
}
},
[steps, reorderStep, reorderAction, actionParentMap, recomputeHash],
);
useDndMonitor({
onDragStart: handleLocalDragStart,
onDragEnd: handleLocalDragEnd,
onDragCancel: () => {
setIsDraggingLibraryAction(false);
},
});
/* ------------------------------------------------------------------------ */
/* 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),
});
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;
if (prev && prev !== el) {
roRef.current?.unobserve(prev);
measureRefs.current.delete(step.id);
}
if (el) {
measureRefs.current.set(step.id, 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(
"rounded border shadow-sm transition-colors mb-2",
selectedStepId === step.id
? "border-blue-400/60 bg-blue-50/40 dark:bg-blue-950/20"
: "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 border-dashed border-muted-foreground/30 rounded px-2 py-1 text-[11px]">
Drop actions here
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------------------ */
/* Render */
/* ------------------------------------------------------------------------ */
return (
<div className={cn("flex h-full flex-col", className)}>
<div className="flex items-center justify-between border-b px-3 py-2 text-xs">
<div className="flex items-center gap-3 font-medium">
<span className="text-muted-foreground flex items-center gap-1">
<GitBranch className="h-4 w-4" />
Flow
</span>
<span className="text-muted-foreground/70">
{steps.length} steps {" "}
{steps.reduce((s, st) => s + st.actions.length, 0)} actions
</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
className="h-7 px-2 text-[11px]"
onClick={() => createStep()}
>
<Plus className="mr-1 h-3 w-3" />
Step
</Button>
</div>
</div>
<div
ref={containerRef}
className="relative flex-1 overflow-y-auto"
onScroll={onScroll}
>
{steps.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center p-6">
<div className="text-center">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full border">
<GitBranch className="h-6 w-6 text-muted-foreground" />
</div>
<p className="mb-2 text-sm font-medium">No steps yet</p>
<p className="text-muted-foreground mb-3 text-xs">
Create your first step to begin designing the flow.
</p>
<Button size="sm" className="h-7 px-2 text-[11px]" onClick={() => createStep()}>
<Plus className="mr-1 h-3 w-3" /> Add Step
</Button>
</div>
</div>
) : (
<SortableContext
items={steps.map((s) => sortableStepId(s.id))}
strategy={verticalListSortingStrategy}
>
<div style={{ height: totalHeight, position: "relative" }}>
{virtualItems.map(
(vi) => vi.visible && <StepRow key={vi.key} item={vi} />,
)}
</div>
</SortableContext>
)}
</div>
</div>
);
}
export default FlowWorkspace;

View File

@@ -131,7 +131,7 @@ export function BottomStatusBar({
title="Validated (hash stable)"
>
<CheckCircle2 className="mr-1 h-3 w-3" />
Validated
<span className="hidden sm:inline">Validated</span>
</Badge>
);
case "drift":
@@ -142,14 +142,14 @@ export function BottomStatusBar({
title="Drift since last validation"
>
<AlertTriangle className="mr-1 h-3 w-3" />
Drift
<span className="hidden sm:inline">Drift</span>
</Badge>
);
default:
return (
<Badge variant="outline" title="Not validated yet">
<Hash className="mr-1 h-3 w-3" />
Unvalidated
<span className="hidden sm:inline">Unvalidated</span>
</Badge>
);
}
@@ -162,7 +162,8 @@ export function BottomStatusBar({
className="border-orange-300 text-orange-600 dark:text-orange-400"
title="Unsaved changes"
>
Unsaved
<AlertTriangle className="mr-1 h-3 w-3" />
<span className="hidden sm:inline">Unsaved</span>
</Badge>
) : null;
@@ -208,7 +209,7 @@ export function BottomStatusBar({
return (
<div
className={cn(
"border-border/60 bg-muted/40 backdrop-blur supports-[backdrop-filter]:bg-muted/30",
"border-border/60 bg-muted/40 supports-[backdrop-filter]:bg-muted/30 backdrop-blur",
"flex h-10 w-full flex-shrink-0 items-center gap-3 border-t px-3 text-xs",
"font-medium",
className,
@@ -216,7 +217,7 @@ export function BottomStatusBar({
aria-label="Designer status bar"
>
{/* Left Cluster: Validation & Hash */}
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
{validationBadge}
{unsavedBadge}
{savingIndicator}
@@ -225,7 +226,7 @@ export function BottomStatusBar({
className="flex items-center gap-1 font-mono text-[11px]"
title="Current design hash"
>
<Hash className="h-3 w-3 text-muted-foreground" />
<Hash className="text-muted-foreground h-3 w-3" />
{shortHash}
{lastPersistedShort && lastPersistedShort !== shortHash && (
<span
@@ -239,20 +240,22 @@ export function BottomStatusBar({
</div>
{/* Middle Cluster: Aggregate Counts */}
<div className="flex items-center gap-3 text-muted-foreground">
<div className="text-muted-foreground flex min-w-0 items-center gap-3 truncate">
<div
className="flex items-center gap-1"
title="Steps in current design"
>
<GitBranch className="h-3 w-3" />
{steps.length} steps
{steps.length}
<span className="hidden sm:inline"> steps</span>
</div>
<div
className="flex items-center gap-1"
title="Total actions across all steps"
>
<Sparkles className="h-3 w-3" />
{actionCount} actions
{actionCount}
<span className="hidden sm:inline"> actions</span>
</div>
<div
className="hidden items-center gap-1 sm:flex"
@@ -270,7 +273,7 @@ export function BottomStatusBar({
{versionStrategy.replace(/_/g, " ")}
</div>
<div
className="hidden items-center gap-1 text-[10px] font-normal tracking-wide text-muted-foreground/80 md:flex"
className="text-muted-foreground/80 hidden items-center gap-1 text-[10px] font-normal tracking-wide md:flex"
title="Relative time since last save"
>
Saved {relSaved}
@@ -289,9 +292,10 @@ export function BottomStatusBar({
disabled={!hasUnsaved && !pendingSave}
onClick={handleSave}
aria-label="Save (s)"
title="Save (s)"
>
<Save className="mr-1 h-3 w-3" />
Save
<span className="hidden sm:inline">Save</span>
</Button>
<Button
variant="ghost"
@@ -300,14 +304,12 @@ export function BottomStatusBar({
onClick={handleValidate}
disabled={validating}
aria-label="Validate (v)"
title="Validate (v)"
>
<RefreshCw
className={cn(
"mr-1 h-3 w-3",
validating && "animate-spin",
)}
/>
Validate
<RefreshCw
className={cn("mr-1 h-3 w-3", validating && "animate-spin")}
/>
<span className="hidden sm:inline">Validate</span>
</Button>
<Button
variant="ghost"
@@ -316,9 +318,10 @@ export function BottomStatusBar({
onClick={handleExport}
disabled={exporting}
aria-label="Export (e)"
title="Export (e)"
>
<Download className="mr-1 h-3 w-3" />
Export
<span className="hidden sm:inline">Export</span>
</Button>
<Separator orientation="vertical" className="mx-1 h-4" />
<Button
@@ -327,9 +330,10 @@ export function BottomStatusBar({
className="h-7 px-2"
onClick={handlePalette}
aria-label="Command Palette (⌘K)"
title="Command Palette (⌘K)"
>
<Keyboard className="mr-1 h-3 w-3" />
Commands
<span className="hidden sm:inline">Commands</span>
</Button>
</div>
</div>

View File

@@ -115,17 +115,17 @@ export function PanelsContainer({
if (!raw) return;
const parsed = JSON.parse(raw) as PersistedLayout;
if (typeof parsed.left === "number") setLeftWidth(parsed.left);
if (typeof parsed.right === "number") setRightWidth(parsed.right);
if (typeof parsed.right === "number")
setRightWidth(Math.max(parsed.right, minRightWidth));
if (typeof parsed.leftCollapsed === "boolean") {
setLeftCollapsed(parsed.leftCollapsed);
}
if (typeof parsed.rightCollapsed === "boolean") {
setRightCollapsed(parsed.rightCollapsed);
}
// Always start with right panel visible to avoid hidden inspector state
setRightCollapsed(false);
} catch {
/* noop */
}
}, [disablePersistence]);
}, [disablePersistence, minRightWidth]);
const persist = useCallback(
(next?: Partial<PersistedLayout>) => {
@@ -172,7 +172,7 @@ export function PanelsContainer({
next = Math.max(minRightWidth, Math.min(maxRightWidth, next));
if (next !== rightWidth) {
if (frameReq.current) cancelAnimationFrame(frameReq.current);
frameReq.current = requestAnimationFrame(() => setRightWidth(next));
frameReq.current = requestAnimationFrame(() => setRightWidth(next));
}
}
},
@@ -205,7 +205,14 @@ export function PanelsContainer({
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", endDrag);
},
[leftWidth, rightWidth, leftCollapsed, rightCollapsed, onPointerMove, endDrag],
[
leftWidth,
rightWidth,
leftCollapsed,
rightCollapsed,
onPointerMove,
endDrag,
],
);
/* ------------------------------------------------------------------------ */
@@ -275,7 +282,7 @@ export function PanelsContainer({
return (
<div
className={cn(
"flex h-full w-full select-none overflow-hidden",
"flex h-full w-full overflow-hidden select-none",
className,
)}
aria-label="Designer panel layout"
@@ -284,13 +291,15 @@ export function PanelsContainer({
{hasLeft && (
<div
className={cn(
"relative flex h-full flex-shrink-0 flex-col border-r bg-background/50 transition-[width] duration-150",
leftCollapsed ? "w-0 border-r-0" : "w-[--panel-left-width]",
"bg-background/50 relative flex h-full flex-shrink-0 flex-col border-r transition-[width] duration-150",
leftCollapsed ? "w-0 border-r-0" : "w-[var(--panel-left-width)]",
)}
style={
leftCollapsed
? undefined
: ({ ["--panel-left-width" as string]: `${leftWidth}px` } as React.CSSProperties)
: ({
["--panel-left-width" as string]: `${leftWidth}px`,
} as React.CSSProperties)
}
>
{!leftCollapsed && (
@@ -303,30 +312,15 @@ export function PanelsContainer({
{hasLeft && !leftCollapsed && (
<button
type="button"
aria-label="Resize left panel (Enter to toggle collapse)"
aria-label="Resize left panel (Enter to toggle collapse)"
onPointerDown={(e) => startDrag("left", e)}
onDoubleClick={toggleLeft}
onKeyDown={(e) => handleKeyResize("left", e)}
className="hover:bg-accent/40 focus-visible:ring-ring group relative z-10 h-full w-1 cursor-col-resize outline-none focus-visible:ring-2"
>
<span className="bg-border absolute inset-y-0 left-0 w-px" />
<span className="bg-border/0 group-hover:bg-border absolute inset-y-0 right-0 w-px transition-colors" />
</button>
className="hover:bg-accent/40 focus-visible:ring-ring relative z-10 h-full w-0 cursor-col-resize px-1 outline-none focus-visible:ring-2"
/>
)}
{/* Collapse / Expand Toggle (Left) */}
{hasLeft && (
<button
type="button"
aria-label={leftCollapsed ? "Expand left panel" : "Collapse left panel"}
onClick={toggleLeft}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute top-2 z-20 rounded border bg-background/95 px-1.5 py-0.5 text-[10px] font-medium shadow-sm outline-none focus-visible:ring-2",
leftCollapsed ? "left-1" : "left-2",
)}
>
{leftCollapsed ? "»" : "«"}
</button>
)}
{/* Left collapse toggle removed to prevent breadcrumb overlap */}
{/* Center (Workspace) */}
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
@@ -337,49 +331,50 @@ export function PanelsContainer({
{hasRight && !rightCollapsed && (
<button
type="button"
aria-label="Resize right panel (Enter to toggle collapse)"
aria-label="Resize right panel (Enter to toggle collapse)"
onPointerDown={(e) => startDrag("right", e)}
onDoubleClick={toggleRight}
onKeyDown={(e) => handleKeyResize("right", e)}
className="hover:bg-accent/40 focus-visible:ring-ring group relative z-10 h-full w-1 cursor-col-resize outline-none focus-visible:ring-2"
>
<span className="bg-border absolute inset-y-0 right-0 w-px" />
<span className="bg-border/0 group-hover:bg-border absolute inset-y-0 left-0 w-px transition-colors" />
</button>
className="hover:bg-accent/40 focus-visible:ring-ring relative z-10 h-full w-1 cursor-col-resize outline-none focus-visible:ring-2"
/>
)}
{/* Right Panel */}
{hasRight && (
<div
className={cn(
"relative flex h-full flex-shrink-0 flex-col border-l bg-background/50 transition-[width] duration-150",
rightCollapsed ? "w-0 border-l-0" : "w-[--panel-right-width]",
"bg-background/50 relative flex h-full flex-shrink-0 flex-col transition-[width] duration-150",
rightCollapsed ? "w-0" : "w-[var(--panel-right-width)]",
)}
style={
rightCollapsed
? undefined
: ({ ["--panel-right-width" as string]: `${rightWidth}px` } as React.CSSProperties)
: ({
["--panel-right-width" as string]: `${rightWidth}px`,
} as React.CSSProperties)
}
>
{!rightCollapsed && (
<div className="flex-1 overflow-hidden">{right}</div>
<div className="min-w-0 flex-1 overflow-hidden">{right}</div>
)}
</div>
)}
{/* Collapse / Expand Toggle (Right) */}
{/* Minimal Right Toggle (top-right), non-intrusive like VSCode */}
{hasRight && (
<button
type="button"
aria-label={
rightCollapsed ? "Expand right panel" : "Collapse right panel"
rightCollapsed ? "Expand inspector" : "Collapse inspector"
}
onClick={toggleRight}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute top-2 z-20 rounded border bg-background/95 px-1.5 py-0.5 text-[10px] font-medium shadow-sm outline-none focus-visible:ring-2",
rightCollapsed ? "right-1" : "right-2",
"text-muted-foreground hover:text-foreground absolute top-1 z-20 p-1 text-[10px]",
rightCollapsed ? "right-1" : "right-1",
)}
title={rightCollapsed ? "Show inspector" : "Hide inspector"}
>
{rightCollapsed ? "«" : "»"}
{rightCollapsed ? "" : ""}
</button>
)}
</div>

View File

@@ -1,20 +1,4 @@
"use client";
/*
Unable to apply the requested minimal edits reliably because I don't have the authoritative line numbers for the current file contents (the editing protocol requires exact line matches with starting line numbers).
Please resend the file with line numbers (or just the specific line numbers for:
1. The DraggableAction wrapper <div> className
2. The star/favorite button block
3. The description <div>
4. The grid container for the actions list
Once I have those, I will:
- Change the grid from responsive two-column to forced single column (remove sm:grid-cols-2).
- Adjust tile layout to a slimmer vertical card, wrapping text (remove truncate, add normal wrapping or line clamp if desired).
- Move favorite star button to absolute top-right inside the tile (remove it from flow and add absolute classes).
- Optionally constrain left panel width through class (e.g., max-w-[260px]) if you want a thinner drawer.
- Ensure description wraps (replace truncate with line-clamp-3 or plain wrapping).
Let me know if you prefer line-clamp (limited lines) or full wrap.
*/
import React, {
useCallback,
@@ -48,20 +32,6 @@ import { cn } from "~/lib/utils";
import { useActionRegistry } from "../ActionRegistry";
import type { ActionDefinition } from "~/lib/experiment-designer/types";
/**
* ActionLibraryPanel
*
* Enhanced wrapper panel for the experiment designer left side:
* - Fuzzy-ish search (case-insensitive substring) over name, description, id
* - Multi-category filtering (toggle chips)
* - Favorites (local persisted)
* - Density toggle (comfortable / compact)
* - Star / unstar actions inline
* - Drag support (DndKit) identical to legacy ActionLibrary
*
* Does NOT own persistence of actions themselves—delegates to action registry.
*/
export type ActionCategory = ActionDefinition["category"];
interface FavoritesState {
@@ -109,22 +79,16 @@ function DraggableAction({
onToggleFavorite,
highlight,
}: DraggableActionProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
id: `action-${action.id}`,
data: { action },
});
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `action-${action.id}`,
data: { action },
});
const style: React.CSSProperties = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px,0)`,
}
: {};
// Disable visual translation during drag so the list does not shift items.
// We still let dnd-kit manage the drag overlay internally (no manual transform).
const style: React.CSSProperties = {};
const IconComponent =
iconMap[action.icon] ??
// fallback icon (Sparkles)
Sparkles;
const IconComponent = iconMap[action.icon] ?? Sparkles;
const categoryColors: Record<ActionCategory, string> = {
wizard: "bg-blue-500",
@@ -140,12 +104,12 @@ function DraggableAction({
{...listeners}
style={style}
className={cn(
"group bg-background/60 hover:bg-accent/50 relative flex w-full cursor-grab flex-col gap-2 rounded border px-3 transition-colors",
compact ? "py-2 text-[11px]" : "py-3 text-[12px]",
isDragging && "opacity-50",
"group bg-background/60 hover:bg-accent/50 relative flex w-full cursor-grab touch-none flex-col gap-1 rounded border px-2 transition-colors select-none",
compact ? "py-1.5 text-[11px]" : "py-2 text-[12px]",
isDragging && "ring-border opacity-60 ring-1",
)}
draggable={false}
title={action.description ?? ""}
onDragStart={(e) => e.preventDefault()}
>
<button
type="button"
@@ -162,14 +126,15 @@ function DraggableAction({
<StarOff className="h-3 w-3" />
)}
</button>
<div className="flex items-start gap-2">
<div className="flex items-start gap-2 select-none">
<div
className={cn(
"flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-white",
"flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-white",
categoryColors[action.category],
)}
>
<IconComponent className="h-3.5 w-3.5" />
<IconComponent className="h-3 w-3" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1 leading-snug font-medium">
@@ -187,7 +152,7 @@ function DraggableAction({
</span>
</div>
{action.description && !compact && (
<div className="text-muted-foreground mt-1 line-clamp-3 text-[11px] leading-snug break-words whitespace-normal">
<div className="text-muted-foreground mt-1 line-clamp-3 text-[10.5px] leading-snug break-words whitespace-normal">
{highlight
? highlightMatch(action.description, highlight)
: action.description}
@@ -199,10 +164,6 @@ function DraggableAction({
);
}
/* -------------------------------------------------------------------------- */
/* Panel Component */
/* -------------------------------------------------------------------------- */
export function ActionLibraryPanel() {
const registry = useActionRegistry();
@@ -220,7 +181,6 @@ export function ActionLibraryPanel() {
const allActions = registry.getAllActions();
/* ------------------------------- Favorites -------------------------------- */
useEffect(() => {
try {
const raw = localStorage.getItem(FAVORITES_STORAGE_KEY);
@@ -259,7 +219,6 @@ export function ActionLibraryPanel() {
[persistFavorites],
);
/* ----------------------------- Category List ------------------------------ */
const categories = useMemo(
() =>
[
@@ -281,21 +240,48 @@ export function ActionLibraryPanel() {
[],
);
const toggleCategory = useCallback((c: ActionCategory) => {
setSelectedCategories((prev) => {
const next = new Set(prev);
if (next.has(c)) {
next.delete(c);
} else {
next.add(c);
}
if (next.size === 0) {
// Keep at least one category selected
next.add(c);
}
return next;
});
}, []);
/**
* Enforce invariant:
* - Either ALL categories selected
* - Or EXACTLY ONE selected
*
* Behaviors:
* - From ALL -> clicking a category selects ONLY that category
* - From single selected -> clicking same category returns to ALL
* - From single selected -> clicking different category switches to that single
* - Any multi-subset attempt collapses to the clicked category (prevents ambiguous subset)
*/
const toggleCategory = useCallback(
(c: ActionCategory) => {
setSelectedCategories((prev) => {
const allKeys = categories.map((k) => k.key) as ActionCategory[];
const fullSize = allKeys.length;
const isFull = prev.size === fullSize;
const isSingle = prev.size === 1;
const has = prev.has(c);
// Case: full set -> reduce to single clicked
if (isFull) {
return new Set<ActionCategory>([c]);
}
// Case: single selection
if (isSingle) {
// Clicking the same => expand to all
if (has) {
return new Set<ActionCategory>(allKeys);
}
// Clicking different => switch single
return new Set<ActionCategory>([c]);
}
// (Should not normally reach: ambiguous multi-subset)
// Collapse to single clicked to restore invariant
return new Set<ActionCategory>([c]);
});
},
[categories],
);
const clearFilters = useCallback(() => {
setSelectedCategories(new Set(categories.map((c) => c.key)));
@@ -304,11 +290,9 @@ export function ActionLibraryPanel() {
}, [categories]);
useEffect(() => {
// On mount select all categories for richer initial view
setSelectedCategories(new Set(categories.map((c) => c.key)));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
/* ------------------------------- Filtering -------------------------------- */
const filtered = useMemo(() => {
const activeCats = selectedCategories;
const q = search.trim().toLowerCase();
@@ -338,9 +322,7 @@ export function ActionLibraryPanel() {
control: 0,
observation: 0,
};
for (const a of allActions) {
map[a.category] += 1;
}
for (const a of allActions) map[a.category] += 1;
return map;
}, [allActions]);
@@ -348,26 +330,51 @@ export function ActionLibraryPanel() {
filtered.some((a) => a.id === id),
).length;
/* ------------------------------- Rendering -------------------------------- */
return (
<div className="flex h-full flex-col">
{/* Toolbar */}
<div className="flex h-full max-w-[240px] flex-col overflow-hidden">
<div className="bg-background/60 border-b p-2">
<div className="mb-2 flex gap-2">
<div className="relative flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search actions"
className="h-8 pl-7 text-xs"
aria-label="Search actions"
/>
</div>
<div className="relative mb-2">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search"
className="h-8 w-full pl-7 text-xs"
aria-label="Search actions"
/>
</div>
<div className="mb-2 grid grid-cols-2 gap-1">
{categories.map((cat) => {
const active = selectedCategories.has(cat.key);
const Icon = cat.icon;
return (
<Button
key={cat.key}
variant={active ? "default" : "ghost"}
size="sm"
className={cn(
"h-7 justify-start gap-1 text-[11px]",
active && `${cat.color} text-white hover:opacity-90`,
)}
onClick={() => toggleCategory(cat.key)}
aria-pressed={active}
>
<Icon className="h-3 w-3" />
{cat.label}
<span className="ml-auto text-[10px] font-normal opacity-80">
{countsByCategory[cat.key]}
</span>
</Button>
);
})}
</div>
<div className="flex flex-wrap gap-1">
<Button
variant={showOnlyFavorites ? "default" : "outline"}
size="sm"
className="h-8"
className="h-7 min-w-[80px] flex-1"
onClick={() => setShowOnlyFavorites((s) => !s)}
aria-pressed={showOnlyFavorites}
aria-label="Toggle favorites filter"
@@ -387,7 +394,7 @@ export function ActionLibraryPanel() {
<Button
variant="outline"
size="sm"
className="h-8"
className="h-7 min-w-[80px] flex-1"
onClick={() =>
setDensity((d) =>
d === "comfortable" ? "compact" : "comfortable",
@@ -396,66 +403,41 @@ export function ActionLibraryPanel() {
aria-label="Toggle density"
>
<SlidersHorizontal className="mr-1 h-3 w-3" />
{density === "comfortable" ? "Compact" : "Comfort"}
{density === "comfortable" ? "Dense" : "Relax"}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8"
className="h-7 min-w-[60px] flex-1"
onClick={clearFilters}
aria-label="Clear filters"
>
<X className="h-3 w-3" />
Clear
</Button>
</div>
{/* Category Filters */}
<div className="grid grid-cols-4 gap-1">
{categories.map((cat) => {
const active = selectedCategories.has(cat.key);
const Icon = cat.icon;
return (
<Button
key={cat.key}
variant={active ? "default" : "ghost"}
size="sm"
className={cn(
"h-7 justify-start gap-1 truncate text-[11px]",
active && `${cat.color} text-white hover:opacity-90`,
)}
onClick={() => toggleCategory(cat.key)}
aria-pressed={active}
>
<Icon className="h-3 w-3" />
{cat.label}
<span className="ml-auto text-[10px] font-normal opacity-80">
{countsByCategory[cat.key]}
</span>
</Button>
);
})}
</div>
<div className="text-muted-foreground mt-2 flex items-center justify-between text-[10px]">
<div>
{filtered.length} shown / {allActions.length} total
{filtered.length} / {allActions.length}
</div>
<div className="flex items-center gap-1">
<FolderPlus className="h-3 w-3" />
<span>
Plugins: {registry.getDebugInfo().pluginActionsLoaded ? "✓" : "…"}
{registry.getDebugInfo().pluginActionsLoaded
? "Plugins ✓"
: "Plugins …"}
</span>
</div>
</div>
</div>
{/* Actions List */}
<ScrollArea className="flex-1">
<div className="grid grid-cols-1 gap-2 p-2">
<ScrollArea className="flex-1 overflow-x-hidden overflow-y-auto">
<div className="flex flex-col gap-2 p-2">
{filtered.length === 0 ? (
<div className="text-muted-foreground/70 flex flex-col items-center gap-2 py-10 text-center text-xs">
<Filter className="h-6 w-6" />
<div>No actions match filters</div>
<div>No actions</div>
</div>
) : (
filtered.map((action) => (
@@ -472,7 +454,6 @@ export function ActionLibraryPanel() {
</div>
</ScrollArea>
{/* Footer Summary */}
<div className="bg-background/60 border-t p-2">
<div className="flex items-center justify-between text-[10px]">
<div className="flex items-center gap-2">
@@ -481,7 +462,7 @@ export function ActionLibraryPanel() {
</Badge>
{showOnlyFavorites && (
<Badge variant="outline" className="h-4 px-1 text-[10px]">
{visibleFavoritesCount} favorites
{visibleFavoritesCount} fav
</Badge>
)}
</div>
@@ -491,9 +472,8 @@ export function ActionLibraryPanel() {
</div>
</div>
<Separator className="my-1" />
<p className="text-muted-foreground text-[9px] leading-relaxed">
Drag actions into the flow. Use search / category filters to narrow
results. Star actions you use frequently.
<p className="text-muted-foreground hidden text-[9px] leading-relaxed md:block">
Drag actions into the flow. Star frequent actions.
</p>
</div>
</div>

View File

@@ -89,9 +89,21 @@ export function InspectorPanel({
/* ------------------------------------------------------------------------ */
/* Local Active Tab State (uncontrolled mode) */
/* ------------------------------------------------------------------------ */
const INSPECTOR_TAB_STORAGE_KEY = "hristudio-designer-inspector-tab-v1";
const [internalTab, setInternalTab] = useState<
"properties" | "issues" | "dependencies"
>(() => {
try {
const raw =
typeof window !== "undefined"
? localStorage.getItem(INSPECTOR_TAB_STORAGE_KEY)
: null;
if (raw === "properties" || raw === "issues" || raw === "dependencies") {
return raw;
}
} catch {
/* noop */
}
if (selectedStepId) return "properties";
return "issues";
});
@@ -103,6 +115,25 @@ export function InspectorPanel({
if (!autoFocusOnSelection) return;
if (selectedStepId || selectedActionId) {
setInternalTab("properties");
// Scroll properties panel to top and focus first field
requestAnimationFrame(() => {
const activeTabpanel = document.querySelector(
'[role="tabpanel"][data-state="active"]',
);
if (!(activeTabpanel instanceof HTMLElement)) return;
const viewportEl = activeTabpanel.querySelector(
'[data-slot="scroll-area-viewport"]',
);
if (viewportEl instanceof HTMLElement) {
viewportEl.scrollTop = 0;
const firstField = viewportEl.querySelector(
"input, select, textarea, button",
);
if (firstField instanceof HTMLElement) {
firstField.focus();
}
}
});
}
}, [selectedStepId, selectedActionId, autoFocusOnSelection]);
@@ -113,6 +144,11 @@ export function InspectorPanel({
onTabChange?.(val);
} else {
setInternalTab(val);
try {
localStorage.setItem(INSPECTOR_TAB_STORAGE_KEY, val);
} catch {
/* noop */
}
}
}
},
@@ -164,9 +200,12 @@ export function InspectorPanel({
return (
<div
className={cn(
"bg-background/40 flex h-full flex-col border-l backdrop-blur-sm",
"bg-background/40 border-border relative flex h-full min-w-0 flex-col overflow-hidden border-l backdrop-blur-sm",
className,
)}
style={{ contain: "layout paint size" }}
role="complementary"
aria-label="Inspector panel"
>
{/* Tab Header */}
<div className="border-b px-2 py-1.5">
@@ -175,41 +214,41 @@ export function InspectorPanel({
onValueChange={handleTabChange}
className="w-full"
>
<TabsList className="grid h-8 grid-cols-3">
<TabsList className="flex h-9 w-full items-center gap-1 overflow-hidden">
<TabsTrigger
value="properties"
className="flex items-center gap-1 text-[11px]"
className="flex min-w-0 flex-1 items-center justify-center gap-1 truncate text-[11px]"
title="Properties (Step / Action)"
>
<Settings className="h-3 w-3" />
<Settings className="h-3 w-3 flex-shrink-0" />
<span className="hidden sm:inline">Props</span>
</TabsTrigger>
<TabsTrigger
value="issues"
className="flex items-center gap-1 text-[11px]"
className="flex min-w-0 flex-1 items-center justify-center gap-1 truncate text-[11px]"
title="Validation Issues"
>
<AlertTriangle className="h-3 w-3" />
<AlertTriangle className="h-3 w-3 flex-shrink-0" />
<span className="hidden sm:inline">
Issues{issueCount > 0 ? ` (${issueCount})` : ""}
</span>
{issueCount > 0 && (
<span className="text-amber-600 sm:hidden dark:text-amber-400">
<span className="xs:hidden text-amber-600 dark:text-amber-400">
{issueCount}
</span>
)}
</TabsTrigger>
<TabsTrigger
value="dependencies"
className="flex items-center gap-1 text-[11px]"
className="flex min-w-0 flex-1 items-center justify-center gap-1 truncate text-[11px]"
title="Dependencies / Drift"
>
<PackageSearch className="h-3 w-3" />
<PackageSearch className="h-3 w-3 flex-shrink-0" />
<span className="hidden sm:inline">
Deps{driftCount > 0 ? ` (${driftCount})` : ""}
</span>
{driftCount > 0 && (
<span className="text-purple-600 sm:hidden dark:text-purple-400">
<span className="xs:hidden text-purple-600 dark:text-purple-400">
{driftCount}
</span>
)}
@@ -220,11 +259,15 @@ export function InspectorPanel({
{/* Content */}
<div className="flex min-h-0 flex-1 flex-col">
{/*
Force consistent width for tab bodies to prevent reflow when
switching between content with different intrinsic widths.
*/}
<Tabs value={effectiveTab}>
{/* Properties */}
<TabsContent
value="properties"
className="m-0 flex h-full flex-col data-[state=inactive]:hidden"
className="m-0 flex min-h-0 flex-1 flex-col data-[state=inactive]:hidden"
>
{propertiesEmpty ? (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 p-4 text-center">
@@ -240,7 +283,7 @@ export function InspectorPanel({
</div>
) : (
<ScrollArea className="flex-1">
<div className="p-3">
<div className="w-full px-3 py-3">
<PropertiesPanel
design={{
id: "design",
@@ -263,35 +306,46 @@ export function InspectorPanel({
{/* Issues */}
<TabsContent
value="issues"
className="m-0 flex h-full flex-col data-[state=inactive]:hidden"
className="m-0 flex min-h-0 flex-1 flex-col data-[state=inactive]:hidden"
>
<ScrollArea className="flex-1">
<div className="p-3">
<ValidationPanel
issues={validationIssues}
onIssueClick={(issue) => {
if (issue.stepId) {
selectStep(issue.stepId);
if (issue.actionId) {
selectAction(issue.stepId, issue.actionId);
if (autoFocusOnSelection) {
handleTabChange("properties");
}
}
}
}}
/>
</div>
</ScrollArea>
<ValidationPanel
issues={validationIssues}
entityLabelForId={(entityId) => {
if (entityId.startsWith("action-")) {
for (const s of steps) {
const a = s.actions.find((x) => x.id === entityId);
if (a) return `${a.name}${s.name}`;
}
}
if (entityId.startsWith("step-")) {
const st = steps.find((s) => s.id === entityId);
if (st) return st.name;
}
return "Unknown";
}}
onIssueClick={(issue) => {
if (issue.stepId) {
selectStep(issue.stepId);
if (issue.actionId) {
selectAction(issue.stepId, issue.actionId);
} else {
selectAction(issue.stepId, undefined);
}
if (autoFocusOnSelection) {
handleTabChange("properties");
}
}
}}
/>
</TabsContent>
{/* Dependencies */}
<TabsContent
value="dependencies"
className="m-0 flex h-full flex-col data-[state=inactive]:hidden"
className="m-0 flex min-h-0 flex-1 flex-col data-[state=inactive]:hidden"
>
<ScrollArea className="flex-1">
<div className="p-3">
<div className="w-full px-3 py-3">
<DependencyInspector
steps={steps}
actionSignatureDrift={actionSignatureDrift}

View File

@@ -12,7 +12,6 @@
import type {
ExperimentStep,
ExperimentAction,
ActionDefinition,
TriggerType,
StepType,
@@ -69,7 +68,7 @@ const VALID_TRIGGER_TYPES: TriggerType[] = [
export function validateStructural(
steps: ExperimentStep[],
context: ValidationContext,
_context: ValidationContext,
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
@@ -189,7 +188,7 @@ export function validateStructural(
}
// Action-level structural validation
step.actions.forEach((action, actionIndex) => {
step.actions.forEach((action) => {
const actionId = action.id;
// Action name validation
@@ -423,7 +422,10 @@ export function validateParameters(
field,
stepId,
actionId,
suggestion: `Choose from: ${paramDef.options.join(", ")}`,
suggestion:
Array.isArray(paramDef.options) && paramDef.options.length
? `Choose from: ${paramDef.options.join(", ")}`
: "Choose a valid option",
});
}
break;
@@ -472,7 +474,7 @@ export function validateParameters(
export function validateSemantic(
steps: ExperimentStep[],
context: ValidationContext,
_context: ValidationContext,
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
@@ -629,7 +631,7 @@ export function validateSemantic(
export function validateExecution(
steps: ExperimentStep[],
context: ValidationContext,
_context: ValidationContext,
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
@@ -720,7 +722,7 @@ export function groupIssuesByEntity(
const grouped: Record<string, ValidationIssue[]> = {};
issues.forEach((issue) => {
const entityId = issue.actionId || issue.stepId || "experiment";
const entityId = issue.actionId ?? issue.stepId ?? "experiment";
if (!grouped[entityId]) {
grouped[entityId] = [];
}

View File

@@ -28,7 +28,7 @@ function mapStepTypeToDatabase(
): "wizard" | "robot" | "parallel" | "conditional" {
switch (stepType) {
case "sequential":
return "wizard"; // Default to wizard for sequential
return "wizard";
case "parallel":
return "parallel";
case "conditional":