diff --git a/scripts/debug-experiment-structure.ts b/scripts/debug-experiment-structure.ts new file mode 100644 index 0000000..f1e6dd9 --- /dev/null +++ b/scripts/debug-experiment-structure.ts @@ -0,0 +1,58 @@ + +import { db } from "~/server/db"; +import { steps, experiments, actions } from "~/server/db/schema"; +import { eq, asc } from "drizzle-orm"; + +async function debugExperimentStructure() { + console.log("Debugging Experiment Structure for Interactive Storyteller..."); + + // Find the experiment + const experiment = await db.query.experiments.findFirst({ + where: eq(experiments.name, "The Interactive Storyteller"), + with: { + steps: { + orderBy: [asc(steps.orderIndex)], + with: { + actions: { + orderBy: [asc(actions.orderIndex)], + } + } + } + } + }); + + if (!experiment) { + console.error("Experiment not found!"); + return; + } + + console.log(`Experiment: ${experiment.name} (${experiment.id})`); + console.log(`Plugin Dependencies:`, experiment.pluginDependencies); + console.log("---------------------------------------------------"); + + experiment.steps.forEach((step, index) => { + console.log(`Step ${index + 1}: ${step.name}`); + console.log(` ID: ${step.id}`); + console.log(` Type: ${step.type}`); + console.log(` Order: ${step.orderIndex}`); + console.log(` Conditions:`, JSON.stringify(step.conditions, null, 2)); + + if (step.actions && step.actions.length > 0) { + console.log(` Actions (${step.actions.length}):`); + step.actions.forEach((action, actionIndex) => { + console.log(` ${actionIndex + 1}. [${action.type}] ${action.name}`); + if (action.type === 'wizard_wait_for_response') { + console.log(` Options:`, JSON.stringify((action.parameters as any)?.options, null, 2)); + } + }); + } + console.log("---------------------------------------------------"); + }); +} + +debugExperimentStructure() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/scripts/inspect-all-steps.ts b/scripts/inspect-all-steps.ts new file mode 100644 index 0000000..401f50d --- /dev/null +++ b/scripts/inspect-all-steps.ts @@ -0,0 +1,42 @@ + +import { db } from "../src/server/db"; +import { experiments, steps } from "../src/server/db/schema"; +import { eq } from "drizzle-orm"; + +async function inspectAllSteps() { + const result = await db.query.experiments.findMany({ + with: { + steps: { + orderBy: (steps, { asc }) => [asc(steps.orderIndex)], + columns: { + id: true, + name: true, + type: true, + orderIndex: true, + conditions: true, + } + } + } + }); + + console.log(`Found ${result.length} experiments.`); + + for (const exp of result) { + console.log(`Experiment: ${exp.name} (${exp.id})`); + for (const step of exp.steps) { + // Only print conditional steps or the first step + if (step.type === 'conditional' || step.orderIndex === 0) { + console.log(` [${step.orderIndex}] ${step.name} (${step.type})`); + console.log(` Conditions: ${JSON.stringify(step.conditions)}`); + } + } + console.log('---'); + } +} + +inspectAllSteps() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/scripts/inspect-branch-action.ts b/scripts/inspect-branch-action.ts new file mode 100644 index 0000000..c7df93d --- /dev/null +++ b/scripts/inspect-branch-action.ts @@ -0,0 +1,47 @@ + +import { db } from "~/server/db"; +import { actions, steps } from "~/server/db/schema"; +import { eq } from "drizzle-orm"; + +async function inspectAction() { + console.log("Inspecting Action 10851aef-e720-45fc-ba5e-05e1e3425dab..."); + + const actionId = "10851aef-e720-45fc-ba5e-05e1e3425dab"; + + const action = await db.query.actions.findFirst({ + where: eq(actions.id, actionId), + with: { + step: { + columns: { + id: true, + name: true, + type: true, + conditions: true + } + } + } + }); + + if (!action) { + console.error("Action not found!"); + return; + } + + console.log("Action Found:"); + console.log(" Name:", action.name); + console.log(" Type:", action.type); + console.log(" Parameters:", JSON.stringify(action.parameters, null, 2)); + + console.log("Parent Step:"); + console.log(" ID:", action.step.id); + console.log(" Name:", action.step.name); + console.log(" Type:", action.step.type); + console.log(" Conditions:", JSON.stringify(action.step.conditions, null, 2)); +} + +inspectAction() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/scripts/inspect-branch-steps.ts b/scripts/inspect-branch-steps.ts new file mode 100644 index 0000000..ab62351 --- /dev/null +++ b/scripts/inspect-branch-steps.ts @@ -0,0 +1,30 @@ + +import { db } from "~/server/db"; +import { steps } from "~/server/db/schema"; +import { eq, inArray } from "drizzle-orm"; + +async function inspectBranchSteps() { + console.log("Inspecting Steps 4 (Branch A) and 5 (Branch B)..."); + + const step4Id = "3a2dc0b7-a43e-4236-9b9e-f957abafc1e5"; + const step5Id = "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30"; + + const branchSteps = await db.query.steps.findMany({ + where: inArray(steps.id, [step4Id, step5Id]) + }); + + branchSteps.forEach(step => { + console.log(`Step: ${step.name} (${step.id})`); + console.log(` Type: ${step.type}`); + console.log(` Order: ${step.orderIndex}`); + console.log(` Conditions:`, JSON.stringify(step.conditions, null, 2)); + console.log("---------------------------------------------------"); + }); +} + +inspectBranchSteps() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/scripts/inspect-db.ts b/scripts/inspect-db.ts new file mode 100644 index 0000000..1b4196e --- /dev/null +++ b/scripts/inspect-db.ts @@ -0,0 +1,24 @@ + +import { db } from "../src/server/db"; +import { steps } from "../src/server/db/schema"; +import { eq, like } from "drizzle-orm"; + +async function checkSteps() { + const allSteps = await db.select().from(steps).where(like(steps.name, "%Comprehension Check%")); + + console.log("Found steps:", allSteps.length); + + for (const step of allSteps) { + console.log("Step Name:", step.name); + console.log("Type:", step.type); + console.log("Conditions (typeof):", typeof step.conditions); + console.log("Conditions (value):", JSON.stringify(step.conditions, null, 2)); + } +} + +checkSteps() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/scripts/inspect-step.ts b/scripts/inspect-step.ts new file mode 100644 index 0000000..a7cdb09 --- /dev/null +++ b/scripts/inspect-step.ts @@ -0,0 +1,61 @@ + + +import { db } from "~/server/db"; +import { steps, experiments } from "~/server/db/schema"; +import { eq, asc } from "drizzle-orm"; + +async function inspectExperimentSteps() { + // Find experiment by ID + const experiment = await db.query.experiments.findFirst({ + where: eq(experiments.id, "961d0cb1-256d-4951-8387-6d855a0ae603") + }); + + if (!experiment) { + console.log("Experiment not found!"); + return; + } + + console.log(`Inspecting Experiment: ${experiment.name} (${experiment.id})`); + + const experimentSteps = await db.query.steps.findMany({ + where: eq(steps.experimentId, experiment.id), + orderBy: [asc(steps.orderIndex)], + with: { + actions: { + orderBy: (actions, { asc }) => [asc(actions.orderIndex)] + } + } + }); + + console.log(`Found ${experimentSteps.length} steps.`); + + for (const step of experimentSteps) { + console.log("--------------------------------------------------"); + console.log(`Step [${step.orderIndex}] ID: ${step.id}`); + console.log(`Name: ${step.name}`); + console.log(`Type: ${step.type}`); + console.log(`NextStepId: ${step.nextStepId}`); + + if (step.type === 'conditional') { + console.log("Conditions:", JSON.stringify(step.conditions, null, 2)); + } + + if (step.actions.length > 0) { + console.log("Actions:"); + for (const action of step.actions) { + console.log(` - [${action.orderIndex}] ${action.name} (${action.type})`); + if (action.type === 'wizard_wait_for_response') { + console.log(" Parameters:", JSON.stringify(action.parameters, null, 2)); + } + } + } + } +} + +inspectExperimentSteps() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); + diff --git a/scripts/inspect-visual-design.ts b/scripts/inspect-visual-design.ts new file mode 100644 index 0000000..77247eb --- /dev/null +++ b/scripts/inspect-visual-design.ts @@ -0,0 +1,33 @@ + +import { db } from "../src/server/db"; +import { experiments } from "../src/server/db/schema"; +import { eq } from "drizzle-orm"; + +async function inspectVisualDesign() { + const exps = await db.select().from(experiments); + + for (const exp of exps) { + console.log(`Experiment: ${exp.name}`); + if (exp.visualDesign) { + const vd = exp.visualDesign as any; + console.log("Visual Design Steps:"); + if (vd.steps && Array.isArray(vd.steps)) { + vd.steps.forEach((s: any, i: number) => { + console.log(` [${i}] ${s.name} (${s.type})`); + console.log(` Trigger: ${JSON.stringify(s.trigger)}`); + }); + } else { + console.log(" No steps in visualDesign or invalid format."); + } + } else { + console.log(" No visualDesign blob."); + } + } +} + +inspectVisualDesign() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/scripts/patch-branch-action-params.ts b/scripts/patch-branch-action-params.ts new file mode 100644 index 0000000..50fa836 --- /dev/null +++ b/scripts/patch-branch-action-params.ts @@ -0,0 +1,69 @@ + +import { db } from "~/server/db"; +import { actions, steps } from "~/server/db/schema"; +import { eq, sql } from "drizzle-orm"; + +async function patchActionParams() { + console.log("Patching Action Parameters for Interactive Storyteller..."); + + // Target Step IDs + const step3CondId = "b9d43f8c-c40c-4f1c-9fdc-9076338d3c85"; // Step 3: Comprehension Check + const actionId = "10851aef-e720-45fc-ba5e-05e1e3425dab"; // Action: Wait for Choice + + // 1. Get the authoritative conditions from the Step + const step = await db.query.steps.findFirst({ + where: eq(steps.id, step3CondId) + }); + + if (!step) { + console.error("Step 3 not found!"); + return; + } + + const conditions = step.conditions as any; + const richOptions = conditions?.options; + + if (!richOptions || !Array.isArray(richOptions)) { + console.error("Step 3 conditions are missing valid options!"); + return; + } + + console.log("Found rich options in Step:", JSON.stringify(richOptions, null, 2)); + + // 2. Get the Action + const action = await db.query.actions.findFirst({ + where: eq(actions.id, actionId) + }); + + if (!action) { + console.error("Action not found!"); + return; + } + + console.log("Current Action Parameters:", JSON.stringify(action.parameters, null, 2)); + + // 3. Patch the Action Parameters + // We replace the simple string options with the rich object options + const currentParams = action.parameters as any; + const newParams = { + ...currentParams, + options: richOptions // Overwrite with rich options from step + }; + + console.log("New Action Parameters:", JSON.stringify(newParams, null, 2)); + + await db.execute(sql` + UPDATE hs_action + SET parameters = ${JSON.stringify(newParams)}::jsonb + WHERE id = ${actionId} + `); + + console.log("Action parameters successfully patched."); +} + +patchActionParams() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/scripts/patch-branch-steps.ts b/scripts/patch-branch-steps.ts new file mode 100644 index 0000000..e4e676c --- /dev/null +++ b/scripts/patch-branch-steps.ts @@ -0,0 +1,92 @@ + +import { db } from "~/server/db"; +import { steps } from "~/server/db/schema"; +import { eq, sql } from "drizzle-orm"; + +async function patchBranchSteps() { + console.log("Patching branch steps for Interactive Storyteller..."); + + // Target Step IDs (From debug output) + const step3CondId = "b9d43f8c-c40c-4f1c-9fdc-9076338d3c85"; // Step 3: Comprehension Check + const stepBranchAId = "3a2dc0b7-a43e-4236-9b9e-f957abafc1e5"; // Step 4: Branch A (Correct) + const stepBranchBId = "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30"; // Step 5: Branch B (Incorrect) + const stepConclusionId = "cc3fbc7f-29e5-45e0-8d46-e80813c54292"; // Step 6: Conclusion + + // Update Step 3 (The Conditional Step) + console.log("Updating Step 3 (Conditional Step)..."); + const step3Conditional = await db.query.steps.findFirst({ + where: eq(steps.id, step3CondId) + }); + + if (step3Conditional) { + const currentConditions = (step3Conditional.conditions as any) || {}; + const options = currentConditions.options || []; + + // Patch options to point to real step IDs + const newOptions = options.map((opt: any) => { + if (opt.value === "Correct") return { ...opt, nextStepId: stepBranchAId }; + if (opt.value === "Incorrect") return { ...opt, nextStepId: stepBranchBId }; + return opt; + }); + + const newConditions = { ...currentConditions, options: newOptions }; + + await db.execute(sql` + UPDATE hs_step + SET conditions = ${JSON.stringify(newConditions)}::jsonb + WHERE id = ${step3CondId} + `); + console.log("Step 3 (Conditional) updated links."); + } else { + console.log("Step 3 (Conditional) not found."); + } + + // Update Step 4 (Branch A) + console.log("Updating Step 4 (Branch A)..."); + /* + Note: We already patched Step 4 in previous run but under wrong assumption? + Let's re-patch to be safe. + Debug output showed ID: 3a2dc0b7-a43e-4236-9b9e-f957abafc1e5 + It should jump to Conclusion (cc3fbc7f...) + */ + const stepBranchA = await db.query.steps.findFirst({ + where: eq(steps.id, stepBranchAId) + }); + + if (stepBranchA) { + const currentConditions = (stepBranchA.conditions as Record) || {}; + const newConditions = { ...currentConditions, nextStepId: stepConclusionId }; + + await db.execute(sql` + UPDATE hs_step + SET conditions = ${JSON.stringify(newConditions)}::jsonb + WHERE id = ${stepBranchAId} + `); + console.log("Step 4 (Branch A) updated jump target."); + } + + // Update Step 5 (Branch B) + console.log("Updating Step 5 (Branch B)..."); + const stepBranchB = await db.query.steps.findFirst({ + where: eq(steps.id, stepBranchBId) + }); + + if (stepBranchB) { + const currentConditions = (stepBranchB.conditions as Record) || {}; + const newConditions = { ...currentConditions, nextStepId: stepConclusionId }; + + await db.execute(sql` + UPDATE hs_step + SET conditions = ${JSON.stringify(newConditions)}::jsonb + WHERE id = ${stepBranchBId} + `); + console.log("Step 5 (Branch B) updated jump target."); + } +} + +patchBranchSteps() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/scripts/seed-dev.ts b/scripts/seed-dev.ts index c926ffd..93fc2ee 100755 --- a/scripts/seed-dev.ts +++ b/scripts/seed-dev.ts @@ -35,6 +35,19 @@ async function loadNaoPluginDef() { // Global variable to hold the loaded definition let NAO_PLUGIN_DEF: any; +let CORE_PLUGIN_DEF: any; +let WOZ_PLUGIN_DEF: any; + +function loadSystemPlugin(filename: string) { + const LOCAL_PATH = path.join(__dirname, `../src/plugins/definitions/${filename}`); + try { + const raw = fs.readFileSync(LOCAL_PATH, "utf-8"); + return JSON.parse(raw); + } catch (err) { + console.error(`❌ Failed to load system plugin ${filename}:`, err); + process.exit(1); + } +} async function main() { console.log("🌱 Starting realistic seed script..."); @@ -43,6 +56,8 @@ async function main() { try { NAO_PLUGIN_DEF = await loadNaoPluginDef(); + CORE_PLUGIN_DEF = loadSystemPlugin("hristudio-core.json"); + WOZ_PLUGIN_DEF = loadSystemPlugin("hristudio-woz.json"); // Ensure legacy 'actions' property maps to 'actionDefinitions' if needed, though schema supports both if we map it if (NAO_PLUGIN_DEF.actions && !NAO_PLUGIN_DEF.actionDefinitions) { @@ -61,6 +76,7 @@ async function main() { await db.delete(schema.studyPlugins).where(sql`1=1`); await db.delete(schema.studyMembers).where(sql`1=1`); await db.delete(schema.studies).where(sql`1=1`); + await db.delete(schema.studies).where(sql`1=1`); await db.delete(schema.plugins).where(sql`1=1`); await db.delete(schema.pluginRepositories).where(sql`1=1`); await db.delete(schema.userSystemRoles).where(sql`1=1`); @@ -144,12 +160,51 @@ async function main() { { studyId: study!.id, userId: researcherUser!.id, role: "researcher" } ]); - await db.insert(schema.studyPlugins).values({ - studyId: study!.id, - pluginId: naoPlugin!.id, - configuration: { robotIp: "10.0.0.42" }, - installedBy: adminUser.id - }); + // Insert System Plugins + const [corePlugin] = await db.insert(schema.plugins).values({ + name: CORE_PLUGIN_DEF.name, + version: CORE_PLUGIN_DEF.version, + description: CORE_PLUGIN_DEF.description, + author: CORE_PLUGIN_DEF.author, + trustLevel: "official", + actionDefinitions: CORE_PLUGIN_DEF.actionDefinitions, + robotId: null, // System Plugin + metadata: { ...CORE_PLUGIN_DEF, id: CORE_PLUGIN_DEF.id }, + status: "active" + }).returning(); + + const [wozPlugin] = await db.insert(schema.plugins).values({ + name: WOZ_PLUGIN_DEF.name, + version: WOZ_PLUGIN_DEF.version, + description: WOZ_PLUGIN_DEF.description, + author: WOZ_PLUGIN_DEF.author, + trustLevel: "official", + actionDefinitions: WOZ_PLUGIN_DEF.actionDefinitions, + robotId: null, // System Plugin + metadata: { ...WOZ_PLUGIN_DEF, id: WOZ_PLUGIN_DEF.id }, + status: "active" + }).returning(); + + await db.insert(schema.studyPlugins).values([ + { + studyId: study!.id, + pluginId: naoPlugin!.id, + configuration: { robotIp: "10.0.0.42" }, + installedBy: adminUser.id + }, + { + studyId: study!.id, + pluginId: corePlugin!.id, + configuration: {}, + installedBy: adminUser.id + }, + { + studyId: study!.id, + pluginId: wozPlugin!.id, + configuration: {}, + installedBy: adminUser.id + } + ]); const [experiment] = await db.insert(schema.experiments).values({ studyId: study!.id, @@ -256,16 +311,49 @@ async function main() { } ]); + // --- Step 3: Comprehension Check (Wizard Decision Point) --- + // Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect) + // --- Step 3: Comprehension Check (Wizard Decision Point) --- + // Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect) + // --- Step 4a: Correct Response Branch --- + const [step4a] = await db.insert(schema.steps).values({ + experimentId: experiment!.id, + name: "Branch A: Correct Response", + description: "Response when participant says 'Red'", + type: "robot", + orderIndex: 3, + required: false, + durationEstimate: 20 + }).returning(); + + // --- 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(); + // --- 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", + type: "conditional", orderIndex: 2, required: true, - durationEstimate: 30 + durationEstimate: 30, + conditions: { + variable: "last_wizard_response", + options: [ + { label: "Correct Response (Red)", value: "Correct", nextStepId: step4a!.id, variant: "default" }, + { label: "Incorrect Response", value: "Incorrect", nextStepId: step4b!.id, variant: "destructive" } + ] + } }).returning(); await db.insert(schema.actions).values([ @@ -282,30 +370,30 @@ async function main() { }, { stepId: step3!.id, - name: "Wait for Wizard Input", + name: "Wait for Choice", type: "wizard_wait_for_response", orderIndex: 1, + // Define the options that will be presented to the Wizard parameters: { prompt_text: "Did participant answer 'Red' correctly?", - response_type: "verbal", - timeout: 60 + options: ["Correct", "Incorrect"] }, sourceKind: "core", + pluginId: "hristudio-woz", // Explicit link category: "wizard" + }, + { + stepId: step3!.id, + name: "Branch Decision", + type: "branch", + orderIndex: 2, + parameters: {}, + sourceKind: "core", + pluginId: "hristudio-core", // Explicit link + category: "control" } ]); - // --- Step 4a: Correct Response Branch --- - const [step4a] = await db.insert(schema.steps).values({ - experimentId: experiment!.id, - name: "Branch A: Correct Response", - description: "Response when participant says 'Red'", - type: "robot", - orderIndex: 3, - required: false, - durationEstimate: 20 - }).returning(); - await db.insert(schema.actions).values([ { stepId: step4a!.id, @@ -342,16 +430,7 @@ async function main() { } ]); - // --- 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([ { diff --git a/scripts/simulate-branch-logic.ts b/scripts/simulate-branch-logic.ts new file mode 100644 index 0000000..8a726e8 --- /dev/null +++ b/scripts/simulate-branch-logic.ts @@ -0,0 +1,78 @@ + +// Mock of the logic in WizardInterface.tsx handleNextStep +const steps = [ + { + id: "b9d43f8c-c40c-4f1c-9fdc-9076338d3c85", + name: "Step 3 (Conditional)", + order: 2 + }, + { + id: "3a2dc0b7-a43e-4236-9b9e-f957abafc1e5", + name: "Step 4 (Branch A)", + order: 3, + conditions: { + "nextStepId": "cc3fbc7f-29e5-45e0-8d46-e80813c54292" + } + }, + { + id: "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30", + name: "Step 5 (Branch B)", + order: 4, + conditions: { + "nextStepId": "cc3fbc7f-29e5-45e0-8d46-e80813c54292" + } + }, + { + id: "cc3fbc7f-29e5-45e0-8d46-e80813c54292", + name: "Step 6 (Conclusion)", + order: 5 + } +]; + +function simulateNextStep(currentStepIndex: number) { + const currentStep = steps[currentStepIndex]; + console.log(`\n--- Simulating Next Step from: ${currentStep.name} ---`); + console.log("Current Step Data:", JSON.stringify(currentStep, null, 2)); + + // Logic from WizardInterface.tsx + console.log("[WizardInterface] Checking for nextStepId condition:", currentStep?.conditions); + + if (currentStep?.conditions?.nextStepId) { + const nextId = String(currentStep.conditions.nextStepId); + const targetIndex = steps.findIndex(s => s.id === nextId); + + console.log(`Target ID: ${nextId}`); + console.log(`Target Index Found: ${targetIndex}`); + + if (targetIndex !== -1) { + console.log(`[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`); + return targetIndex; + } else { + console.warn(`[WizardInterface] Targeted nextStepId ${nextId} not found in steps list.`); + } + } else { + console.log("[WizardInterface] No nextStepId found in conditions, proceeding linearly."); + } + + // Default: Linear progression + const nextIndex = currentStepIndex + 1; + console.log(`Proceeding linearly to index ${nextIndex}`); + return nextIndex; +} + +// Simulate Branch A (Index 1 in this array, but 3 in real experiment?) +// In real exp, Step 3 is index 2. Step 4 (Branch A) is index 3. +console.log("Real experiment indices:"); +// 0: Hook, 1: Narrative, 2: Conditional, 3: Branch A, 4: Branch B, 5: Conclusion +const indexStep4 = 1; // logical index in my mock array +const indexStep5 = 2; // logical index + +console.log("Testing Branch A Logic:"); +const resultA = simulateNextStep(indexStep4); +if (resultA === 3) console.log("SUCCESS: Branch A jumped to Conclusion"); +else console.log("FAILURE: Branch A fell through"); + +console.log("\nTesting Branch B Logic:"); +const resultB = simulateNextStep(indexStep5); +if (resultB === 3) console.log("SUCCESS: Branch B jumped to Conclusion"); +else console.log("FAILURE: Branch B fell through"); diff --git a/scripts/verify-conversion.ts b/scripts/verify-conversion.ts new file mode 100644 index 0000000..5290022 --- /dev/null +++ b/scripts/verify-conversion.ts @@ -0,0 +1,44 @@ + +import { db } from "../src/server/db"; +import { experiments } from "../src/server/db/schema"; +import { eq, asc } from "drizzle-orm"; +import { convertDatabaseToSteps } from "../src/lib/experiment-designer/block-converter"; + +async function verifyConversion() { + const experiment = await db.query.experiments.findFirst({ + with: { + steps: { + orderBy: (steps, { asc }) => [asc(steps.orderIndex)], + with: { + actions: { + orderBy: (actions, { asc }) => [asc(actions.orderIndex)], + } + } + } + } + }); + + if (!experiment) { + console.log("No experiment found"); + return; + } + + console.log("Raw DB Steps Count:", experiment.steps.length); + const converted = convertDatabaseToSteps(experiment.steps); + + console.log("Converted Steps:"); + converted.forEach((s, idx) => { + console.log(`[${idx}] ${s.name} (${s.type})`); + console.log(` Trigger:`, JSON.stringify(s.trigger)); + if (s.type === 'conditional') { + console.log(` Conditions populated?`, Object.keys(s.trigger.conditions).length > 0); + } + }); +} + +verifyConversion() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/scripts/verify-trpc-logic.ts b/scripts/verify-trpc-logic.ts new file mode 100644 index 0000000..3578cdc --- /dev/null +++ b/scripts/verify-trpc-logic.ts @@ -0,0 +1,74 @@ + +import { db } from "~/server/db"; +import { experiments, steps, actions } from "~/server/db/schema"; +import { eq, asc, desc } from "drizzle-orm"; +import { convertDatabaseToSteps } from "~/lib/experiment-designer/block-converter"; + +async function verifyTrpcLogic() { + console.log("Verifying TRPC Logic for Interactive Storyteller..."); + + // 1. Simulate the DB Query from experiments.ts + const experiment = await db.query.experiments.findFirst({ + where: eq(experiments.name, "The Interactive Storyteller"), + with: { + study: { + columns: { + id: true, + name: true, + }, + }, + createdBy: { + columns: { + id: true, + name: true, + email: true, + }, + }, + robot: true, + steps: { + with: { + actions: { + orderBy: [asc(actions.orderIndex)], + }, + }, + orderBy: [asc(steps.orderIndex)], + }, + }, + }); + + if (!experiment) { + console.error("Experiment not found!"); + return; + } + + // 2. Simulate the Transformation + console.log("Transforming DB steps to Designer steps..."); + const transformedSteps = convertDatabaseToSteps(experiment.steps); + + // 3. Inspect Step 4 (Branch A) + // Step index 3 (0-based) is Branch A + const branchAStep = transformedSteps[3]; + console.log("Step 4 (Branch A):", branchAStep.name); + console.log(" Type:", branchAStep.type); + console.log(" Trigger:", JSON.stringify(branchAStep.trigger, null, 2)); + + // Check conditions specifically + const conditions = branchAStep.trigger?.conditions as any; + if (conditions?.nextStepId) { + console.log("SUCCESS: nextStepId found in conditions:", conditions.nextStepId); + } else { + console.error("FAILURE: nextStepId MISSING in conditions!"); + } + + // Inspect Step 5 (Branch B) for completeness + const branchBStep = transformedSteps[4]; + console.log("Step 5 (Branch B):", branchBStep.name); + console.log(" Trigger:", JSON.stringify(branchBStep.trigger, null, 2)); +} + +verifyTrpcLogic() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/src/app/(dashboard)/studies/[id]/experiments/[experimentId]/designer/page.tsx b/src/app/(dashboard)/studies/[id]/experiments/[experimentId]/designer/page.tsx index 08093d8..aa354cb 100755 --- a/src/app/(dashboard)/studies/[id]/experiments/[experimentId]/designer/page.tsx +++ b/src/app/(dashboard)/studies/[id]/experiments/[experimentId]/designer/page.tsx @@ -222,7 +222,10 @@ export default async function ExperimentDesignerPage({ : "sequential"; })(), order: s.orderIndex ?? idx, - trigger: { type: "trial_start", conditions: {} }, + trigger: { + type: idx === 0 ? "trial_start" : "previous_step", + conditions: (s.conditions as Record) || {}, + }, actions, expanded: true, }; diff --git a/src/app/(dashboard)/studies/[id]/trials/[trialId]/wizard/page.tsx b/src/app/(dashboard)/studies/[id]/trials/[trialId]/wizard/page.tsx index 362a621..99b965a 100755 --- a/src/app/(dashboard)/studies/[id]/trials/[trialId]/wizard/page.tsx +++ b/src/app/(dashboard)/studies/[id]/trials/[trialId]/wizard/page.tsx @@ -171,10 +171,27 @@ function WizardPageContent() { const renderView = () => { const trialData = { - ...trial, + id: trial.id, + status: trial.status, + scheduledAt: trial.scheduledAt, + startedAt: trial.startedAt, + completedAt: trial.completedAt, + duration: trial.duration, + sessionNumber: trial.sessionNumber, + notes: trial.notes, metadata: trial.metadata as Record | null, + experimentId: trial.experimentId, + participantId: trial.participantId, + wizardId: trial.wizardId, + experiment: { + id: trial.experiment.id, + name: trial.experiment.name, + description: trial.experiment.description, + studyId: trial.experiment.studyId, + }, participant: { - ...trial.participant, + id: trial.participant.id, + participantCode: trial.participant.participantCode, demographics: trial.participant.demographics as Record< string, unknown @@ -184,7 +201,7 @@ function WizardPageContent() { switch (currentRole) { case "wizard": - return ; + return ; case "observer": return ; case "participant": @@ -195,24 +212,8 @@ function WizardPageContent() { }; return ( -
- - - - Back to Trial - - - ) : null - } - /> - -
{renderView()}
+
+ {renderView()}
); } diff --git a/src/components/experiments/designer/ActionRegistry.ts b/src/components/experiments/designer/ActionRegistry.ts index b79f9ac..ff21610 100755 --- a/src/components/experiments/designer/ActionRegistry.ts +++ b/src/components/experiments/designer/ActionRegistry.ts @@ -1,13 +1,15 @@ "use client"; import { useState, useEffect } from "react"; -import type { ActionDefinition } from "~/lib/experiment-designer/types"; +import type { ActionDefinition, ExperimentAction } from "~/lib/experiment-designer/types"; +import corePluginDef from "~/plugins/definitions/hristudio-core.json"; +import wozPluginDef from "~/plugins/definitions/hristudio-woz.json"; /** * ActionRegistry * * Central singleton for loading and serving action definitions from: - * - Core system action JSON manifests (served from /hristudio-core/plugins/*.json) + * - Core system action JSON manifests (hristudio-core, hristudio-woz) * - Study-installed plugin action definitions (ROS2 / REST / internal transports) * * Responsibilities: @@ -15,12 +17,6 @@ import type { ActionDefinition } from "~/lib/experiment-designer/types"; * - Provenance retention (core vs plugin, plugin id/version, robot id) * - Parameter schema → UI parameter mapping (primitive only for now) * - Fallback action population if core load fails (ensures minimal functionality) - * - * Notes: - * - The registry is client-side only (designer runtime); server performs its own - * validation & compilation using persisted action instances (never trusts client). - * - Action IDs for plugins are namespaced: `${plugin.id}.${action.id}`. - * - Core actions retain their base IDs (e.g., wait, wizard_speak) for clarity. */ export class ActionRegistry { private static instance: ActionRegistry; @@ -31,6 +27,8 @@ export class ActionRegistry { private loadedStudyId: string | null = null; private listeners = new Set<() => void>(); + private readonly SYSTEM_PLUGIN_IDS = ["hristudio-core", "hristudio-woz"]; + static getInstance(): ActionRegistry { if (!ActionRegistry.instance) { ActionRegistry.instance = new ActionRegistry(); @@ -49,234 +47,18 @@ export class ActionRegistry { this.listeners.forEach((listener) => listener()); } - /* ---------------- Core Actions ---------------- */ + /* ---------------- Core / System Actions ---------------- */ async loadCoreActions(): Promise { if (this.coreActionsLoaded) return; - interface CoreBlockParam { - id: string; - name: string; - type: string; - placeholder?: string; - options?: string[]; - min?: number; - max?: number; - value?: string | number | boolean; - required?: boolean; - description?: string; - step?: number; - } + // Load System Plugins (Core & WoZ) + this.registerPluginDefinition(corePluginDef); + this.registerPluginDefinition(wozPluginDef); - interface CoreBlock { - id: string; - name: string; - description?: string; - category: string; - icon?: string; - color?: string; - parameters?: CoreBlockParam[]; - timeoutMs?: number; - retryable?: boolean; - nestable?: boolean; - } + console.log(`[ActionRegistry] Loaded system plugins: ${this.SYSTEM_PLUGIN_IDS.join(", ")}`); - try { - const coreActionSets = [ - "wizard-actions", - "control-flow", - "observation", - "events", - ]; - - for (const actionSetId of coreActionSets) { - try { - const response = await fetch( - `/hristudio-core/plugins/${actionSetId}.json`, - ); - // Non-blocking skip if not found - if (!response.ok) continue; - - const rawActionSet = (await response.json()) as unknown; - const actionSet = rawActionSet as { blocks?: CoreBlock[] }; - if (!actionSet.blocks || !Array.isArray(actionSet.blocks)) continue; - - // Register each block as an ActionDefinition - actionSet.blocks.forEach((block) => { - if (!block.id || !block.name) return; - - const actionDef: ActionDefinition = { - id: block.id, - type: block.id, - name: block.name, - description: block.description ?? "", - category: this.mapBlockCategoryToActionCategory(block.category), - icon: block.icon ?? "Zap", - color: block.color ?? "#6b7280", - parameters: (block.parameters ?? []).map((param) => ({ - id: param.id, - name: param.name, - type: - (param.type as "text" | "number" | "select" | "boolean") || - "text", - placeholder: param.placeholder, - options: param.options, - min: param.min, - max: param.max, - value: param.value, - required: param.required !== false, - description: param.description, - step: param.step, - })), - source: { - kind: "core", - baseActionId: block.id, - }, - execution: { - transport: "internal", - timeoutMs: block.timeoutMs, - retryable: block.retryable, - }, - parameterSchemaRaw: { - parameters: block.parameters ?? [], - }, - nestable: block.nestable, - }; - - this.actions.set(actionDef.id, actionDef); - }); - } catch (error) { - // Non-fatal: we will fallback later - console.warn(`Failed to load core action set ${actionSetId}:`, error); - } - } - - this.coreActionsLoaded = true; - this.notifyListeners(); - } catch (error) { - console.error("Failed to load core actions:", error); - this.loadFallbackActions(); - } - } - - private mapBlockCategoryToActionCategory( - category: string, - ): ActionDefinition["category"] { - switch (category) { - case "wizard": - return "wizard"; - case "event": - return "wizard"; // Events are wizard-initiated triggers - case "robot": - return "robot"; - case "control": - return "control"; - case "sensor": - case "observation": - return "observation"; - default: - return "wizard"; - } - } - - private loadFallbackActions(): void { - const fallbackActions: ActionDefinition[] = [ - { - id: "wizard_say", - type: "wizard_say", - name: "Wizard Says", - description: "Wizard speaks to participant", - category: "wizard", - icon: "MessageSquare", - color: "#a855f7", - parameters: [ - { - id: "message", - name: "Message", - type: "text", - placeholder: "Hello, participant!", - required: true, - }, - { - id: "tone", - name: "Tone", - type: "select", - options: ["neutral", "friendly", "encouraging"], - value: "neutral", - }, - ], - source: { kind: "core", baseActionId: "wizard_say" }, - execution: { transport: "internal", timeoutMs: 30000 }, - parameterSchemaRaw: {}, - nestable: false, - }, - { - id: "wait", - type: "wait", - name: "Wait", - description: "Wait for specified time", - category: "control", - icon: "Clock", - color: "#f59e0b", - parameters: [ - { - id: "duration", - name: "Duration (seconds)", - type: "number", - min: 0.1, - max: 300, - value: 2, - required: true, - }, - ], - source: { kind: "core", baseActionId: "wait" }, - execution: { transport: "internal", timeoutMs: 60000 }, - parameterSchemaRaw: { - type: "object", - properties: { - duration: { - type: "number", - minimum: 0.1, - maximum: 300, - default: 2, - }, - }, - required: ["duration"], - }, - }, - { - id: "observe", - type: "observe", - name: "Observe", - description: "Record participant behavior", - category: "observation", - icon: "Eye", - color: "#8b5cf6", - parameters: [ - { - id: "behavior", - name: "Behavior to observe", - type: "select", - options: ["facial_expression", "body_language", "verbal_response"], - required: true, - }, - ], - source: { kind: "core", baseActionId: "observe" }, - execution: { transport: "internal", timeoutMs: 120000 }, - parameterSchemaRaw: { - type: "object", - properties: { - behavior: { - type: "string", - enum: ["facial_expression", "body_language", "verbal_response"], - }, - }, - required: ["behavior"], - }, - }, - ]; - - fallbackActions.forEach((action) => this.actions.set(action.id, action)); + this.coreActionsLoaded = true; this.notifyListeners(); } @@ -295,108 +77,133 @@ export class ActionRegistry { let totalActionsLoaded = 0; (studyPlugins ?? []).forEach((plugin) => { - const actionDefs = Array.isArray(plugin.actionDefinitions) - ? plugin.actionDefinitions - : undefined; - - if (!actionDefs) return; - - actionDefs.forEach((action: any) => { - const rawCategory = - typeof action.category === "string" - ? action.category.toLowerCase().trim() - : ""; - const categoryMap: Record = { - wizard: "wizard", - robot: "robot", - control: "control", - observation: "observation", - }; - const category = categoryMap[rawCategory] ?? "robot"; - - const execution = action.ros2 - ? { - transport: "ros2" as const, - timeoutMs: action.timeout, - retryable: action.retryable, - ros2: { - topic: action.ros2.topic, - messageType: action.ros2.messageType, - service: action.ros2.service, - action: action.ros2.action, - qos: action.ros2.qos, - payloadMapping: action.ros2.payloadMapping, - }, - } - : action.rest - ? { - transport: "rest" as const, - timeoutMs: action.timeout, - retryable: action.retryable, - rest: { - method: action.rest.method, - path: action.rest.path, - headers: action.rest.headers, - }, - } - : { - transport: "internal" as const, - timeoutMs: action.timeout, - retryable: action.retryable, - }; - - // Extract semantic ID from metadata if available, otherwise fall back to database IDs (which typically causes mismatch if seed uses semantic) - // Ideally, plugin.metadata.robotId should populate this. - const semanticRobotId = plugin.metadata?.robotId || plugin.robotId || plugin.id; - - const actionDef: ActionDefinition = { - id: `${semanticRobotId}.${action.id}`, - type: `${semanticRobotId}.${action.id}`, - name: action.name, - description: action.description ?? "", - category, - icon: action.icon ?? "Bot", - color: "#10b981", - parameters: this.convertParameterSchemaToParameters( - action.parameterSchema, - ), - source: { - kind: "plugin", - pluginId: semanticRobotId, // Use semantic ID here too - robotId: plugin.robotId, - pluginVersion: plugin.version ?? undefined, - baseActionId: action.id, - }, - execution, - 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++; - }); + this.registerPluginDefinition(plugin); + totalActionsLoaded += (plugin.actionDefinitions?.length || 0); }); console.log( `ActionRegistry: Loaded ${totalActionsLoaded} plugin actions for study ${studyId}`, ); - // console.log("Current action registry state:", { totalActions: this.actions.size }); - this.pluginActionsLoaded = true; this.loadedStudyId = studyId; this.notifyListeners(); } + /* ---------------- Shared Registration Logic ---------------- */ + + private registerPluginDefinition(plugin: any) { + const actionDefs = Array.isArray(plugin.actionDefinitions) + ? plugin.actionDefinitions + : undefined; + + if (!actionDefs) return; + + actionDefs.forEach((action: any) => { + const rawCategory = + typeof action.category === "string" + ? action.category.toLowerCase().trim() + : ""; + const categoryMap: Record = { + wizard: "wizard", + robot: "robot", + control: "control", + observation: "observation", + }; + + // Default category based on plugin type or explicit category + let category = categoryMap[rawCategory]; + if (!category) { + if (plugin.id === 'hristudio-woz') category = 'wizard'; + else if (plugin.id === 'hristudio-core') category = 'control'; + else category = 'robot'; + } + + const execution = action.ros2 + ? { + transport: "ros2" as const, + timeoutMs: action.timeout, + retryable: action.retryable, + ros2: { + topic: action.ros2.topic, + messageType: action.ros2.messageType, + service: action.ros2.service, + action: action.ros2.action, + qos: action.ros2.qos, + payloadMapping: action.ros2.payloadMapping, + }, + } + : action.rest + ? { + transport: "rest" as const, + timeoutMs: action.timeout, + retryable: action.retryable, + rest: { + method: action.rest.method, + path: action.rest.path, + headers: action.rest.headers, + }, + } + : { + transport: "internal" as const, + timeoutMs: action.timeout, + retryable: action.retryable, + }; + + // Extract semantic ID from metadata if available, otherwise fall back to database IDs + // Priority: metadata.robotId > metadata.id (for system plugins) > robotId > id + const semanticRobotId = + plugin.metadata?.robotId || + plugin.metadata?.id || + plugin.robotId || + plugin.id; + + // For system plugins, we want to keep the short IDs (wait, branch) to avoid breaking existing save data + // For robot plugins, we namespace them (nao6-ros2.say_text) + const isSystem = this.SYSTEM_PLUGIN_IDS.includes(semanticRobotId); + const actionId = isSystem ? action.id : `${semanticRobotId}.${action.id}`; + const actionType = actionId; // Type is usually same as ID + + const actionDef: ActionDefinition = { + id: actionId, + type: actionType, + name: action.name, + description: action.description ?? "", + category, + icon: action.icon ?? "Bot", + color: action.color || "#10b981", + parameters: this.convertParameterSchemaToParameters( + action.parameterSchema, + ), + source: { + kind: isSystem ? "core" : "plugin", // Maintain 'core' distinction for UI grouping if needed + pluginId: semanticRobotId, + robotId: plugin.robotId, + pluginVersion: plugin.version ?? undefined, + baseActionId: action.id, + }, + execution, + parameterSchemaRaw: action.parameterSchema ?? undefined, + nestable: action.nestable + }; + + // Prevent overwriting if it already exists (first-come-first-served, usually core first) + if (!this.actions.has(actionId)) { + this.actions.set(actionId, actionDef); + } + + // Register aliases + 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); + } + } + } + }); + } + private convertParameterSchemaToParameters( parameterSchema: unknown, ): ActionDefinition["parameters"] { @@ -417,7 +224,7 @@ export class ActionRegistry { if (!schema?.properties) return []; return Object.entries(schema.properties).map(([key, paramDef]) => { - let type: "text" | "number" | "select" | "boolean" = "text"; + let type: "text" | "number" | "select" | "boolean" | "json" | "array" = "text"; if (paramDef.type === "number") { type = "number"; @@ -425,6 +232,10 @@ export class ActionRegistry { type = "boolean"; } else if (paramDef.enum && Array.isArray(paramDef.enum)) { type = "select"; + } else if (paramDef.type === "array") { + type = "array"; + } else if (paramDef.type === "object") { + type = "json"; } return { @@ -444,29 +255,17 @@ export class ActionRegistry { private resetPluginActions(): void { this.pluginActionsLoaded = false; this.loadedStudyId = null; - // Remove existing plugin actions (retain known core ids + fallback ids) - const pluginActionIds = Array.from(this.actions.keys()).filter( - (id) => - !id.startsWith("wizard_") && - !id.startsWith("when_") && - !id.startsWith("wait") && - !id.startsWith("observe") && - !id.startsWith("repeat") && - !id.startsWith("if_") && - !id.startsWith("parallel") && - !id.startsWith("sequence") && - !id.startsWith("random_") && - !id.startsWith("try_") && - !id.startsWith("break") && - !id.startsWith("measure_") && - !id.startsWith("count_") && - !id.startsWith("record_") && - !id.startsWith("capture_") && - !id.startsWith("log_") && - !id.startsWith("survey_") && - !id.startsWith("physiological_"), - ); - pluginActionIds.forEach((id) => this.actions.delete(id)); + + // Robust Reset: Remove valid plugin actions, BUT protect system plugins. + const idsToDelete: string[] = []; + this.actions.forEach((action, id) => { + if (action.source.kind === "plugin" && !this.SYSTEM_PLUGIN_IDS.includes(action.source.pluginId || "")) { + idsToDelete.push(id); + } + }); + + idsToDelete.forEach((id) => this.actions.delete(id)); + this.notifyListeners(); } /* ---------------- Query Helpers ---------------- */ diff --git a/src/components/experiments/designer/DesignerRoot.tsx b/src/components/experiments/designer/DesignerRoot.tsx index aa40339..0c1ed43 100755 --- a/src/components/experiments/designer/DesignerRoot.tsx +++ b/src/components/experiments/designer/DesignerRoot.tsx @@ -116,10 +116,21 @@ 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). + // 1. Prefer database steps (Source of Truth) if valid. 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); + // Check if steps are already converted (have trigger property) to avoid double-conversion data loss + const firstStep = exp.steps[0] as any; + let dbSteps: ExperimentStep[]; + + if (firstStep && typeof firstStep === 'object' && 'trigger' in firstStep) { + // Already converted by server + dbSteps = exp.steps as ExperimentStep[]; + } else { + // Raw DB steps, need conversion + dbSteps = convertDatabaseToSteps(exp.steps); + } + return { id: exp.id, name: exp.name, @@ -129,7 +140,7 @@ function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined { lastSaved: new Date(), }; } catch (err) { - console.warn('[DesignerRoot] Failed to convert DB steps, falling back to visualDesign:', err); + console.warn('[DesignerRoot] Failed to convert/hydrate steps, falling back to visualDesign:', err); } } @@ -616,6 +627,9 @@ export function DesignerRoot({ setLastSavedAt(new Date()); toast.success("Experiment saved"); + // Auto-validate after save to clear "Modified" (drift) status + void validateDesign(); + console.log('[DesignerRoot] 💾 SAVE complete'); onPersist?.({ diff --git a/src/components/experiments/designer/PropertiesPanel.tsx b/src/components/experiments/designer/PropertiesPanel.tsx index 031f80f..d39002e 100755 --- a/src/components/experiments/designer/PropertiesPanel.tsx +++ b/src/components/experiments/designer/PropertiesPanel.tsx @@ -23,6 +23,7 @@ import { type ExperimentDesign, } from "~/lib/experiment-designer/types"; import { actionRegistry } from "./ActionRegistry"; +import { Button } from "~/components/ui/button"; import { Settings, Zap, @@ -39,6 +40,9 @@ import { Mic, Activity, Play, + Plus, + GitBranch, + Trash2, } from "lucide-react"; /** @@ -275,35 +279,166 @@ export function PropertiesPanelBase({
- {/* Parameters */} - {def?.parameters.length ? ( + {/* Branching Configuration (Special Case) */} + {selectedAction.type === "branch" ? (
-
- Parameters +
+ Branch Options +
+
- {def.parameters.map((param) => ( - { - onActionUpdate(containingStep.id, selectedAction.id, { - parameters: { - ...selectedAction.parameters, - [param.id]: val, - }, - }); - }} - onCommit={() => { }} - /> + {((containingStep.trigger.conditions as any)?.options || []).map((opt: any, idx: number) => ( +
+
+
+ + { + const newOpts = [...((containingStep.trigger.conditions as any)?.options || [])]; + newOpts[idx] = { ...newOpts[idx], label: e.target.value }; + onStepUpdate(containingStep.id, { + trigger: { ...containingStep.trigger, conditions: { ...containingStep.trigger.conditions, options: newOpts } } + }); + }} + className="h-7 text-xs" + /> +
+
+ + +
+
+
+ + + +
+
))} + {(!((containingStep.trigger.conditions as any)?.options?.length)) && ( +
+ No options defined.
Click + to add a branch. +
+ )}
) : ( -
- No parameters for this action. -
+ /* Standard Parameters */ + def?.parameters.length ? ( +
+
+ Parameters +
+
+ {def.parameters.map((param) => ( + { + onActionUpdate(containingStep.id, selectedAction.id, { + parameters: { + ...selectedAction.parameters, + [param.id]: val, + }, + }); + }} + onCommit={() => { }} + /> + ))} +
+
+ ) : ( +
+ No parameters for this action. +
+ ) )}
); diff --git a/src/components/experiments/designer/flow/FlowWorkspace.tsx b/src/components/experiments/designer/flow/FlowWorkspace.tsx index 9602550..77f2525 100755 --- a/src/components/experiments/designer/flow/FlowWorkspace.tsx +++ b/src/components/experiments/designer/flow/FlowWorkspace.tsx @@ -29,6 +29,7 @@ import { Trash2, GitBranch, Edit3, + CornerDownRight, } from "lucide-react"; import { cn } from "~/lib/utils"; import { @@ -96,6 +97,7 @@ interface StepRowProps { registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void; onReorderStep: (stepId: string, direction: 'up' | 'down') => void; onReorderAction?: (stepId: string, actionId: string, direction: 'up' | 'down') => void; + isChild?: boolean; } function StepRow({ @@ -115,8 +117,10 @@ function StepRow({ registerMeasureRef, onReorderStep, onReorderAction, + isChild, }: StepRowProps) { // const step = item.step; // Removed local derivation + const allSteps = useDesignerStore((s) => s.steps); const insertionProjection = useDesignerStore((s) => s.insertionProjection); const displayActions = useMemo(() => { @@ -149,9 +153,17 @@ function StepRow({
registerMeasureRef(step.id, el)} - className="relative px-3 py-4" + className={cn( + "relative px-3 py-4 transition-all duration-300", + isChild && "ml-8 pl-0" + )} data-step-id={step.id} > + {isChild && ( +
+ +
+ )}
+ + + {/* Conditional Branching Visualization */} + {/* Conditional Branching Visualization */} + {step.type === "conditional" && ( +
+
+ + Branching Logic +
+ +
+ {!(step.trigger.conditions as any)?.options?.length ? ( +
+ No branches configured. Add options in properties. +
+ ) : ( + (step.trigger.conditions as any).options.map((opt: any, idx: number) => { + // Resolve ID to name for display + let targetName = "Unlinked"; + let targetIndex = -1; + + if (opt.nextStepId) { + const target = allSteps.find(s => s.id === opt.nextStepId); + if (target) { + targetName = target.name; + targetIndex = target.order; + } + } else if (typeof opt.nextStepIndex === 'number') { + targetIndex = opt.nextStepIndex; + targetName = `Step #${targetIndex + 1}`; + } + + return ( +
+
+ + {opt.label} + + then go to +
+ +
+ + {targetName} + + {targetIndex !== -1 && ( + + #{targetIndex + 1} + + )} + +
+
+ ); + }) + )} +
+
+ )} + {/* Action List (Collapsible/Virtual content) */} {step.expanded && (
@@ -315,7 +399,7 @@ function StepRow({ )}
-
+ ); } @@ -787,6 +871,21 @@ export function FlowWorkspace({ return map; }, [steps]); + /* Hierarchy detection for visual indentation */ + const childStepIds = useMemo(() => { + const children = new Set(); + for (const step of steps) { + if (step.type === 'conditional' && (step.trigger.conditions as any)?.options) { + for (const opt of (step.trigger.conditions as any).options) { + if (opt.nextStepId) { + children.add(opt.nextStepId); + } + } + } + } + return children; + }, [steps]); + /* Resize observer for viewport and width changes */ useLayoutEffect(() => { const el = containerRef.current; @@ -1202,6 +1301,7 @@ export function FlowWorkspace({ registerMeasureRef={registerMeasureRef} onReorderStep={handleReorderStep} onReorderAction={handleReorderAction} + isChild={childStepIds.has(vi.step.id)} /> ), )} diff --git a/src/components/experiments/designer/state/hashing.ts b/src/components/experiments/designer/state/hashing.ts index 302a698..48fb709 100755 --- a/src/components/experiments/designer/state/hashing.ts +++ b/src/components/experiments/designer/state/hashing.ts @@ -203,8 +203,7 @@ function projectStepForDesign( order: step.order, trigger: { type: step.trigger.type, - // Only the sorted keys of conditions (structural presence) - conditionKeys: Object.keys(step.trigger.conditions).sort(), + conditions: canonicalize(step.trigger.conditions), }, actions: step.actions.map((a) => projectActionForDesign(a, options)), }; @@ -267,11 +266,35 @@ export async function computeDesignHash( opts: DesignHashOptions = {}, ): Promise { const options = { ...DEFAULT_OPTIONS, ...opts }; - const projected = steps - .slice() - .sort((a, b) => a.order - b.order) - .map((s) => projectStepForDesign(s, options)); - return hashObject({ steps: projected }); + + // 1. Sort steps first to ensure order independence of input array + const sortedSteps = steps.slice().sort((a, b) => a.order - b.order); + + // 2. Map hierarchically (Merkle style) + const stepHashes = await Promise.all(sortedSteps.map(async (s) => { + // Action hashes + const actionHashes = await Promise.all(s.actions.map(a => hashObject(projectActionForDesign(a, options)))); + + // Step hash + const pStep = { + id: s.id, + type: s.type, + order: s.order, + trigger: { + type: s.trigger.type, + conditions: canonicalize(s.trigger.conditions), + }, + actions: actionHashes, + ...(options.includeStepNames ? { name: s.name } : {}), + }; + return hashObject(pStep); + })); + + // 3. Aggregate design hash + return hashObject({ + steps: stepHashes, + count: steps.length + }); } /* -------------------------------------------------------------------------- */ @@ -338,7 +361,7 @@ export async function computeIncrementalDesignHash( order: step.order, trigger: { type: step.trigger.type, - conditionKeys: Object.keys(step.trigger.conditions).sort(), + conditions: canonicalize(step.trigger.conditions), }, actions: step.actions.map((a) => actionHashes.get(a.id) ?? ""), ...(options.includeStepNames ? { name: step.name } : {}), diff --git a/src/components/experiments/designer/state/validators.ts b/src/components/experiments/designer/state/validators.ts index 3bd77d7..0027470 100755 --- a/src/components/experiments/designer/state/validators.ts +++ b/src/components/experiments/designer/state/validators.ts @@ -53,6 +53,7 @@ export interface ValidationResult { // Parallel/conditional/loop execution happens at the ACTION level, not step level const VALID_STEP_TYPES: StepType[] = [ "sequential", + "conditional", ]; const VALID_TRIGGER_TYPES: TriggerType[] = [ "trial_start", @@ -391,6 +392,34 @@ export function validateParameters( } break; + case "array": + if (!Array.isArray(value)) { + issues.push({ + severity: "error", + message: `Parameter '${paramDef.name}' must be a list/array`, + category: "parameter", + field, + stepId, + actionId, + suggestion: "Enter a list of values", + }); + } + break; + + case "json": + if (typeof value !== "object" || value === null) { + issues.push({ + severity: "error", + message: `Parameter '${paramDef.name}' must be a valid object`, + category: "parameter", + field, + stepId, + actionId, + suggestion: "Enter a valid JSON object", + }); + } + break; + default: // Unknown parameter type issues.push({ diff --git a/src/components/trials/views/WizardView.tsx b/src/components/trials/views/WizardView.tsx index f2a75e9..bb6195c 100755 --- a/src/components/trials/views/WizardView.tsx +++ b/src/components/trials/views/WizardView.tsx @@ -29,12 +29,13 @@ interface WizardViewProps { demographics: Record | null; }; }; + userRole: string; } -export function WizardView({ trial }: WizardViewProps) { +export function WizardView({ trial, userRole }: WizardViewProps) { return ( -
- +
+
); } diff --git a/src/components/trials/wizard/RobotActionsPanel.tsx b/src/components/trials/wizard/RobotActionsPanel.tsx index 980db0d..26738ed 100755 --- a/src/components/trials/wizard/RobotActionsPanel.tsx +++ b/src/components/trials/wizard/RobotActionsPanel.tsx @@ -155,7 +155,7 @@ export function RobotActionsPanel({ disconnect: disconnectRos, executeRobotAction: executeRosAction, } = useWizardRos({ - autoConnect: true, + autoConnect: false, // Let WizardInterface handle connection onActionCompleted: (execution) => { toast.success(`Completed: ${execution.actionId}`, { description: `Action executed in ${execution.endTime ? execution.endTime.getTime() - execution.startTime.getTime() : 0}ms`, diff --git a/src/components/trials/wizard/WizardInterface.tsx b/src/components/trials/wizard/WizardInterface.tsx index 46ef0b0..fbb01f7 100755 --- a/src/components/trials/wizard/WizardInterface.tsx +++ b/src/components/trials/wizard/WizardInterface.tsx @@ -1,21 +1,35 @@ "use client"; import React, { useState, useEffect, useCallback, useMemo } from "react"; -import { Play, CheckCircle, X, Clock, AlertCircle, HelpCircle } from "lucide-react"; +import { + Play, + CheckCircle, + X, + Clock, + AlertCircle, + PanelLeftClose, + PanelLeftOpen, + PanelRightClose, + PanelRightOpen, + ChevronDown, + ChevronUp, + Pause, + SkipForward +} from "lucide-react"; import { useRouter } from "next/navigation"; +import { cn } from "~/lib/utils"; import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import { PageHeader } from "~/components/ui/page-header"; +import Link from "next/link"; import { Progress } from "~/components/ui/progress"; import { Alert, AlertDescription } from "~/components/ui/alert"; -import { PanelsContainer } from "~/components/experiments/designer/layout/PanelsContainer"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { WizardControlPanel } from "./panels/WizardControlPanel"; import { WizardExecutionPanel } from "./panels/WizardExecutionPanel"; import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel"; import { WizardObservationPane } from "./panels/WizardObservationPane"; -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "~/components/ui/resizable"; +import { TrialStatusBar } from "./panels/TrialStatusBar"; import { api } from "~/trpc/react"; import { useWizardRos } from "~/hooks/useWizardRos"; import { toast } from "sonner"; @@ -68,8 +82,18 @@ interface StepData { | "wizard_action" | "robot_action" | "parallel_steps" - | "conditional_branch"; + | "conditional"; parameters: Record; + conditions?: { + nextStepId?: string; + options?: { + label: string; + value: string; + nextStepId?: string; + nextStepIndex?: number; + variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"; + }[]; + }; order: number; actions: ActionData[]; } @@ -87,24 +111,31 @@ export const WizardInterface = React.memo(function WizardInterface({ const [elapsedTime, setElapsedTime] = useState(0); const router = useRouter(); - // Persistent tab states to prevent resets from parent re-renders - const [controlPanelTab, setControlPanelTab] = useState< - "control" | "step" | "actions" | "robot" - >("control"); - const [executionPanelTab, setExecutionPanelTab] = useState< - "current" | "timeline" | "events" - >(trial.status === "in_progress" ? "current" : "timeline"); + // UI State + const [executionPanelTab, setExecutionPanelTab] = useState<"current" | "timeline" | "events">("timeline"); + const [obsTab, setObsTab] = useState<"notes" | "timeline">("notes"); const [isExecutingAction, setIsExecutingAction] = useState(false); const [monitoringPanelTab, setMonitoringPanelTab] = useState< "status" | "robot" | "events" >("status"); const [completedActionsCount, setCompletedActionsCount] = useState(0); + // Collapse state for panels + const [leftCollapsed, setLeftCollapsed] = useState(false); + const [rightCollapsed, setRightCollapsed] = useState(false); + const [obsCollapsed, setObsCollapsed] = useState(false); + + // Center tabs (Timeline | Actions) + const [centerTab, setCenterTab] = useState<"timeline" | "actions">("timeline"); + // Reset completed actions when step changes useEffect(() => { setCompletedActionsCount(0); }, [currentStepIndex]); + // Track the last response value from wizard_wait_for_response for branching + const [lastResponse, setLastResponse] = useState(null); + // Get experiment steps from API const { data: experimentSteps } = api.experiments.getSteps.useQuery( { experimentId: trial.experimentId }, @@ -145,7 +176,7 @@ export const WizardInterface = React.memo(function WizardInterface({ case "parallel": return "parallel_steps" as const; case "conditional": - return "conditional_branch" as const; + return "conditional" as const; default: return "wizard_action" as const; } @@ -276,9 +307,11 @@ export const WizardInterface = React.memo(function WizardInterface({ name: step.name ?? `Step ${index + 1}`, description: step.description, type: mapStepType(step.type), + // Fix: Conditions are at root level from API + conditions: (step as any).conditions ?? (step as any).trigger?.conditions ?? undefined, parameters: step.parameters ?? {}, order: step.order ?? index, - actions: step.actions?.map((action) => ({ + actions: step.actions?.filter(a => a.type !== 'branch').map((action) => ({ id: action.id, name: action.name, description: action.description, @@ -414,11 +447,60 @@ export const WizardInterface = React.memo(function WizardInterface({ console.log("Pause trial"); }; - const handleNextStep = () => { - if (currentStepIndex < steps.length - 1) { - setCompletedActionsCount(0); // Reset immediately to prevent flickering/double-click issues - setCurrentStepIndex(currentStepIndex + 1); - // Note: Step transitions can be enhanced later with database logging + const handleNextStep = (targetIndex?: number) => { + // If explicit target provided (from branching choice), use it + if (typeof targetIndex === 'number') { + // Find step by index to ensure safety + if (targetIndex >= 0 && targetIndex < steps.length) { + console.log(`[WizardInterface] Manual jump to step ${targetIndex}`); + setCompletedActionsCount(0); + setCurrentStepIndex(targetIndex); + setLastResponse(null); + return; + } + } + + // Dynamic Branching Logic + const currentStep = steps[currentStepIndex]; + + // Check if we have a stored response that dictates the next step + if (currentStep?.type === 'conditional' && currentStep.conditions?.options && lastResponse) { + const matchedOption = currentStep.conditions.options.find(opt => opt.value === lastResponse); + if (matchedOption && matchedOption.nextStepId) { + // Find index of the target step + const targetIndex = steps.findIndex(s => s.id === matchedOption.nextStepId); + if (targetIndex !== -1) { + console.log(`[WizardInterface] Branching to step ${targetIndex} (${matchedOption.label})`); + setCurrentStepIndex(targetIndex); + setLastResponse(null); // Reset after consuming + return; + } + } + } + + // Check for explicit nextStepId in conditions (e.g. for end of branch) + console.log("[WizardInterface] Checking for nextStepId condition:", currentStep?.conditions); + if (currentStep?.conditions?.nextStepId) { + const nextId = String(currentStep.conditions.nextStepId); + const targetIndex = steps.findIndex(s => s.id === nextId); + if (targetIndex !== -1) { + console.log(`[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`); + setCurrentStepIndex(targetIndex); + setCompletedActionsCount(0); + return; + } else { + console.warn(`[WizardInterface] Targeted nextStepId ${nextId} not found in steps list.`); + } + } else { + console.log("[WizardInterface] No nextStepId found in conditions, proceeding linearly."); + } + + // Default: Linear progression + const nextIndex = currentStepIndex + 1; + if (nextIndex < steps.length) { + setCurrentStepIndex(nextIndex); + } else { + handleCompleteTrial(); } }; @@ -476,8 +558,25 @@ export const WizardInterface = React.memo(function WizardInterface({ parameters?: Record, ) => { try { + // Log action execution console.log("Executing action:", actionId, parameters); + // Handle branching logic (wizard_wait_for_response) + if (parameters?.value && parameters?.label) { + setLastResponse(String(parameters.value)); + + // If nextStepId is provided, jump immediately + if (parameters.nextStepId) { + const nextId = String(parameters.nextStepId); + const targetIndex = steps.findIndex(s => s.id === nextId); + if (targetIndex !== -1) { + console.log(`[WizardInterface] Choice-based jump to step ${targetIndex} (${nextId})`); + handleNextStep(targetIndex); + return; // Exit after jump + } + } + } + if (actionId === "acknowledge") { await logEventMutation.mutateAsync({ trialId: trial.id, @@ -614,65 +713,102 @@ export const WizardInterface = React.memo(function WizardInterface({ [logRobotActionMutation, trial.id], ); + + return ( -
- {/* Compact Status Bar */} -
-
-
- - - {trial.status.replace("_", " ")} - +
+ + {trial.status === "scheduled" && ( + + )} {trial.status === "in_progress" && ( -
- - {formatElapsedTime(elapsedTime)} -
+ <> + + + + + + + + )} - {steps.length > 0 && ( -
- - Step {currentStepIndex + 1} of {totalSteps} - -
- -
-
+ {_userRole !== "participant" && ( + )}
+ } + className="flex-none px-2 pb-2" + /> -
-
{trial.experiment.name}
-
{trial.participant.participantCode}
- - {rosConnected ? "ROS Connected" : "ROS Offline"} - - -
-
-
- - {/* Main Content with Vertical Resizable Split */} -
- - - + {/* Top Row - 3 Column Layout */} +
+ {/* Left Sidebar - Control Panel (Collapsible) */} + {!leftCollapsed && ( +
+
+ Control + +
+
- } - center={ -
- setCurrentStepIndex(index)} - onExecuteAction={handleExecuteAction} - onExecuteRobotAction={handleExecuteRobotAction} - activeTab={executionPanelTab} - onTabChange={setExecutionPanelTab} - onSkipAction={handleSkipAction} - isExecuting={isExecutingAction} - onNextStep={handleNextStep} - completedActionsCount={completedActionsCount} - onActionCompleted={() => setCompletedActionsCount(c => c + 1)} - onCompleteTrial={handleCompleteTrial} - readOnly={trial.status === 'completed' || _userRole === 'observer'} - /> -
- } - right={ +
+
+ )} + + {/* Center - Tabbed Workspace */} + {/* Center - Execution Workspace */} +
+
+ {leftCollapsed && ( + + )} + +
+ Trial Execution + {currentStep && ( + + {currentStep.name} + + )} +
+ +
+ +
+ Step {currentStepIndex + 1} / {steps.length} +
+ + {rightCollapsed && ( + + )} +
+
+
+ setCurrentStepIndex(index)} + onExecuteAction={handleExecuteAction} + onExecuteRobotAction={handleExecuteRobotAction} + activeTab={executionPanelTab} + onTabChange={setExecutionPanelTab} + onSkipAction={handleSkipAction} + isExecuting={isExecutingAction} + onNextStep={handleNextStep} + completedActionsCount={completedActionsCount} + onActionCompleted={() => setCompletedActionsCount(c => c + 1)} + onCompleteTrial={handleCompleteTrial} + readOnly={trial.status === 'completed' || _userRole === 'observer'} + /> +
+
+
+ + {/* Right Sidebar - Robot Status (Collapsible) */} + {!rightCollapsed && ( +
+
+ Robot Status + +
+
- } - showDividers={true} - className="h-full" - /> - +
+
+ )} +
- - - - - - -
-
+ {/* Bottom Row - Observations (Full Width, Collapsible) */} + {!obsCollapsed && ( + setObsTab(v as "notes" | "timeline")} className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm h-48 flex-none"> +
+ Observations + + Notes + Timeline + +
+ +
+
+ +
+ + )} + { + obsCollapsed && ( + + ) + } +
+
); }); diff --git a/src/components/trials/wizard/panels/TrialStatusBar.tsx b/src/components/trials/wizard/panels/TrialStatusBar.tsx new file mode 100644 index 0000000..5dd970d --- /dev/null +++ b/src/components/trials/wizard/panels/TrialStatusBar.tsx @@ -0,0 +1,133 @@ +"use client"; + +import React, { useMemo } from "react"; +import { + GitBranch, + Sparkles, + CheckCircle2, + Clock, + Play, + StickyNote, +} from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { Badge } from "~/components/ui/badge"; +import { Separator } from "~/components/ui/separator"; +import { cn } from "~/lib/utils"; +import { Progress } from "~/components/ui/progress"; + +export interface TrialStatusBarProps { + currentStepIndex: number; + totalSteps: number; + trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed"; + rosConnected: boolean; + eventsCount: number; + completedActionsCount: number; + totalActionsCount: number; + onAddNote?: () => void; + className?: string; +} + +export function TrialStatusBar({ + currentStepIndex, + totalSteps, + trialStatus, + rosConnected, + eventsCount, + completedActionsCount, + totalActionsCount, + onAddNote, + className, +}: TrialStatusBarProps) { + const progressPercentage = useMemo( + () => (totalSteps > 0 ? ((currentStepIndex + 1) / totalSteps) * 100 : 0), + [currentStepIndex, totalSteps], + ); + + const actionProgress = useMemo( + () => + totalActionsCount > 0 + ? (completedActionsCount / totalActionsCount) * 100 + : 0, + [completedActionsCount, totalActionsCount], + ); + + return ( +
+ {/* Step Progress */} +
+ + + Step {currentStepIndex + 1}/{totalSteps} + +
+ +
+ {Math.round(progressPercentage)}% +
+ + + + {/* Action Progress */} + {totalActionsCount > 0 && ( + <> +
+ + + {completedActionsCount}/{totalActionsCount} actions + +
+ +
+
+ + + )} + + {/* Trial Stats */} +
+ + + {eventsCount} events + + {trialStatus === "in_progress" && ( + + + Live + + )} + {trialStatus === "completed" && ( + + + Completed + + )} +
+ +
+ + {/* Quick Actions */} +
+ {onAddNote && ( + + )} +
+
+ ); +} + +export default TrialStatusBar; diff --git a/src/components/trials/wizard/panels/WebcamPanel.tsx b/src/components/trials/wizard/panels/WebcamPanel.tsx index 8f6a50e..26f95cd 100644 --- a/src/components/trials/wizard/panels/WebcamPanel.tsx +++ b/src/components/trials/wizard/panels/WebcamPanel.tsx @@ -207,9 +207,9 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) { )}
-
+
{isCameraEnabled ? ( -
+
) : ( -
- -

Camera is disabled

+
+
+ +
+

Camera is disabled

- )} - {trial.status === "in_progress" && ( -
-
- - -
+ - - - - - -
- )} - - {(trial.status === "completed" || - trial.status === "aborted") && ( - - - - Trial has ended. All controls are disabled. - - - )} - - -
-
Robot Status
- -
- - Connection - - {_isConnected ? ( - - Connected - - ) : ( - - Polling... - - )} -
- -
-
- -
- -
-
-
- - - - {/* Current Step Tab */} - - -
- {currentStep && trial.status === "in_progress" ? ( -
-
-
- {currentStep.name} -
- - {currentStep.type.replace("_", " ")} - -
- - {currentStep.description && ( -
- {currentStep.description} -
- )} - - - -
-
Step Progress
-
- Current - Step {currentStepIndex + 1} -
-
- Remaining - {steps.length - currentStepIndex - 1} steps -
-
- - {currentStep.type === "robot_action" && ( - - - - Robot is executing this step. Monitor progress in the - monitoring panel. - - - )} -
- ) : ( -
- {trial.status === "scheduled" - ? "Start trial to see current step" - : trial.status === "in_progress" - ? "No current step" - : "Trial has ended"} -
- )} -
-
-
- - {/* Quick Actions Tab */} - - -
- {trial.status === "in_progress" ? ( - <> -
- Quick Actions -
+ + {currentStep?.type === "wizard_action" && ( + )} +
+ ) : ( +
+ Controls available during trial +
+ )} +
- + - - - - - {currentStep?.type === "wizard_action" && ( -
-
Step Actions
- -
- )} - + {/* Robot Controls (Merged from System & Robot Tab) */} +
+
+ Connection + {_isConnected ? ( + Connected ) : ( -
-
- {trial.status === "scheduled" - ? "Start trial to access actions" - : "Actions unavailable - trial not active"} -
-
+ Offline )}
- - - {/* Robot Actions Tab */} - - -
- {studyId && onExecuteRobotAction ? ( -
- -
- ) : ( - - - - Robot actions are not available. Study ID or action - handler is missing. - - - )} +
+ +
- - -
- -
+ + + + {/* Robot Actions Panel Integration */} + {studyId && onExecuteRobotAction ? ( +
+ +
+ ) : ( +
Robot actions unavailable
+ )} +
+
+ +
+
); -} +}); diff --git a/src/components/trials/wizard/panels/WizardExecutionPanel.tsx b/src/components/trials/wizard/panels/WizardExecutionPanel.tsx index 6335778..033990f 100755 --- a/src/components/trials/wizard/panels/WizardExecutionPanel.tsx +++ b/src/components/trials/wizard/panels/WizardExecutionPanel.tsx @@ -3,16 +3,15 @@ import React from "react"; import { Play, - Clock, + SkipForward, CheckCircle, AlertCircle, - Bot, - User, - Activity, - Zap, ArrowRight, - AlertTriangle, + Zap, + Loader2, + Clock, RotateCcw, + AlertTriangle, } from "lucide-react"; import { Button } from "~/components/ui/button"; import { Badge } from "~/components/ui/badge"; @@ -26,8 +25,17 @@ interface StepData { | "wizard_action" | "robot_action" | "parallel_steps" - | "conditional_branch"; + | "conditional"; parameters: Record; + conditions?: { + options?: { + label: string; + value: string; + nextStepId?: string; + nextStepIndex?: number; + variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"; + }[]; + }; order: number; actions?: { id: string; @@ -129,30 +137,31 @@ export function WizardExecutionPanel({ const activeActionIndex = completedActionsCount; + // Auto-scroll to active action + const activeActionRef = React.useRef(null); + + React.useEffect(() => { + if (activeActionRef.current) { + activeActionRef.current.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + }, [activeActionIndex, currentStepIndex]); + // Pre-trial state if (trial.status === "scheduled") { return (
-
-

Trial Ready

-

- {steps.length} steps prepared for execution -

-
- -
-
- +
+
+
-

Ready to Begin

-

- Use the control panel to start this trial +

Ready to Begin

+

+ {steps.length} steps prepared. Use controls to start.

-
-
Experiment: {trial.experiment.name}
-
Participant: {trial.participant.participantCode}
-
@@ -197,160 +206,188 @@ export function WizardExecutionPanel({ // Active trial state return ( -
- {/* Header */} -
-
-

Trial Execution

- - {currentStepIndex + 1} / {steps.length} - -
- {currentStep && ( -

- {currentStep.name} -

- )} -
- - {/* Simplified Content - Sequential Focus */} -
- - {currentStep ? ( -
- {/* Header Info (Simplified) */} -
-
-
-

{currentStep.name}

- {currentStep.description && ( -
{currentStep.description}
- )} -
+
+
+ +
+ {currentStep ? ( +
+ {/* Header Info */} +
+

{currentStep.name}

+ {currentStep.description && ( +
{currentStep.description}
+ )}
-
- {/* Action Sequence */} - {currentStep.actions && currentStep.actions.length > 0 && ( -
-
-

- Execution Sequence -

-
- -
+ {/* Action Sequence */} + {currentStep.actions && currentStep.actions.length > 0 && ( +
{currentStep.actions.map((action, idx) => { const isCompleted = idx < activeActionIndex; const isActive = idx === activeActionIndex; + const isLast = idx === currentStep.actions!.length - 1; return (
-
- {isCompleted ? : idx + 1} -
+ {/* Connecting Line */} + {!isLast && ( +
+ )} -
-
{action.name}
- {action.description && ( -
- {action.description} -
+ {/* Marker */} +
+ {isCompleted ? ( + + ) : ( + {idx + 1} )}
- {action.pluginId && isActive && ( -
- - -
- )} - - {/* Fallback for actions with no plugin ID (e.g. manual steps) */} - {!action.pluginId && isActive && ( -
- -
- )} - - {/* Completed State Indicator */} - {isCompleted && ( -
-
- Done + {/* Content Card */} +
+
+
+
+ {action.name} +
- {action.pluginId && ( - <> + + {action.description && ( +
+ {action.description} +
+ )} + + {/* Active Action Controls */} + {isActive && ( +
+ {action.pluginId && !["hristudio-core", "hristudio-woz"].includes(action.pluginId) ? ( + <> + + + + ) : ( + + )} +
+ )} + + {/* Wizard Wait For Response / Branching UI */} + {isActive && action.type === 'wizard_wait_for_response' && action.parameters?.options && Array.isArray(action.parameters.options) && ( +
+ {(action.parameters.options as any[]).map((opt, optIdx) => { + // Handle both string options and object options + const label = typeof opt === 'string' ? opt : opt.label; + const value = typeof opt === 'string' ? opt : opt.value; + const nextStepId = typeof opt === 'object' ? opt.nextStepId : undefined; + + return ( + + ); + })} +
+ )} + + {/* Completed State Actions */} + {isCompleted && action.pluginId && ( +
- - +
)}
- )} +
- ) + ); })}
+ ) + } - {/* Manual Advance Button */} - {activeActionIndex >= (currentStep.actions?.length || 0) && ( -
- -
- )} -
- )} - - {/* Manual Wizard Controls (If applicable) */} - {currentStep.type === "wizard_action" && ( -
-

Manual Controls

-
+ {/* Manual Advance Button */} + {activeActionIndex >= (currentStep.actions?.length || 0) && ( +
-
- )} -
- ) : ( -
- No active step -
- )} + )} +
+ ) : ( +
+ +
Waiting for trial to start...
+
+ )} +
- {/* Scroll Hint Fade */} -
-
+
); } diff --git a/src/components/trials/wizard/panels/WizardMonitoringPanel.tsx b/src/components/trials/wizard/panels/WizardMonitoringPanel.tsx index 20e3ff6..2ee1cbe 100755 --- a/src/components/trials/wizard/panels/WizardMonitoringPanel.tsx +++ b/src/components/trials/wizard/panels/WizardMonitoringPanel.tsx @@ -49,7 +49,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({ return (
{/* Camera View - Always Visible */} -
+
@@ -69,7 +69,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({ {rosConnected ? ( ) : ( - + Offline )}
diff --git a/src/components/trials/wizard/panels/WizardObservationPane.tsx b/src/components/trials/wizard/panels/WizardObservationPane.tsx index 5904dbb..0dd6785 100644 --- a/src/components/trials/wizard/panels/WizardObservationPane.tsx +++ b/src/components/trials/wizard/panels/WizardObservationPane.tsx @@ -31,6 +31,7 @@ interface WizardObservationPaneProps { ) => Promise; isSubmitting?: boolean; readOnly?: boolean; + activeTab?: "notes" | "timeline"; } export function WizardObservationPane({ @@ -38,6 +39,7 @@ export function WizardObservationPane({ isSubmitting = false, trialEvents = [], readOnly = false, + activeTab = "notes", }: WizardObservationPaneProps & { trialEvents?: TrialEvent[] }) { const [note, setNote] = useState(""); const [category, setCategory] = useState("observation"); @@ -68,95 +70,82 @@ export function WizardObservationPane({ }; return ( -
- -
- - - Notes & Observations - - - Timeline - - -
+
+
+
+