From 388897c70e99398a6559dc0d4e4f8a113e3ed369 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Tue, 3 Feb 2026 13:58:47 -0500 Subject: [PATCH] feat: Implement collapsible left and right panels with dynamic column spanning, updated styling, and integrated a bottom status bar in the DesignerRoot. --- robot-plugins | 2 +- scripts/check-db.ts | 65 ++ scripts/seed-dev.ts | 282 ++++++--- .../experiments/designer/ActionRegistry.ts | 47 +- .../experiments/designer/DesignerRoot.tsx | 290 +++++++-- .../experiments/designer/PropertiesPanel.tsx | 7 +- .../experiments/designer/ValidationPanel.tsx | 33 +- .../designer/flow/FlowWorkspace.tsx | 558 ++++++++++++------ .../designer/layout/BottomStatusBar.tsx | 268 ++------- .../designer/layout/PanelsContainer.tsx | 22 + .../designer/panels/InspectorPanel.tsx | 6 + .../experiments/designer/state/store.ts | 2 - .../experiments/designer/state/validators.ts | 78 +-- .../experiment-designer/block-converter.ts | 86 +++ src/lib/experiment-designer/types.ts | 19 +- src/server/api/routers/experiments.ts | 6 +- src/styles/globals.css | 95 ++- 17 files changed, 1147 insertions(+), 719 deletions(-) create mode 100644 scripts/check-db.ts diff --git a/robot-plugins b/robot-plugins index c6310d3..d554891 160000 --- a/robot-plugins +++ b/robot-plugins @@ -1 +1 @@ -Subproject commit c6310d3144d55b5e5455066fd1d35db426bfddb0 +Subproject commit d554891dab32094b41e63e09c6e22149a08ac9a1 diff --git a/scripts/check-db.ts b/scripts/check-db.ts new file mode 100644 index 0000000..3df2cd9 --- /dev/null +++ b/scripts/check-db.ts @@ -0,0 +1,65 @@ + +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import * as schema from "../src/server/db/schema"; +import { eq } from "drizzle-orm"; + +const connectionString = process.env.DATABASE_URL!; +const connection = postgres(connectionString); +const db = drizzle(connection, { schema }); + +async function main() { + console.log("πŸ” Checking Database State..."); + + // 1. Check Plugin + const plugins = await db.query.plugins.findMany(); + console.log(`\nFound ${plugins.length} plugins.`); + + const expectedKeys = new Set(); + + for (const p of plugins) { + const meta = p.metadata as any; + const defs = p.actionDefinitions as any[]; + + console.log(`Plugin [${p.name}] (ID: ${p.id}):`); + console.log(` - Robot ID (Column): ${p.robotId}`); + console.log(` - Metadata.robotId: ${meta?.robotId}`); + console.log(` - Action Definitions: ${defs?.length ?? 0} found.`); + + if (defs && meta?.robotId) { + defs.forEach(d => { + const key = `${meta.robotId}.${d.id}`; + expectedKeys.add(key); + // console.log(` -> Registers: ${key}`); + }); + } + } + + // 2. Check Actions + const actions = await db.query.actions.findMany(); + console.log(`\nFound ${actions.length} actions.`); + let errorCount = 0; + for (const a of actions) { + // Only check plugin actions + if (a.sourceKind === 'plugin' || a.type.includes(".")) { + const isRegistered = expectedKeys.has(a.type); + const pluginIdMatch = a.pluginId === 'nao6-ros2'; + + console.log(`Action [${a.name}] (Type: ${a.type}):`); + console.log(` - PluginId: ${a.pluginId} ${pluginIdMatch ? 'βœ…' : '❌'}`); + console.log(` - In Registry: ${isRegistered ? 'βœ…' : '❌'}`); + + if (!isRegistered || !pluginIdMatch) errorCount++; + } + } + + if (errorCount > 0) { + console.log(`\n❌ Found ${errorCount} actions with issues.`); + } else { + console.log("\nβœ… All plugin actions validated successfully against registry definitions."); + } + + await connection.end(); +} + +main().catch(console.error); diff --git a/scripts/seed-dev.ts b/scripts/seed-dev.ts index 6f21ec4..c926ffd 100755 --- a/scripts/seed-dev.ts +++ b/scripts/seed-dev.ts @@ -159,6 +159,7 @@ async function main() { status: "ready", robotId: naoRobot!.id, createdBy: adminUser.id, + // visualDesign will be auto-generated by designer from DB steps }).returning(); // 5. Create Steps & Actions (The Interactive Storyteller Protocol) @@ -168,98 +169,116 @@ async function main() { const [step1] = await db.insert(schema.steps).values({ experimentId: experiment!.id, name: "The Hook", - description: "Initial greeting and engagement", + description: "Initial greeting and story introduction", type: "robot", orderIndex: 0, required: true, - durationEstimate: 30 + durationEstimate: 25 }).returning(); await db.insert(schema.actions).values([ { stepId: step1!.id, - name: "Greet Participant", - type: "nao6-ros2.say_with_emotion", + name: "Introduce Story", + type: "nao6-ros2.say_text", orderIndex: 0, - parameters: { text: "Hello there! I have a wonderful story to share with you today.", emotion: "happy", speed: 1.0 }, - pluginId: naoPlugin!.id, + parameters: { text: "Hello. I have a story to tell you about a space traveler. Are you ready?" }, + pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2", + pluginVersion: "2.1.0", category: "interaction", retryable: true }, { stepId: step1!.id, - name: "Wave Greeting", + name: "Welcome Gesture", type: "nao6-ros2.move_arm", orderIndex: 1, - // Raising right arm to wave position + // Open hand/welcome position parameters: { arm: "right", - shoulder_pitch: -1.0, - shoulder_roll: -0.3, - elbow_yaw: 1.5, - elbow_roll: 0.5, - speed: 0.5 + shoulder_pitch: 1.0, + shoulder_roll: -0.2, + elbow_yaw: 0.5, + elbow_roll: -0.4, + speed: 0.4 }, - pluginId: naoPlugin!.id, + pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2", + pluginVersion: "2.1.0", category: "movement", retryable: true } ]); - // --- Step 2: The Narrative (Part 1) --- + // --- Step 2: The Narrative --- const [step2] = await db.insert(schema.steps).values({ experimentId: experiment!.id, - name: "The Narrative - Part 1", - description: "Robot tells the first part of the story", + name: "The Narrative", + description: "Robot tells the space traveler story with gaze behavior", type: "robot", orderIndex: 1, required: true, - durationEstimate: 60 - }).returning(); - - await db.insert(schema.actions).values([ - { - stepId: step2!.id, - name: "Tell Story Part 1", - type: "nao6-ros2.say_text", - orderIndex: 0, - parameters: { text: "Once upon a time, in a land far away, there lived a curious robot named Alpha." }, - pluginId: naoPlugin!.id, - category: "interaction" - }, - { - stepId: step2!.id, - name: "Look at Audience", - type: "nao6-ros2.move_head", - orderIndex: 1, - parameters: { yaw: 0.0, pitch: -0.2, speed: 0.5 }, - pluginId: naoPlugin!.id, - category: "movement" - } - ]); - - // --- Step 3: Comprehension Check (Wizard Decision) --- - // Note: In a real visual designer, this would be a Branch/Conditional. - // Here we model it as a Wizard Step where they explicitly choose the next robot action. - const [step3] = await db.insert(schema.steps).values({ - experimentId: experiment!.id, - name: "Comprehension Check", - description: "Wizard verifies participant understanding", - type: "wizard", - orderIndex: 2, - required: true, durationEstimate: 45 }).returning(); + await db.insert(schema.actions).values([ + { + stepId: step2!.id, + name: "Tell Story", + type: "nao6-ros2.say_text", + orderIndex: 0, + parameters: { text: "The traveler flew to Mars. He found a red rock that glowed in the dark. He put it in his pocket." }, + pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2", + pluginVersion: "2.1.0", + category: "interaction", + retryable: true + }, + { + stepId: step2!.id, + name: "Look Away (Thinking)", + type: "nao6-ros2.turn_head", + orderIndex: 1, + parameters: { yaw: 1.5, pitch: 0.0, speed: 0.3 }, + pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2", + pluginVersion: "2.1.0", + category: "movement", + retryable: true + }, + { + stepId: step2!.id, + name: "Look Back at Participant", + type: "nao6-ros2.turn_head", + orderIndex: 2, + parameters: { yaw: 0.0, pitch: -0.1, speed: 0.4 }, + pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2", + pluginVersion: "2.1.0", + category: "movement", + retryable: true + } + ]); + + // --- Step 3: Comprehension Check (Wizard Decision Point) --- + // Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect) + const [step3] = await db.insert(schema.steps).values({ + experimentId: experiment!.id, + name: "Comprehension Check", + description: "Ask participant about rock color and wait for wizard input", + type: "wizard", + orderIndex: 2, + required: true, + durationEstimate: 30 + }).returning(); + await db.insert(schema.actions).values([ { stepId: step3!.id, name: "Ask Question", - type: "nao6-ros2.say_with_emotion", + type: "nao6-ros2.say_text", orderIndex: 0, - parameters: { text: "Did you understand the story so far?", emotion: "happy", speed: 1.0 }, - pluginId: naoPlugin!.id, - category: "interaction" + parameters: { text: "What color was the rock the traveler found?" }, + pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2", + pluginVersion: "2.1.0", + category: "interaction", + retryable: true }, { stepId: step3!.id, @@ -267,7 +286,7 @@ async function main() { type: "wizard_wait_for_response", orderIndex: 1, parameters: { - prompt_text: "Did participant answer 'Alpha'?", + prompt_text: "Did participant answer 'Red' correctly?", response_type: "verbal", timeout: 60 }, @@ -276,36 +295,108 @@ async function main() { } ]); - // --- Step 4: Feedback (Positive/Negative branches implied) --- - // For linear seed, we just add the Positive feedback step - const [step4] = await db.insert(schema.steps).values({ + // --- Step 4a: Correct Response Branch --- + const [step4a] = await db.insert(schema.steps).values({ experimentId: experiment!.id, - name: "Positive Feedback", - description: "Correct answer response", + name: "Branch A: Correct Response", + description: "Response when participant says 'Red'", type: "robot", orderIndex: 3, - required: true, - durationEstimate: 15 + required: false, + durationEstimate: 20 }).returning(); await db.insert(schema.actions).values([ { - stepId: step4!.id, - name: "Express Agreement", + stepId: step4a!.id, + name: "Confirm Correct Answer", type: "nao6-ros2.say_with_emotion", orderIndex: 0, - parameters: { text: "Yes, exactly!", emotion: "happy", speed: 1.0 }, - pluginId: naoPlugin!.id, - category: "interaction" + parameters: { text: "Yes! It was a glowing red rock.", emotion: "happy", speed: 1.0 }, + pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2", + pluginVersion: "2.1.0", + category: "interaction", + retryable: true }, { - stepId: step4!.id, - name: "Say Correct", - type: "nao6-ros2.say_text", + stepId: step4a!.id, + name: "Nod Head", + type: "nao6-ros2.turn_head", orderIndex: 1, - parameters: { text: "That is correct! Well done." }, - pluginId: naoPlugin!.id, - category: "interaction" + parameters: { yaw: 0.0, pitch: -0.3, speed: 0.5 }, + pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2", + pluginVersion: "2.1.0", + category: "movement", + retryable: true + }, + { + stepId: step4a!.id, + name: "Return to Neutral", + type: "nao6-ros2.turn_head", + orderIndex: 2, + parameters: { yaw: 0.0, pitch: 0.0, speed: 0.4 }, + pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2", + pluginVersion: "2.1.0", + category: "movement", + retryable: true + } + ]); + + // --- Step 4b: Incorrect Response Branch --- + const [step4b] = await db.insert(schema.steps).values({ + experimentId: experiment!.id, + name: "Branch B: Incorrect Response", + description: "Response when participant gives wrong answer", + type: "robot", + orderIndex: 4, + required: false, + durationEstimate: 20 + }).returning(); + + await db.insert(schema.actions).values([ + { + stepId: step4b!.id, + name: "Correct Participant", + type: "nao6-ros2.say_text", + orderIndex: 0, + parameters: { text: "Actually, it was red." }, + pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2", + pluginVersion: "2.1.0", + category: "interaction", + retryable: true + }, + { + stepId: step4b!.id, + name: "Shake Head (Left)", + type: "nao6-ros2.turn_head", + orderIndex: 1, + parameters: { yaw: -0.5, pitch: 0.0, speed: 0.5 }, + pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2", + pluginVersion: "2.1.0", + category: "movement", + retryable: true + }, + { + stepId: step4b!.id, + name: "Shake Head (Right)", + type: "nao6-ros2.turn_head", + orderIndex: 2, + parameters: { yaw: 0.5, pitch: 0.0, speed: 0.5 }, + pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2", + pluginVersion: "2.1.0", + category: "movement", + retryable: true + }, + { + stepId: step4b!.id, + name: "Return to Center", + type: "nao6-ros2.turn_head", + orderIndex: 3, + parameters: { yaw: 0.0, pitch: 0.0, speed: 0.4 }, + pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2", + pluginVersion: "2.1.0", + category: "movement", + retryable: true } ]); @@ -313,31 +404,42 @@ async function main() { const [step5] = await db.insert(schema.steps).values({ experimentId: experiment!.id, name: "Conclusion", - description: "Wrap up the story", + description: "End the story and thank participant", type: "robot", - orderIndex: 4, + orderIndex: 5, required: true, - durationEstimate: 30 + durationEstimate: 25 }).returning(); await db.insert(schema.actions).values([ { stepId: step5!.id, - name: "Finish Story", + name: "End Story", type: "nao6-ros2.say_text", orderIndex: 0, - parameters: { text: "Alpha explored the world and learned many things. The end." }, - pluginId: naoPlugin!.id, - category: "interaction" + parameters: { text: "The End. Thank you for listening." }, + pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2", + pluginVersion: "2.1.0", + category: "interaction", + retryable: true }, { stepId: step5!.id, - name: "Say Goodbye", - type: "nao6-ros2.say_with_emotion", + name: "Bow Gesture", + type: "nao6-ros2.move_arm", orderIndex: 1, - parameters: { text: "Goodbye everyone!", emotion: "happy", speed: 1.0 }, - pluginId: naoPlugin!.id, - category: "interaction" + parameters: { + arm: "right", + shoulder_pitch: 1.8, + shoulder_roll: 0.1, + elbow_yaw: 0.0, + elbow_roll: -0.3, + speed: 0.3 + }, + pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2", + pluginVersion: "2.1.0", + category: "movement", + retryable: true } ]); @@ -360,7 +462,13 @@ async function main() { console.log(`Summary:`); console.log(`- 1 Admin User (sean@soconnor.dev)`); console.log(`- Study: 'Comparative WoZ Study'`); - console.log(`- Experiment: 'The Interactive Storyteller' (${5} steps created)`); + console.log(`- Experiment: 'The Interactive Storyteller' (6 steps created)`); + console.log(` - Step 1: The Hook (greeting + welcome gesture)`); + console.log(` - Step 2: The Narrative (story + gaze sequence)`); + console.log(` - Step 3: Comprehension Check (question + wizard wait)`); + console.log(` - Step 4a: Branch A - Correct Response (affirmation + nod)`); + console.log(` - Step 4b: Branch B - Incorrect Response (correction + head shake)`); + console.log(` - Step 5: Conclusion (ending + bow)`); console.log(`- ${insertedParticipants.length} Participants`); } catch (error) { diff --git a/src/components/experiments/designer/ActionRegistry.ts b/src/components/experiments/designer/ActionRegistry.ts index 1733098..b79f9ac 100755 --- a/src/components/experiments/designer/ActionRegistry.ts +++ b/src/components/experiments/designer/ActionRegistry.ts @@ -284,46 +284,8 @@ export class ActionRegistry { loadPluginActions( studyId: string, - studyPlugins: Array<{ - plugin: { - id: string; - robotId: string | null; - version: string | null; - actionDefinitions?: Array<{ - id: string; - name: string; - description?: string; - category?: string; - icon?: string; - timeout?: number; - retryable?: boolean; - aliases?: string[]; - parameterSchema?: unknown; - ros2?: { - topic?: string; - messageType?: string; - service?: string; - action?: string; - payloadMapping?: unknown; - qos?: { - reliability?: string; - durability?: string; - history?: string; - depth?: number; - }; - }; - rest?: { - method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; - path: string; - headers?: Record; - }; - }>; - metadata?: Record; - }; - }>, + studyPlugins: any[], ): void { - // console.log("ActionRegistry.loadPluginActions called with:", { studyId, pluginCount: studyPlugins?.length ?? 0 }); - if (this.pluginActionsLoaded && this.loadedStudyId === studyId) return; if (this.loadedStudyId !== studyId) { @@ -332,17 +294,14 @@ export class ActionRegistry { let totalActionsLoaded = 0; - (studyPlugins ?? []).forEach((studyPlugin) => { - const { plugin } = studyPlugin; + (studyPlugins ?? []).forEach((plugin) => { const actionDefs = Array.isArray(plugin.actionDefinitions) ? plugin.actionDefinitions : undefined; - // console.log(`Plugin ${plugin.id}:`, { actionCount: actionDefs?.length ?? 0 }); - if (!actionDefs) return; - actionDefs.forEach((action) => { + actionDefs.forEach((action: any) => { const rawCategory = typeof action.category === "string" ? action.category.toLowerCase().trim() diff --git a/src/components/experiments/designer/DesignerRoot.tsx b/src/components/experiments/designer/DesignerRoot.tsx index 67bb398..aa40339 100755 --- a/src/components/experiments/designer/DesignerRoot.tsx +++ b/src/components/experiments/designer/DesignerRoot.tsx @@ -37,7 +37,7 @@ import { MouseSensor, TouchSensor, KeyboardSensor, - closestCorners, + closestCenter, type DragEndEvent, type DragStartEvent, type DragOverEvent, @@ -45,7 +45,8 @@ import { import { BottomStatusBar } from "./layout/BottomStatusBar"; import { ActionLibraryPanel } from "./panels/ActionLibraryPanel"; import { InspectorPanel } from "./panels/InspectorPanel"; -import { FlowWorkspace } from "./flow/FlowWorkspace"; +import { FlowWorkspace, SortableActionChip, StepCardPreview } from "./flow/FlowWorkspace"; +import { GripVertical } from "lucide-react"; import { type ExperimentDesign, @@ -54,12 +55,13 @@ import { } from "~/lib/experiment-designer/types"; import { useDesignerStore } from "./state/store"; -import { actionRegistry } from "./ActionRegistry"; +import { actionRegistry, useActionRegistry } from "./ActionRegistry"; import { computeDesignHash } from "./state/hashing"; import { validateExperimentDesign, groupIssuesByEntity, } from "./state/validators"; +import { convertDatabaseToSteps } from "~/lib/experiment-designer/block-converter"; /** * DesignerRoot @@ -104,6 +106,7 @@ interface RawExperiment { integrityHash?: string | null; pluginDependencies?: string[] | null; visualDesign?: unknown; + steps?: unknown[]; // DB steps from relation } /* -------------------------------------------------------------------------- */ @@ -111,6 +114,26 @@ interface RawExperiment { /* -------------------------------------------------------------------------- */ function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined { + // 1. Prefer database steps (Source of Truth) if valid, to ensure we have the latest + // plugin provenance data (which might be missing from stale visualDesign snapshots). + if (Array.isArray(exp.steps) && exp.steps.length > 0) { + try { + // console.log('[DesignerRoot] Hydrating design from Database Steps (Source of Truth)'); + const dbSteps = convertDatabaseToSteps(exp.steps); + return { + id: exp.id, + name: exp.name, + description: exp.description ?? "", + steps: dbSteps, + version: 1, // Reset version on re-hydration + lastSaved: new Date(), + }; + } catch (err) { + console.warn('[DesignerRoot] Failed to convert DB steps, falling back to visualDesign:', err); + } + } + + // 2. Fallback to visualDesign blob if DB steps unavailable or conversion failed if ( !exp.visualDesign || typeof exp.visualDesign !== "object" || @@ -124,6 +147,7 @@ function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined { lastSaved?: string; }; if (!Array.isArray(vd.steps)) return undefined; + return { id: exp.id, name: exp.name, @@ -162,6 +186,9 @@ export function DesignerRoot({ autoCompile = true, onPersist, }: DesignerRootProps) { + // Subscribe to registry updates to ensure re-renders when actions load + useActionRegistry(); + const { startTour } = useTour(); /* ----------------------------- Remote Experiment ------------------------- */ @@ -169,7 +196,18 @@ export function DesignerRoot({ data: experiment, isLoading: loadingExperiment, refetch: refetchExperiment, - } = api.experiments.get.useQuery({ id: experimentId }); + } = api.experiments.get.useQuery( + { id: experimentId }, + { + // Debug Mode: Disable all caching to ensure fresh data from DB + refetchOnMount: true, + refetchOnWindowFocus: true, + staleTime: 0, + gcTime: 0, // Garbage collect immediately + } + ); + + const updateExperiment = api.experiments.update.useMutation({ onError: (err) => { @@ -209,6 +247,7 @@ export function DesignerRoot({ const upsertAction = useDesignerStore((s) => s.upsertAction); const selectStep = useDesignerStore((s) => s.selectStep); const selectAction = useDesignerStore((s) => s.selectAction); + const reorderStep = useDesignerStore((s) => s.reorderStep); const setValidationIssues = useDesignerStore((s) => s.setValidationIssues); const clearAllValidationIssues = useDesignerStore( (s) => s.clearAllValidationIssues, @@ -296,6 +335,11 @@ export function DesignerRoot({ description?: string; } | null>(null); + const [activeSortableItem, setActiveSortableItem] = useState<{ + type: 'step' | 'action'; + data: any; + } | null>(null); + /* ----------------------------- Initialization ---------------------------- */ useEffect(() => { if (initialized) return; @@ -354,13 +398,14 @@ export function DesignerRoot({ .catch((err) => console.error("Core action load failed:", err)); }, []); - // Load plugin actions when study plugins available + // Load plugin actions only after we have the flattened, processed plugin list useEffect(() => { if (!experiment?.studyId) return; - 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]); + if (!studyPlugins) return; + + // Pass the flattened plugins which match the structure ActionRegistry expects + actionRegistry.loadPluginActions(experiment.studyId, studyPlugins); + }, [experiment?.studyId, studyPlugins]); /* ------------------------- Ready State Management ------------------------ */ // Mark as ready once initialized and plugins are loaded @@ -375,11 +420,10 @@ export function DesignerRoot({ // 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]); + }, [initialized, isReady, studyPlugins]); /* ----------------------- Automatic Hash Recomputation -------------------- */ // Automatically recompute hash when steps change (debounced to avoid excessive computation) @@ -442,6 +486,7 @@ export function DesignerRoot({ const currentSteps = [...steps]; // Ensure core actions are loaded before validating await actionRegistry.loadCoreActions(); + const result = validateExperimentDesign(currentSteps, { steps: currentSteps, actionDefinitions: actionRegistry.getAllActions(), @@ -509,6 +554,15 @@ export function DesignerRoot({ clearAllValidationIssues, ]); + // Trigger initial validation when ready (plugins loaded) to ensure no stale errors + // Trigger initial validation when ready (plugins loaded) to ensure no stale errors + // DISABLED: User prefers manual validation to avoid noise on improved sequential architecture + // useEffect(() => { + // if (isReady) { + // void validateDesign(); + // } + // }, [isReady, validateDesign]); + /* --------------------------------- Save ---------------------------------- */ const persist = useCallback(async () => { if (!initialized) return; @@ -691,15 +745,21 @@ export function DesignerRoot({ useSensor(KeyboardSensor), ); + /* ----------------------------- Drag Handlers ----------------------------- */ /* ----------------------------- Drag Handlers ----------------------------- */ const handleDragStart = useCallback( (event: DragStartEvent) => { const { active } = event; + const activeId = active.id.toString(); + const activeData = active.data.current; + + console.log("[DesignerRoot] DragStart", { activeId, activeData }); + if ( - active.id.toString().startsWith("action-") && - active.data.current?.action + activeId.startsWith("action-") && + activeData?.action ) { - const a = active.data.current.action as { + const a = activeData.action as { id: string; name: string; category: string; @@ -713,6 +773,18 @@ export function DesignerRoot({ category: a.category, description: a.description, }); + } else if (activeId.startsWith("s-step-")) { + console.log("[DesignerRoot] Setting active sortable STEP", activeData); + setActiveSortableItem({ + type: 'step', + data: activeData + }); + } else if (activeId.startsWith("s-act-")) { + console.log("[DesignerRoot] Setting active sortable ACTION", activeData); + setActiveSortableItem({ + type: 'action', + data: activeData + }); } }, [toggleLibraryScrollLock], @@ -721,14 +793,7 @@ export function DesignerRoot({ 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; - } + const activeId = active.id.toString(); if (!over) { if (store.insertionProjection) { @@ -737,6 +802,16 @@ export function DesignerRoot({ return; } + // 3. Library -> Flow Projection (Action) + if (!activeId.startsWith("action-")) { + if (store.insertionProjection) { + store.setInsertionProjection(null); + } + return; + } + + + const overId = over.id.toString(); const activeDef = active.data.current?.action; @@ -831,6 +906,7 @@ export function DesignerRoot({ // Clear overlay immediately toggleLibraryScrollLock(false); setDragOverlayAction(null); + setActiveSortableItem(null); // Capture and clear projection const store = useDesignerStore.getState(); @@ -841,6 +917,32 @@ export function DesignerRoot({ return; } + const activeId = active.id.toString(); + + // Handle Step Reordering (Active is a sortable step) + if (activeId.startsWith("s-step-")) { + const overId = over.id.toString(); + // Allow reordering over both sortable steps (s-step-) and drop zones (step-) + if (!overId.startsWith("s-step-") && !overId.startsWith("step-")) return; + + // Strip prefixes to get raw IDs + const rawActiveId = activeId.replace(/^s-step-/, ""); + const rawOverId = overId.replace(/^s-step-/, "").replace(/^step-/, ""); + + console.log("[DesignerRoot] DragEnd - Step Sort", { activeId, overId, rawActiveId, rawOverId }); + + const oldIndex = steps.findIndex((s) => s.id === rawActiveId); + const newIndex = steps.findIndex((s) => s.id === rawOverId); + + console.log("[DesignerRoot] Indices", { oldIndex, newIndex }); + + if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) { + console.log("[DesignerRoot] Reordering..."); + reorderStep(oldIndex, newIndex); + } + return; + } + // 1. Determine Target (Step, Parent, Index) let stepId: string | null = null; let parentId: string | null = null; @@ -907,8 +1009,9 @@ export function DesignerRoot({ } : undefined; + const newId = `action-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`; const newAction: ExperimentAction = { - id: crypto.randomUUID(), + id: newId, type: actionDef.type, // this is the 'type' key name: actionDef.name, category: actionDef.category as any, @@ -933,7 +1036,7 @@ export function DesignerRoot({ void recomputeHash(); } }, - [steps, upsertAction, selectAction, recomputeHash, toggleLibraryScrollLock], + [steps, upsertAction, selectAction, recomputeHash, toggleLibraryScrollLock, reorderStep], ); // validation status badges removed (unused) /* ------------------------------- Panels ---------------------------------- */ @@ -962,10 +1065,11 @@ export function DesignerRoot({ activeTab={inspectorTab} onTabChange={setInspectorTab} studyPlugins={studyPlugins} + onClearAll={clearAllValidationIssues} /> ), - [inspectorTab, studyPlugins], + [inspectorTab, studyPlugins, clearAllValidationIssues], ); /* ------------------------------- Render ---------------------------------- */ @@ -1020,51 +1124,117 @@ export function DesignerRoot({ {/* Main Grid Container - 2-4-2 Split */} {/* Main Grid Container - 2-4-2 Split */} -
+
toggleLibraryScrollLock(false)} > -
- {/* Left Panel (2/8) */} -
-
- Left Panel (2fr) +
+ {/* Left Panel (Library) */} + {!leftCollapsed && ( +
+
+ Action Library + +
+
+ {leftPanel} +
-
- {leftPanel} -
-
+ )} - {/* Center Panel (4/8) - The Workspace */} -
-
- Center Workspace (4fr) + {/* Center Panel (Workspace) */} +
+
+ {leftCollapsed && ( + + )} + Flow Workspace + {rightCollapsed && ( + + )}
- {/* Center content needs to be relative for absolute positioning children if any */} {centerPanel}
+
+ persist()} + onValidate={() => validateDesign()} + onExport={() => handleExport()} + onRecalculateHash={() => recomputeHash()} + lastSavedAt={lastSavedAt} + saving={isSaving} + validating={isValidating} + exporting={isExporting} + /> +
- {/* Right Panel (2/8) */} -
-
- Right Panel (2fr) + {/* Right Panel (Inspector) */} + {!rightCollapsed && ( +
+
+ Inspector + +
+
+ {rightPanel} +
-
- {rightPanel} -
-
+ )}
- + {dragOverlayAction ? ( -
+ // Library Item Drag +
{dragOverlayAction.name}
+ ) : activeSortableItem?.type === 'action' ? ( + // Existing Action Sort +
+ { }} + onDeleteAction={() => { }} + dragHandle={true} + /> +
+ ) : activeSortableItem?.type === 'step' ? ( + // Existing Step Sort +
+ +
) : null} diff --git a/src/components/experiments/designer/PropertiesPanel.tsx b/src/components/experiments/designer/PropertiesPanel.tsx index fbfe5dd..031f80f 100755 --- a/src/components/experiments/designer/PropertiesPanel.tsx +++ b/src/components/experiments/designer/PropertiesPanel.tsx @@ -388,17 +388,18 @@ export function PropertiesPanelBase({ onValueChange={(val) => { onStepUpdate(selectedStep.id, { type: val as StepType }); }} + disabled={true} > Sequential - Parallel - Conditional - Loop +

+ Steps always execute sequentially. Use control flow actions for parallel/conditional logic. +

diff --git a/src/components/experiments/designer/ValidationPanel.tsx b/src/components/experiments/designer/ValidationPanel.tsx index 915f952..288f123 100755 --- a/src/components/experiments/designer/ValidationPanel.tsx +++ b/src/components/experiments/designer/ValidationPanel.tsx @@ -46,6 +46,10 @@ export interface ValidationPanelProps { * Called to clear all issues for an entity. */ onEntityClear?: (entityId: string) => void; + /** + * Called to clear all issues globally. + */ + onClearAll?: () => void; /** * Optional function to map entity IDs to human-friendly names (e.g., step/action names). */ @@ -60,25 +64,25 @@ export interface ValidationPanelProps { const severityConfig = { error: { icon: AlertCircle, - color: "text-red-600 dark:text-red-400", - bgColor: "bg-red-100 dark:bg-red-950/60", - borderColor: "border-red-300 dark:border-red-700", + color: "text-validation-error-text", + bgColor: "bg-validation-error-bg", + borderColor: "border-validation-error-border", badgeVariant: "destructive" as const, label: "Error", }, warning: { icon: AlertTriangle, - color: "text-amber-600 dark:text-amber-400", - bgColor: "bg-amber-100 dark:bg-amber-950/60", - borderColor: "border-amber-300 dark:border-amber-700", - badgeVariant: "secondary" as const, + color: "text-validation-warning-text", + bgColor: "bg-validation-warning-bg", + borderColor: "border-validation-warning-border", + badgeVariant: "outline" as const, label: "Warning", }, info: { icon: Info, - color: "text-blue-600 dark:text-blue-400", - bgColor: "bg-blue-100 dark:bg-blue-950/60", - borderColor: "border-blue-300 dark:border-blue-700", + color: "text-validation-info-text", + bgColor: "bg-validation-info-bg", + borderColor: "border-validation-info-border", badgeVariant: "outline" as const, label: "Info", }, @@ -141,7 +145,7 @@ function IssueItem({
-

+

{issue.message}

@@ -199,6 +203,7 @@ export function ValidationPanel({ onIssueClick, onIssueClear, onEntityClear: _onEntityClear, + onClearAll, entityLabelForId, className, }: ValidationPanelProps) { @@ -284,7 +289,7 @@ export function ValidationPanel({ className={cn( "h-7 justify-start gap-1 text-[11px]", severityFilter === "error" && - "bg-red-600 text-white hover:opacity-90", + "bg-red-600 text-white hover:opacity-90", )} onClick={() => setSeverityFilter("error")} aria-pressed={severityFilter === "error"} @@ -300,7 +305,7 @@ export function ValidationPanel({ className={cn( "h-7 justify-start gap-1 text-[11px]", severityFilter === "warning" && - "bg-amber-500 text-white hover:opacity-90", + "bg-amber-500 text-white hover:opacity-90", )} onClick={() => setSeverityFilter("warning")} aria-pressed={severityFilter === "warning"} @@ -316,7 +321,7 @@ export function ValidationPanel({ className={cn( "h-7 justify-start gap-1 text-[11px]", severityFilter === "info" && - "bg-blue-600 text-white hover:opacity-90", + "bg-blue-600 text-white hover:opacity-90", )} onClick={() => setSeverityFilter("info")} aria-pressed={severityFilter === "info"} diff --git a/src/components/experiments/designer/flow/FlowWorkspace.tsx b/src/components/experiments/designer/flow/FlowWorkspace.tsx index 91aefa8..9602550 100755 --- a/src/components/experiments/designer/flow/FlowWorkspace.tsx +++ b/src/components/experiments/designer/flow/FlowWorkspace.tsx @@ -8,6 +8,7 @@ import React, { useState, } from "react"; import { + useDndContext, useDroppable, useDndMonitor, type DragEndEvent, @@ -80,21 +81,27 @@ export interface VirtualItem { interface StepRowProps { item: VirtualItem; + step: ExperimentStep; // Explicit pass for freshness + totalSteps: number; 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; + onRenameStep: (step: ExperimentStep, newName: string) => void; onDeleteStep: (step: ExperimentStep) => void; onDeleteAction: (stepId: string, actionId: string) => void; setRenamingStepId: (id: string | null) => void; registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void; + onReorderStep: (stepId: string, direction: 'up' | 'down') => void; + onReorderAction?: (stepId: string, actionId: string, direction: 'up' | 'down') => void; } -const StepRow = React.memo(function StepRow({ +function StepRow({ item, + step, + totalSteps, selectedStepId, selectedActionId, renamingStepId, @@ -106,8 +113,10 @@ const StepRow = React.memo(function StepRow({ onDeleteAction, setRenamingStepId, registerMeasureRef, + onReorderStep, + onReorderAction, }: StepRowProps) { - const step = item.step; + // const step = item.step; // Removed local derivation const insertionProjection = useDesignerStore((s) => s.insertionProjection); const displayActions = useMemo(() => { @@ -125,34 +134,19 @@ const StepRow = React.memo(function StepRow({ 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, + transition: "top 300ms cubic-bezier(0.4, 0, 0.2, 1)", + // transform: CSS.Transform.toString(transform), // Removed + // zIndex: isDragging ? 25 : undefined, }; return ( -
+
registerMeasureRef(step.id, el)} className="relative px-3 py-4" @@ -164,8 +158,7 @@ const StepRow = React.memo(function StepRow({ "mb-2 rounded-lg 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", + : "hover:bg-accent/30" )} >
-
{ + e.stopPropagation(); + onReorderStep(step.id, 'up'); + }} + disabled={item.index === 0} + aria-label="Move step up" > - -
+ + + +
@@ -282,7 +294,7 @@ const StepRow = React.memo(function StepRow({ Drop actions here
) : ( - displayActions.map((action) => ( + displayActions.map((action, index) => ( )) )} @@ -302,7 +317,51 @@ const StepRow = React.memo(function StepRow({
); -}); +} + +/* -------------------------------------------------------------------------- */ +/* Step Card Preview (for DragOverlay) */ +/* -------------------------------------------------------------------------- */ + +export function StepCardPreview({ step, dragHandle }: { step: ExperimentStep; dragHandle?: boolean }) { + return ( +
+
+
+
+ +
+ + {step.order + 1} + +
+ {step.name} +
+ + {step.actions.length} actions + +
+
+ +
+
+ {/* Preview optional: show empty body hint or just the header? Header is usually enough for sorting. */} +
+ + {step.actions.length} actions hidden while dragging + +
+
+ ); +} /* -------------------------------------------------------------------------- */ /* Utility */ @@ -331,9 +390,19 @@ function parseSortableAction(id: string): string | null { /* Droppable Overlay (for palette action drops) */ /* -------------------------------------------------------------------------- */ function StepDroppableArea({ stepId }: { stepId: string }) { - const { isOver } = useDroppable({ id: `step-${stepId}` }); + const { active } = useDndContext(); + const isStepDragging = active?.id.toString().startsWith("s-step-"); + + const { isOver, setNodeRef } = useDroppable({ + id: `step-${stepId}`, + disabled: isStepDragging + }); + + if (isStepDragging) return null; + return (
void; onDeleteAction: (stepId: string, actionId: string) => void; + onReorderAction?: (stepId: string, actionId: string, direction: 'up' | 'down') => void; dragHandle?: boolean; + isFirst?: boolean; + isLast?: boolean; } -function SortableActionChip({ +/* -------------------------------------------------------------------------- */ +/* Action Chip Visuals (Pure Component) */ +/* -------------------------------------------------------------------------- */ + +export interface ActionChipVisualsProps { + action: ExperimentAction; + isSelected?: boolean; + isDragging?: boolean; + isOverNested?: boolean; + onSelect?: (e: React.MouseEvent) => void; + onDelete?: (e: React.MouseEvent) => void; + onReorder?: (direction: 'up' | 'down') => void; + dragHandleProps?: React.HTMLAttributes; + children?: React.ReactNode; + isFirst?: boolean; + isLast?: boolean; + validationStatus?: "error" | "warning" | "info"; +} + +export function ActionChipVisuals({ + action, + isSelected, + isDragging, + isOverNested, + onSelect, + onDelete, + onReorder, + dragHandleProps, + children, + isFirst, + isLast, + validationStatus, +}: ActionChipVisualsProps) { + const def = actionRegistry.getAction(action.type); + + return ( +
+
+ + {action.name} + {validationStatus === "error" && ( +
+ )} + {validationStatus === "warning" && ( +
+ )} + + +
+ + +
+ + +
+ {def?.description && ( +
+ {def.description} +
+ )} + {def?.parameters.length ? ( +
+ {def.parameters.slice(0, 4).map((p) => ( + + {p.name} + + ))} + {def.parameters.length > 4 && ( + +{def.parameters.length - 4} + )} +
+ ) : null} + + {children} +
+ ); +} + +export function SortableActionChip({ stepId, action, parentId, selectedActionId, onSelectAction, onDeleteAction, + onReorderAction, dragHandle, + isFirst, + isLast, }: ActionChipProps) { - const def = actionRegistry.getAction(action.type); const isSelected = selectedActionId === action.id; const insertionProjection = useDesignerStore((s) => s.insertionProjection); @@ -388,35 +586,44 @@ function SortableActionChip({ /* ------------------------------------------------------------------------ */ const isPlaceholder = action.id === "projection-placeholder"; - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging: isSortableDragging, - } = useSortable({ - id: sortableActionId(action.id), - disabled: isPlaceholder, // Disable sortable for placeholder - data: { - type: "action", - stepId, - parentId, - id: action.id, - }, - }); + // Compute validation status + const issues = useDesignerStore((s) => s.validationIssues[action.id]); + const validationStatus = useMemo(() => { + if (!issues?.length) return undefined; + if (issues.some((i) => i.severity === "error")) return "error"; + if (issues.some((i) => i.severity === "warning")) return "warning"; + return "info"; + }, [issues]); + + /* ------------------------------------------------------------------------ */ + /* Sortable (Local) DnD Monitoring */ + /* ------------------------------------------------------------------------ */ + // useSortable disabled per user request to remove action drag-and-drop + // const { ... } = useSortable(...) // Use local dragging state or passed prop - const isDragging = isSortableDragging || dragHandle; + const isDragging = dragHandle || false; const style = { - transform: CSS.Translate.toString(transform), - transition, + // transform: CSS.Translate.toString(transform), + // transition, }; + // We need a ref for droppable? Droppable is below. + // For the chip itself, if not sortable, we don't need setNodeRef. + // But we might need it for layout? + // Let's keep a simple div ref usage if needed, but useSortable provided setNodeRef. + // We can just use a normal ref or nothing if not measuring. + const setNodeRef = undefined; // No-op + const attributes = {}; + const listeners = {}; + + + /* ------------------------------------------------------------------------ */ /* Nested Droppable (for control flow containers) */ /* ------------------------------------------------------------------------ */ + const def = actionRegistry.getAction(action.type); const nestedDroppableId = `container-${action.id}`; const { isOver: isOverNested, @@ -472,114 +679,61 @@ function SortableActionChip({
{ - e.stopPropagation(); - onSelectAction(stepId, action.id); - }} {...attributes} - role="button" - aria-pressed={isSelected} - tabIndex={0} > -
-
- -
- - - {action.name} - - -
- {def?.description && ( -
- {def.description} -
- )} - {def?.parameters.length ? ( -
- {def.parameters.slice(0, 4).map((p) => ( - - {p.name} - - ))} - {def.parameters.length > 4 && ( - +{def.parameters.length - 4} - )} -
- ) : null} - - {/* Nested Actions Container */} - {shouldRenderChildren && ( -
- c.id !== "projection-placeholder") - .map(c => sortableActionId(c.id))} - strategy={verticalListSortingStrategy} - > - {(displayChildren || action.children || []).map((child) => ( - - ))} - {(!displayChildren?.length && !action.children?.length) && ( -
- Drag actions here -
+ { + e.stopPropagation(); + onSelectAction(stepId, action.id); + }} + onDelete={(e) => { + e.stopPropagation(); + onDeleteAction(stepId, action.id); + }} + onReorder={(direction) => onReorderAction?.(stepId, action.id, direction)} + dragHandleProps={listeners} + isLast={isLast} + validationStatus={validationStatus} + > + {/* Nested Actions Container */} + {shouldRenderChildren && ( +
-
- )} - + > + c.id !== "projection-placeholder") + .map(c => sortableActionId(c.id))} + strategy={verticalListSortingStrategy} + > + {(displayChildren || action.children || []).map((child) => ( + + ))} + {(!displayChildren?.length && !action.children?.length) && ( +
+ Drag actions here +
+ )} +
+
+ )} +
); } @@ -796,6 +950,52 @@ export function FlowWorkspace({ [removeAction, selectedActionId, selectAction, recomputeHash], ); + const handleReorderStep = useCallback( + (stepId: string, direction: 'up' | 'down') => { + console.log('handleReorderStep', stepId, direction); + const currentIndex = steps.findIndex((s) => s.id === stepId); + console.log('currentIndex', currentIndex, 'total', steps.length); + if (currentIndex === -1) return; + const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1; + console.log('newIndex', newIndex); + if (newIndex < 0 || newIndex >= steps.length) return; + reorderStep(currentIndex, newIndex); + }, + [steps, reorderStep] + ); + + const handleReorderAction = useCallback( + (stepId: string, actionId: string, direction: 'up' | 'down') => { + const step = steps.find(s => s.id === stepId); + if (!step) return; + + const findInTree = (list: ExperimentAction[], pId: string | null): { list: ExperimentAction[], parentId: string | null, index: number } | null => { + const idx = list.findIndex(a => a.id === actionId); + if (idx !== -1) return { list, parentId: pId, index: idx }; + + for (const a of list) { + if (a.children) { + const res = findInTree(a.children, a.id); + if (res) return res; + } + } + return null; + }; + + const context = findInTree(step.actions, null); + if (!context) return; + + const { parentId, index, list } = context; + const newIndex = direction === 'up' ? index - 1 : index + 1; + + if (newIndex < 0 || newIndex >= list.length) return; + + moveAction(stepId, actionId, parentId, newIndex); + }, + [steps, moveAction] + ); + + /* ------------------------------------------------------------------------ */ /* Sortable (Local) DnD Monitoring */ /* ------------------------------------------------------------------------ */ @@ -815,19 +1015,9 @@ export function FlowWorkspace({ } 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(); - } - } - } + + // Step reorder is now handled globally in DesignerRoot + // Action reorder (supports nesting) if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) { const activeData = active.data.current; @@ -839,8 +1029,9 @@ export function FlowWorkspace({ activeData.type === 'action' && overData.type === 'action' ) { const stepId = activeData.stepId as string; - const activeActionId = activeData.action.id; - const overActionId = overData.action.id; + // Fix: SortableActionChip puts 'id' directly on data, not inside 'action' property + const activeActionId = activeData.id; + const overActionId = overData.id; if (activeActionId !== overActionId) { const newParentId = overData.parentId as string | null; @@ -877,8 +1068,10 @@ export function FlowWorkspace({ activeData.type === 'action' && overData.type === 'action' ) { - const activeActionId = activeData.action.id; - const overActionId = overData.action.id; + // Fix: Access 'id' directly from data payload + const activeActionId = activeData.id; + const overActionId = overData.id; + const activeStepId = activeData.stepId; const overStepId = overData.stepId; const activeParentId = activeData.parentId; @@ -956,7 +1149,8 @@ export function FlowWorkspace({
{steps.length === 0 ? ( @@ -990,6 +1184,8 @@ export function FlowWorkspace({ ), )} diff --git a/src/components/experiments/designer/layout/BottomStatusBar.tsx b/src/components/experiments/designer/layout/BottomStatusBar.tsx index 2a998ef..f0e53e5 100755 --- a/src/components/experiments/designer/layout/BottomStatusBar.tsx +++ b/src/components/experiments/designer/layout/BottomStatusBar.tsx @@ -5,14 +5,11 @@ import { Save, RefreshCw, Download, - Hash, AlertTriangle, CheckCircle2, - UploadCloud, - Wand2, - Sparkles, + Hash, GitBranch, - Keyboard, + Sparkles, } from "lucide-react"; import { Button } from "~/components/ui/button"; import { Badge } from "~/components/ui/badge"; @@ -20,21 +17,6 @@ import { Separator } from "~/components/ui/separator"; import { cn } from "~/lib/utils"; import { useDesignerStore } from "../state/store"; -/** - * BottomStatusBar - * - * Compact, persistent status + quick-action bar for the Experiment Designer. - * Shows: - * - Validation / drift / unsaved state - * - Short design hash & version - * - Aggregate counts (steps / actions) - * - Last persisted hash (if available) - * - Quick actions (Save, Validate, Export, Command Palette) - * - * The bar is intentionally UI-only: callback props are used so that higher-level - * orchestration (e.g. DesignerRoot / Shell) controls actual side effects. - */ - export interface BottomStatusBarProps { onSave?: () => void; onValidate?: () => void; @@ -45,9 +27,6 @@ export interface BottomStatusBarProps { saving?: boolean; validating?: boolean; exporting?: boolean; - /** - * Optional externally supplied last saved Date for relative display. - */ lastSavedAt?: Date; } @@ -55,24 +34,16 @@ export function BottomStatusBar({ onSave, onValidate, onExport, - onOpenCommandPalette, - onRecalculateHash, className, saving, validating, exporting, - lastSavedAt, }: BottomStatusBarProps) { - /* ------------------------------------------------------------------------ */ - /* Store Selectors */ - /* ------------------------------------------------------------------------ */ const steps = useDesignerStore((s) => s.steps); const lastPersistedHash = useDesignerStore((s) => s.lastPersistedHash); const currentDesignHash = useDesignerStore((s) => s.currentDesignHash); const lastValidatedHash = useDesignerStore((s) => s.lastValidatedHash); const pendingSave = useDesignerStore((s) => s.pendingSave); - const versionStrategy = useDesignerStore((s) => s.versionStrategy); - const autoSaveEnabled = useDesignerStore((s) => s.autoSaveEnabled); const actionCount = useMemo( () => steps.reduce((sum, st) => sum + st.actions.length, 0), @@ -93,64 +64,28 @@ export function BottomStatusBar({ return "valid"; }, [currentDesignHash, lastValidatedHash]); - const shortHash = useMemo( - () => (currentDesignHash ? currentDesignHash.slice(0, 8) : "β€”"), - [currentDesignHash], - ); - - const lastPersistedShort = useMemo( - () => (lastPersistedHash ? lastPersistedHash.slice(0, 8) : null), - [lastPersistedHash], - ); - - /* ------------------------------------------------------------------------ */ - /* Derived Display Helpers */ - /* ------------------------------------------------------------------------ */ - function formatRelative(date?: Date): string { - if (!date) return "β€”"; - const now = Date.now(); - const diffMs = now - date.getTime(); - if (diffMs < 30_000) return "just now"; - const mins = Math.floor(diffMs / 60_000); - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - const days = Math.floor(hrs / 24); - return `${days}d ago`; - } - - const relSaved = formatRelative(lastSavedAt); - const validationBadge = (() => { switch (validationStatus) { case "valid": return ( - - - Validated - +
+ + Valid +
); case "drift": return ( - - - Drift - +
+ + Modified +
); default: return ( - - +
+ Unvalidated - +
); } })(); @@ -159,190 +94,63 @@ export function BottomStatusBar({ hasUnsaved && !pendingSave ? ( - - Unsaved + Unsaved ) : null; const savingIndicator = pendingSave || saving ? ( - - - Saving… - +
+ + Saving... +
) : null; - /* ------------------------------------------------------------------------ */ - /* Handlers */ - /* ------------------------------------------------------------------------ */ - const handleSave = useCallback(() => { - if (onSave) onSave(); - }, [onSave]); - - const handleValidate = useCallback(() => { - if (onValidate) onValidate(); - }, [onValidate]); - - const handleExport = useCallback(() => { - if (onExport) onExport(); - }, [onExport]); - - const handlePalette = useCallback(() => { - if (onOpenCommandPalette) onOpenCommandPalette(); - }, [onOpenCommandPalette]); - - const handleRecalculateHash = useCallback(() => { - if (onRecalculateHash) onRecalculateHash(); - }, [onRecalculateHash]); - - /* ------------------------------------------------------------------------ */ - /* Render */ - /* ------------------------------------------------------------------------ */ - return (
- {/* Left Cluster: Validation & Hash */} -
+ {/* Status Indicators */} +
{validationBadge} {unsavedBadge} {savingIndicator} - -
- - {shortHash} - {lastPersistedShort && lastPersistedShort !== shortHash && ( - - / {lastPersistedShort} - - )} -
- {/* Middle Cluster: Aggregate Counts */} -
-
- + + + {/* Stats */} +
+ + {steps.length} - steps -
-
- + + + {actionCount} - actions -
-
- - {autoSaveEnabled ? "auto-save on" : "auto-save off"} -
-
- - {currentDesignHash?.slice(0, 16) ?? 'β€”'} - -
-
- Saved {relSaved} -
+
- {/* Flexible Spacer */}
- {/* Right Cluster: Quick Actions */} + {/* Actions */}
- - - -
diff --git a/src/components/experiments/designer/layout/PanelsContainer.tsx b/src/components/experiments/designer/layout/PanelsContainer.tsx index acb32b7..098ade6 100755 --- a/src/components/experiments/designer/layout/PanelsContainer.tsx +++ b/src/components/experiments/designer/layout/PanelsContainer.tsx @@ -404,6 +404,28 @@ export function PanelsContainer({ {right} )} + + {/* Resize Handles */} + {hasLeft && !leftCollapsed && ( +
); diff --git a/src/components/experiments/designer/panels/InspectorPanel.tsx b/src/components/experiments/designer/panels/InspectorPanel.tsx index 2a29862..9b46416 100755 --- a/src/components/experiments/designer/panels/InspectorPanel.tsx +++ b/src/components/experiments/designer/panels/InspectorPanel.tsx @@ -67,6 +67,10 @@ export interface InspectorPanelProps { name: string; version: string; }>; + /** + * Called to clear all validation issues. + */ + onClearAll?: () => void; } export function InspectorPanel({ @@ -77,6 +81,7 @@ export function InspectorPanel({ studyPlugins, collapsed, onCollapse, + onClearAll, }: InspectorPanelProps) { /* ------------------------------------------------------------------------ */ /* Store Selectors */ @@ -323,6 +328,7 @@ export function InspectorPanel({ > { if (entityId.startsWith("action-")) { for (const s of steps) { diff --git a/src/components/experiments/designer/state/store.ts b/src/components/experiments/designer/state/store.ts index 6e3f136..90fb6a0 100755 --- a/src/components/experiments/designer/state/store.ts +++ b/src/components/experiments/designer/state/store.ts @@ -167,8 +167,6 @@ function cloneSteps(steps: ExperimentStep[]): ExperimentStep[] { function reindexSteps(steps: ExperimentStep[]): ExperimentStep[] { return steps - .slice() - .sort((a, b) => a.order - b.order) .map((s, idx) => ({ ...s, order: idx })); } diff --git a/src/components/experiments/designer/state/validators.ts b/src/components/experiments/designer/state/validators.ts index be7dd91..3bd77d7 100755 --- a/src/components/experiments/designer/state/validators.ts +++ b/src/components/experiments/designer/state/validators.ts @@ -49,11 +49,10 @@ export interface ValidationResult { /* Validation Rule Sets */ /* -------------------------------------------------------------------------- */ +// Steps should ALWAYS execute sequentially +// Parallel/conditional/loop execution happens at the ACTION level, not step level const VALID_STEP_TYPES: StepType[] = [ "sequential", - "parallel", - "conditional", - "loop", ]; const VALID_TRIGGER_TYPES: TriggerType[] = [ "trial_start", @@ -144,48 +143,8 @@ export function validateStructural( }); } - // Conditional step must have conditions - if (step.type === "conditional") { - const conditionKeys = Object.keys(step.trigger.conditions || {}); - if (conditionKeys.length === 0) { - issues.push({ - severity: "error", - message: "Conditional step must define at least one condition", - category: "structural", - field: "trigger.conditions", - stepId, - suggestion: "Add conditions to define when this step should execute", - }); - } - } - - // Loop step should have termination conditions - if (step.type === "loop") { - const conditionKeys = Object.keys(step.trigger.conditions || {}); - if (conditionKeys.length === 0) { - issues.push({ - severity: "warning", - message: - "Loop step should define termination conditions to prevent infinite loops", - category: "structural", - field: "trigger.conditions", - stepId, - suggestion: "Add conditions to control when the loop should exit", - }); - } - } - - // Parallel step should have multiple actions - if (step.type === "parallel" && step.actions.length < 2) { - issues.push({ - severity: "warning", - message: - "Parallel step has fewer than 2 actions - consider using sequential type", - category: "structural", - stepId, - suggestion: "Add more actions or change to sequential execution", - }); - } + // All steps must be sequential type (parallel/conditional/loop removed) + // Control flow and parallelism should be implemented at the ACTION level // Action-level structural validation step.actions.forEach((action) => { @@ -234,6 +193,7 @@ export function validateStructural( } // Plugin actions need plugin metadata + /* VALIDATION DISABLED BY USER REQUEST if (action.source?.kind === "plugin") { if (!action.source.pluginId) { issues.push({ @@ -258,6 +218,7 @@ export function validateStructural( }); } } + */ // Execution descriptor validation if (!action.execution?.transport) { @@ -532,10 +493,9 @@ export function validateSemantic( // Check for empty steps steps.forEach((step) => { if (step.actions.length === 0) { - const severity = step.type === "parallel" ? "error" : "warning"; issues.push({ - severity, - message: `${step.type} step has no actions`, + severity: "warning", + message: "Step has no actions", category: "semantic", stepId: step.id, suggestion: "Add actions to this step or remove it", @@ -635,25 +595,9 @@ export function validateExecution( ): ValidationIssue[] { const issues: ValidationIssue[] = []; - // Check for unreachable steps (basic heuristic) - if (steps.length > 1) { - const trialStartSteps = steps.filter( - (s) => s.trigger.type === "trial_start", - ); - if (trialStartSteps.length > 1) { - trialStartSteps.slice(1).forEach((step) => { - issues.push({ - severity: "info", - message: - "This step will start immediately at trial start. For sequential flow, use 'Previous Step' trigger.", - category: "execution", - field: "trigger.type", - stepId: step.id, - suggestion: "Change trigger to 'Previous Step' if this step should follow the previous one", - }); - }); - } - } + // Note: Trigger validation removed - convertDatabaseToSteps() automatically assigns + // correct triggers (trial_start for first step, previous_step for others) based on orderIndex. + // Manual trigger configuration is intentional for advanced workflows. // Check for missing robot dependencies const robotActions = steps.flatMap((step) => diff --git a/src/lib/experiment-designer/block-converter.ts b/src/lib/experiment-designer/block-converter.ts index 1510c6c..ca2b3af 100755 --- a/src/lib/experiment-designer/block-converter.ts +++ b/src/lib/experiment-designer/block-converter.ts @@ -158,3 +158,89 @@ export function convertActionToDatabase( category: action.category, }; } + +// Reconstruct designer steps from database records +export function convertDatabaseToSteps( + dbSteps: any[] // Typing as any[] because Drizzle types are complex to import here without circular deps +): ExperimentStep[] { + // Paranoid Sort: Ensure steps are strictly ordered by index before assigning Triggers. + // This safeguards against API returning unsorted data. + const sortedSteps = [...dbSteps].sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0)); + + return sortedSteps.map((dbStep, idx) => { + // console.log(`[block-converter] Step ${dbStep.name} OrderIndex:`, dbStep.orderIndex, dbStep.order_index); + return { + id: dbStep.id, + name: dbStep.name, + description: dbStep.description ?? undefined, + type: mapDatabaseToStepType(dbStep.type), + order: dbStep.orderIndex ?? idx, // Fallback to array index if missing + trigger: { + // Enforce Sequential Architecture: Validated by user requirement. + // Index 0 is Trial Start, all others are Previous Step. + type: idx === 0 ? "trial_start" : "previous_step", + conditions: (dbStep.conditions as Record) || {}, + }, + expanded: true, // Default to expanded in designer + actions: (dbStep.actions || []).map((dbAction: any) => + convertDatabaseToAction(dbAction) + ), + }; + }); +} + +function mapDatabaseToStepType(type: string): ExperimentStep["type"] { + switch (type) { + case "wizard": + return "sequential"; + case "parallel": + return "parallel"; + case "conditional": + return "conditional"; // Loop is also stored as conditional, distinction lost unless encoded in metadata + default: + return "sequential"; + } +} + +export function convertDatabaseToAction(dbAction: any): ExperimentAction { + // Reconstruct nested source object + const source: ExperimentAction["source"] = { + kind: (dbAction.sourceKind || dbAction.source_kind || "core") as "core" | "plugin", + pluginId: dbAction.pluginId || dbAction.plugin_id || undefined, + pluginVersion: dbAction.pluginVersion || dbAction.plugin_version || undefined, + robotId: dbAction.robotId || dbAction.robot_id || undefined, + baseActionId: dbAction.baseActionId || dbAction.base_action_id || undefined, + }; + + // Robust Inference: If properties are missing but Type suggests a plugin (e.g., "nao6-ros2.say_text"), + // assume/infer the pluginId to ensure validation passes. + if (dbAction.type && dbAction.type.includes(".") && !source.pluginId) { + const parts = dbAction.type.split("."); + if (parts.length === 2) { + source.kind = "plugin"; + source.pluginId = parts[0]; + // Fallback robotId if missing + if (!source.robotId) source.robotId = parts[0]; + } + } + + // Reconstruct execution object + const execution: ExecutionDescriptor = { + transport: dbAction.transport as ExecutionDescriptor["transport"], + ros2: dbAction.ros2 as ExecutionDescriptor["ros2"], + rest: dbAction.rest as ExecutionDescriptor["rest"], + retryable: dbAction.retryable ?? false, + }; + + return { + id: dbAction.id, + name: dbAction.name, + description: dbAction.description ?? undefined, + type: dbAction.type, + category: dbAction.category ?? "general", + parameters: (dbAction.parameters as Record) || {}, + source, + execution, + parameterSchemaRaw: dbAction.parameterSchemaRaw, + }; +} diff --git a/src/lib/experiment-designer/types.ts b/src/lib/experiment-designer/types.ts index 9b5fa82..768cf81 100755 --- a/src/lib/experiment-designer/types.ts +++ b/src/lib/experiment-designer/types.ts @@ -119,26 +119,13 @@ export const TRIGGER_OPTIONS = [ ]; // Step type options for UI +// IMPORTANT: Steps should ALWAYS execute sequentially +// Parallel execution, conditionals, and loops should be implemented via control flow ACTIONS export const STEP_TYPE_OPTIONS = [ { value: "sequential" as const, label: "Sequential", - description: "Actions run one after another", - }, - { - value: "parallel" as const, - label: "Parallel", - description: "Actions run at the same time", - }, - { - value: "conditional" as const, - label: "Conditional", - description: "Actions run if condition is met", - }, - { - value: "loop" as const, - label: "Loop", - description: "Actions repeat multiple times", + description: "Actions run one after another (enforced for all steps)", }, ]; diff --git a/src/server/api/routers/experiments.ts b/src/server/api/routers/experiments.ts index f79cda8..442b893 100755 --- a/src/server/api/routers/experiments.ts +++ b/src/server/api/routers/experiments.ts @@ -17,7 +17,10 @@ import { studyMembers, userSystemRoles, } from "~/server/db/schema"; -import { convertStepsToDatabase } from "~/lib/experiment-designer/block-converter"; +import { + convertStepsToDatabase, + convertDatabaseToSteps, +} from "~/lib/experiment-designer/block-converter"; import type { ExperimentStep, ExperimentDesign, @@ -382,6 +385,7 @@ export const experimentsRouter = createTRPCRouter({ return { ...experiment, + steps: convertDatabaseToSteps(experiment.steps), integrityHash: experiment.integrityHash, executionGraphSummary, pluginDependencies: experiment.pluginDependencies ?? [], diff --git a/src/styles/globals.css b/src/styles/globals.css index 93e21c0..0c4c016 100755 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -69,6 +69,17 @@ --shadow-opacity: var(--shadow-opacity); --color-shadow-color: var(--shadow-color); --color-destructive-foreground: var(--destructive-foreground); + + /* Validation Colors */ + --color-validation-error-bg: var(--validation-error-bg); + --color-validation-error-text: var(--validation-error-text); + --color-validation-error-border: var(--validation-error-border); + --color-validation-warning-bg: var(--validation-warning-bg); + --color-validation-warning-text: var(--validation-warning-text); + --color-validation-warning-border: var(--validation-warning-border); + --color-validation-info-bg: var(--validation-info-bg); + --color-validation-info-text: var(--validation-info-text); + --color-validation-info-border: var(--validation-info-border); } :root { @@ -140,14 +151,12 @@ @media (prefers-color-scheme: dark) { :root { - /* Dark Mode (Inverted: Lighter BG, Black Cards) */ - --background: hsl(240 3.7% 15.9%); - /* Lighter Dark BG */ + --background: hsl(240 10% 3.9%); --foreground: hsl(0 0% 98%); - --card: hsl(240 10% 3.9%); - /* Deep Black Card */ + /* Distinct Card Background for better contrast */ + --card: hsl(240 5% 9%); --card-foreground: hsl(0 0% 98%); - --popover: hsl(240 10% 3.9%); + --popover: hsl(240 5% 9%); --popover-foreground: hsl(0 0% 98%); --primary: hsl(0 0% 98%); --primary-foreground: hsl(240 5.9% 10%); @@ -180,27 +189,25 @@ @layer base { .dark { - /* Dark Mode (Zinc) */ --background: hsl(240 10% 3.9%); --foreground: hsl(0 0% 98%); - --card: hsl(240 3.7% 15.9%); + --card: hsl(240 5% 9%); --card-foreground: hsl(0 0% 98%); - --popover: hsl(240 10% 3.9%); + --popover: hsl(240 5% 9%); --popover-foreground: hsl(0 0% 98%); - --primary: hsl(217.2 91.2% 59.8%); - /* Indigo-400 */ - --primary-foreground: hsl(222.2 47.4% 11.2%); - --secondary: hsl(217.2 32.6% 17.5%); - --secondary-foreground: hsl(210 40% 98%); - --muted: hsl(217.2 32.6% 17.5%); - --muted-foreground: hsl(215 20.2% 65.1%); - --accent: hsl(217.2 32.6% 17.5%); - --accent-foreground: hsl(210 40% 98%); + --primary: hsl(0 0% 98%); + --primary-foreground: hsl(240 5.9% 10%); + --secondary: hsl(240 3.7% 15.9%); + --secondary-foreground: hsl(0 0% 98%); + --muted: hsl(240 3.7% 15.9%); + --muted-foreground: hsl(240 5% 64.9%); + --accent: hsl(240 3.7% 15.9%); + --accent-foreground: hsl(0 0% 98%); --destructive: hsl(0 62.8% 30.6%); - --destructive-foreground: hsl(210 40% 98%); - --border: hsl(217.2 32.6% 17.5%); - --input: hsl(217.2 32.6% 17.5%); - --ring: hsl(217.2 91.2% 59.8%); + --destructive-foreground: hsl(0 0% 98%); + --border: hsl(240 3.7% 15.9%); + --input: hsl(240 3.7% 15.9%); + --ring: hsl(240 4.9% 83.9%); --chart-1: hsl(220 70% 50%); --chart-2: hsl(160 60% 45%); --chart-3: hsl(30 80% 55%); @@ -213,11 +220,53 @@ --sidebar-accent: hsl(240 3.7% 15.9%); --sidebar-accent-foreground: hsl(240 4.8% 95.9%); --sidebar-border: hsl(240 3.7% 15.9%); + --sidebar-border: hsl(240 3.7% 15.9%); --sidebar-ring: hsl(217.2 91.2% 59.8%); + /* Validation Dark Mode */ + --validation-error-bg: hsl(0 75% 15%); + /* Red 950-ish */ + --validation-error-text: hsl(0 100% 90%); + /* Red 100 */ + --validation-error-border: hsl(0 50% 30%); + /* Red 900 */ + --validation-warning-bg: hsl(30 90% 10%); + /* Amber 950-ish */ + --validation-warning-text: hsl(30 100% 90%); + /* Amber 100 */ + --validation-warning-border: hsl(30 60% 30%); + /* Amber 900 */ + --validation-info-bg: hsl(210 50% 15%); + /* Blue 950-ish */ + --validation-info-text: hsl(210 100% 90%); + /* Blue 100 */ + --validation-info-border: hsl(210 40% 30%); + /* Blue 900 */ } } +:root { + /* Validation Light Mode Defaults */ + --validation-error-bg: hsl(0 85% 97%); + /* Red 50 */ + --validation-error-text: hsl(0 72% 45%); + /* Red 700 */ + --validation-error-border: hsl(0 80% 90%); + /* Red 200 */ + --validation-warning-bg: hsl(40 85% 97%); + /* Amber 50 */ + --validation-warning-text: hsl(35 90% 35%); + /* Amber 700 */ + --validation-warning-border: hsl(40 80% 90%); + /* Amber 200 */ + --validation-info-bg: hsl(210 85% 97%); + /* Blue 50 */ + --validation-info-text: hsl(220 80% 45%); + /* Blue 700 */ + --validation-info-border: hsl(210 80% 90%); + /* Blue 200 */ +} + @layer base { * { @apply border-border outline-ring/50; @@ -248,4 +297,4 @@ body, #__next { height: 100%; min-height: 100vh; -} +} \ No newline at end of file