feat: introduce conditional steps and branching logic to the experiment wizard and designer, along with new core and WoZ plugins.

This commit is contained in:
2026-02-10 10:24:09 -05:00
parent 388897c70e
commit 0f535f6887
38 changed files with 2410 additions and 1190 deletions

View File

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

View File

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

View File

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

View File

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

24
scripts/inspect-db.ts Normal file
View File

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

61
scripts/inspect-step.ts Normal file
View File

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

View File

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

View File

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

View File

@@ -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<string, unknown>) || {};
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<string, unknown>) || {};
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);
});

View File

@@ -35,6 +35,19 @@ async function loadNaoPluginDef() {
// Global variable to hold the loaded definition // Global variable to hold the loaded definition
let NAO_PLUGIN_DEF: any; 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() { async function main() {
console.log("🌱 Starting realistic seed script..."); console.log("🌱 Starting realistic seed script...");
@@ -43,6 +56,8 @@ async function main() {
try { try {
NAO_PLUGIN_DEF = await loadNaoPluginDef(); 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 // 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) { 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.studyPlugins).where(sql`1=1`);
await db.delete(schema.studyMembers).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.studies).where(sql`1=1`);
await db.delete(schema.plugins).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.pluginRepositories).where(sql`1=1`);
await db.delete(schema.userSystemRoles).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" } { studyId: study!.id, userId: researcherUser!.id, role: "researcher" }
]); ]);
await db.insert(schema.studyPlugins).values({ // 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, studyId: study!.id,
pluginId: naoPlugin!.id, pluginId: naoPlugin!.id,
configuration: { robotIp: "10.0.0.42" }, configuration: { robotIp: "10.0.0.42" },
installedBy: adminUser.id 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({ const [experiment] = await db.insert(schema.experiments).values({
studyId: study!.id, 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) --- // --- Step 3: Comprehension Check (Wizard Decision Point) ---
// Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect) // Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect)
const [step3] = await db.insert(schema.steps).values({ const [step3] = await db.insert(schema.steps).values({
experimentId: experiment!.id, experimentId: experiment!.id,
name: "Comprehension Check", name: "Comprehension Check",
description: "Ask participant about rock color and wait for wizard input", description: "Ask participant about rock color and wait for wizard input",
type: "wizard", type: "conditional",
orderIndex: 2, orderIndex: 2,
required: true, 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(); }).returning();
await db.insert(schema.actions).values([ await db.insert(schema.actions).values([
@@ -282,30 +370,30 @@ async function main() {
}, },
{ {
stepId: step3!.id, stepId: step3!.id,
name: "Wait for Wizard Input", name: "Wait for Choice",
type: "wizard_wait_for_response", type: "wizard_wait_for_response",
orderIndex: 1, orderIndex: 1,
// Define the options that will be presented to the Wizard
parameters: { parameters: {
prompt_text: "Did participant answer 'Red' correctly?", prompt_text: "Did participant answer 'Red' correctly?",
response_type: "verbal", options: ["Correct", "Incorrect"]
timeout: 60
}, },
sourceKind: "core", sourceKind: "core",
pluginId: "hristudio-woz", // Explicit link
category: "wizard" 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([ await db.insert(schema.actions).values([
{ {
stepId: step4a!.id, 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([ await db.insert(schema.actions).values([
{ {

View File

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

View File

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

View File

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

View File

@@ -222,7 +222,10 @@ export default async function ExperimentDesignerPage({
: "sequential"; : "sequential";
})(), })(),
order: s.orderIndex ?? idx, order: s.orderIndex ?? idx,
trigger: { type: "trial_start", conditions: {} }, trigger: {
type: idx === 0 ? "trial_start" : "previous_step",
conditions: (s.conditions as Record<string, unknown>) || {},
},
actions, actions,
expanded: true, expanded: true,
}; };

View File

@@ -171,10 +171,27 @@ function WizardPageContent() {
const renderView = () => { const renderView = () => {
const trialData = { 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<string, unknown> | null, metadata: trial.metadata as Record<string, unknown> | 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: { participant: {
...trial.participant, id: trial.participant.id,
participantCode: trial.participant.participantCode,
demographics: trial.participant.demographics as Record< demographics: trial.participant.demographics as Record<
string, string,
unknown unknown
@@ -184,7 +201,7 @@ function WizardPageContent() {
switch (currentRole) { switch (currentRole) {
case "wizard": case "wizard":
return <WizardView trial={trialData} />; return <WizardView trial={trialData} userRole={currentRole} />;
case "observer": case "observer":
return <ObserverView trial={trialData} />; return <ObserverView trial={trialData} />;
case "participant": case "participant":
@@ -195,24 +212,8 @@ function WizardPageContent() {
}; };
return ( return (
<div className="flex h-full flex-col"> <div>
<PageHeader {renderView()}
title={getViewTitle(currentRole)}
description={`Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
icon={getViewIcon(currentRole)}
actions={
currentRole !== "participant" ? (
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/trials/${trialId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trial
</Link>
</Button>
) : null
}
/>
<div className="min-h-0 flex-1">{renderView()}</div>
</div> </div>
); );
} }

View File

@@ -1,13 +1,15 @@
"use client"; "use client";
import { useState, useEffect } from "react"; 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 * ActionRegistry
* *
* Central singleton for loading and serving action definitions from: * 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) * - Study-installed plugin action definitions (ROS2 / REST / internal transports)
* *
* Responsibilities: * Responsibilities:
@@ -15,12 +17,6 @@ import type { ActionDefinition } from "~/lib/experiment-designer/types";
* - Provenance retention (core vs plugin, plugin id/version, robot id) * - Provenance retention (core vs plugin, plugin id/version, robot id)
* - Parameter schema → UI parameter mapping (primitive only for now) * - Parameter schema → UI parameter mapping (primitive only for now)
* - Fallback action population if core load fails (ensures minimal functionality) * - 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 { export class ActionRegistry {
private static instance: ActionRegistry; private static instance: ActionRegistry;
@@ -31,6 +27,8 @@ export class ActionRegistry {
private loadedStudyId: string | null = null; private loadedStudyId: string | null = null;
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
private readonly SYSTEM_PLUGIN_IDS = ["hristudio-core", "hristudio-woz"];
static getInstance(): ActionRegistry { static getInstance(): ActionRegistry {
if (!ActionRegistry.instance) { if (!ActionRegistry.instance) {
ActionRegistry.instance = new ActionRegistry(); ActionRegistry.instance = new ActionRegistry();
@@ -49,235 +47,19 @@ export class ActionRegistry {
this.listeners.forEach((listener) => listener()); this.listeners.forEach((listener) => listener());
} }
/* ---------------- Core Actions ---------------- */ /* ---------------- Core / System Actions ---------------- */
async loadCoreActions(): Promise<void> { async loadCoreActions(): Promise<void> {
if (this.coreActionsLoaded) return; if (this.coreActionsLoaded) return;
interface CoreBlockParam { // Load System Plugins (Core & WoZ)
id: string; this.registerPluginDefinition(corePluginDef);
name: string; this.registerPluginDefinition(wozPluginDef);
type: string;
placeholder?: string;
options?: string[];
min?: number;
max?: number;
value?: string | number | boolean;
required?: boolean;
description?: string;
step?: number;
}
interface CoreBlock { console.log(`[ActionRegistry] Loaded system plugins: ${this.SYSTEM_PLUGIN_IDS.join(", ")}`);
id: string;
name: string;
description?: string;
category: string;
icon?: string;
color?: string;
parameters?: CoreBlockParam[];
timeoutMs?: number;
retryable?: boolean;
nestable?: boolean;
}
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.coreActionsLoaded = true;
this.notifyListeners(); 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.notifyListeners();
} }
/* ---------------- Plugin Actions ---------------- */ /* ---------------- Plugin Actions ---------------- */
@@ -295,6 +77,22 @@ export class ActionRegistry {
let totalActionsLoaded = 0; let totalActionsLoaded = 0;
(studyPlugins ?? []).forEach((plugin) => { (studyPlugins ?? []).forEach((plugin) => {
this.registerPluginDefinition(plugin);
totalActionsLoaded += (plugin.actionDefinitions?.length || 0);
});
console.log(
`ActionRegistry: Loaded ${totalActionsLoaded} plugin actions for study ${studyId}`,
);
this.pluginActionsLoaded = true;
this.loadedStudyId = studyId;
this.notifyListeners();
}
/* ---------------- Shared Registration Logic ---------------- */
private registerPluginDefinition(plugin: any) {
const actionDefs = Array.isArray(plugin.actionDefinitions) const actionDefs = Array.isArray(plugin.actionDefinitions)
? plugin.actionDefinitions ? plugin.actionDefinitions
: undefined; : undefined;
@@ -312,7 +110,14 @@ export class ActionRegistry {
control: "control", control: "control",
observation: "observation", observation: "observation",
}; };
const category = categoryMap[rawCategory] ?? "robot";
// 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 const execution = action.ros2
? { ? {
@@ -345,36 +150,50 @@ export class ActionRegistry {
retryable: action.retryable, retryable: action.retryable,
}; };
// Extract semantic ID from metadata if available, otherwise fall back to database IDs (which typically causes mismatch if seed uses semantic) // Extract semantic ID from metadata if available, otherwise fall back to database IDs
// Ideally, plugin.metadata.robotId should populate this. // Priority: metadata.robotId > metadata.id (for system plugins) > robotId > id
const semanticRobotId = plugin.metadata?.robotId || plugin.robotId || plugin.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 = { const actionDef: ActionDefinition = {
id: `${semanticRobotId}.${action.id}`, id: actionId,
type: `${semanticRobotId}.${action.id}`, type: actionType,
name: action.name, name: action.name,
description: action.description ?? "", description: action.description ?? "",
category, category,
icon: action.icon ?? "Bot", icon: action.icon ?? "Bot",
color: "#10b981", color: action.color || "#10b981",
parameters: this.convertParameterSchemaToParameters( parameters: this.convertParameterSchemaToParameters(
action.parameterSchema, action.parameterSchema,
), ),
source: { source: {
kind: "plugin", kind: isSystem ? "core" : "plugin", // Maintain 'core' distinction for UI grouping if needed
pluginId: semanticRobotId, // Use semantic ID here too pluginId: semanticRobotId,
robotId: plugin.robotId, robotId: plugin.robotId,
pluginVersion: plugin.version ?? undefined, pluginVersion: plugin.version ?? undefined,
baseActionId: action.id, baseActionId: action.id,
}, },
execution, execution,
parameterSchemaRaw: action.parameterSchema ?? undefined, parameterSchemaRaw: action.parameterSchema ?? undefined,
nestable: action.nestable
}; };
this.actions.set(actionDef.id, actionDef);
// Register aliases if provided by plugin metadata // Prevent overwriting if it already exists (first-come-first-served, usually core first)
const aliases = Array.isArray(action.aliases) if (!this.actions.has(actionId)) {
? action.aliases this.actions.set(actionId, actionDef);
: undefined; }
// Register aliases
const aliases = Array.isArray(action.aliases) ? action.aliases : undefined;
if (aliases) { if (aliases) {
for (const alias of aliases) { for (const alias of aliases) {
if (typeof alias === "string" && alias.trim()) { if (typeof alias === "string" && alias.trim()) {
@@ -382,19 +201,7 @@ export class ActionRegistry {
} }
} }
} }
totalActionsLoaded++;
}); });
});
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();
} }
private convertParameterSchemaToParameters( private convertParameterSchemaToParameters(
@@ -417,7 +224,7 @@ export class ActionRegistry {
if (!schema?.properties) return []; if (!schema?.properties) return [];
return Object.entries(schema.properties).map(([key, paramDef]) => { 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") { if (paramDef.type === "number") {
type = "number"; type = "number";
@@ -425,6 +232,10 @@ export class ActionRegistry {
type = "boolean"; type = "boolean";
} else if (paramDef.enum && Array.isArray(paramDef.enum)) { } else if (paramDef.enum && Array.isArray(paramDef.enum)) {
type = "select"; type = "select";
} else if (paramDef.type === "array") {
type = "array";
} else if (paramDef.type === "object") {
type = "json";
} }
return { return {
@@ -444,29 +255,17 @@ export class ActionRegistry {
private resetPluginActions(): void { private resetPluginActions(): void {
this.pluginActionsLoaded = false; this.pluginActionsLoaded = false;
this.loadedStudyId = null; this.loadedStudyId = null;
// Remove existing plugin actions (retain known core ids + fallback ids)
const pluginActionIds = Array.from(this.actions.keys()).filter( // Robust Reset: Remove valid plugin actions, BUT protect system plugins.
(id) => const idsToDelete: string[] = [];
!id.startsWith("wizard_") && this.actions.forEach((action, id) => {
!id.startsWith("when_") && if (action.source.kind === "plugin" && !this.SYSTEM_PLUGIN_IDS.includes(action.source.pluginId || "")) {
!id.startsWith("wait") && idsToDelete.push(id);
!id.startsWith("observe") && }
!id.startsWith("repeat") && });
!id.startsWith("if_") &&
!id.startsWith("parallel") && idsToDelete.forEach((id) => this.actions.delete(id));
!id.startsWith("sequence") && this.notifyListeners();
!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));
} }
/* ---------------- Query Helpers ---------------- */ /* ---------------- Query Helpers ---------------- */

View File

@@ -116,10 +116,21 @@ interface RawExperiment {
function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined { function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
// 1. Prefer database steps (Source of Truth) if valid, to ensure we have the latest // 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). // 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) { if (Array.isArray(exp.steps) && exp.steps.length > 0) {
try { try {
// console.log('[DesignerRoot] Hydrating design from Database Steps (Source of Truth)'); // Check if steps are already converted (have trigger property) to avoid double-conversion data loss
const dbSteps = convertDatabaseToSteps(exp.steps); 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 { return {
id: exp.id, id: exp.id,
name: exp.name, name: exp.name,
@@ -129,7 +140,7 @@ function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
lastSaved: new Date(), lastSaved: new Date(),
}; };
} catch (err) { } 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()); setLastSavedAt(new Date());
toast.success("Experiment saved"); toast.success("Experiment saved");
// Auto-validate after save to clear "Modified" (drift) status
void validateDesign();
console.log('[DesignerRoot] 💾 SAVE complete'); console.log('[DesignerRoot] 💾 SAVE complete');
onPersist?.({ onPersist?.({

View File

@@ -23,6 +23,7 @@ import {
type ExperimentDesign, type ExperimentDesign,
} from "~/lib/experiment-designer/types"; } from "~/lib/experiment-designer/types";
import { actionRegistry } from "./ActionRegistry"; import { actionRegistry } from "./ActionRegistry";
import { Button } from "~/components/ui/button";
import { import {
Settings, Settings,
Zap, Zap,
@@ -39,6 +40,9 @@ import {
Mic, Mic,
Activity, Activity,
Play, Play,
Plus,
GitBranch,
Trash2,
} from "lucide-react"; } from "lucide-react";
/** /**
@@ -275,8 +279,138 @@ export function PropertiesPanelBase({
</div> </div>
</div> </div>
{/* Parameters */} {/* Branching Configuration (Special Case) */}
{def?.parameters.length ? ( {selectedAction.type === "branch" ? (
<div className="space-y-3">
<div className="text-muted-foreground text-[10px] tracking-wide uppercase flex justify-between items-center">
<span>Branch Options</span>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => {
const currentOptions = (containingStep.trigger.conditions as any)?.options || [];
onStepUpdate(containingStep.id, {
trigger: {
...containingStep.trigger,
conditions: {
...containingStep.trigger.conditions,
options: [
...currentOptions,
{ label: "New Option", nextStepIndex: containingStep.order + 1, variant: "default" }
]
}
}
});
// Auto-upgrade step type if needed
if (containingStep.type !== "conditional") {
onStepUpdate(containingStep.id, { type: "conditional" });
}
}}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-3">
{((containingStep.trigger.conditions as any)?.options || []).map((opt: any, idx: number) => (
<div key={idx} className="space-y-2 p-2 rounded border bg-muted/50">
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-[10px]">Label</Label>
<Input
value={opt.label}
onChange={(e) => {
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"
/>
</div>
<div className="w-[80px]">
<Label className="text-[10px]">Target Step</Label>
<Select
value={opt.nextStepId ?? design.steps[opt.nextStepIndex]?.id ?? ""}
onValueChange={(val) => {
const newOpts = [...((containingStep.trigger.conditions as any)?.options || [])];
// Find index for legacy support / display logic if needed
const stepIdx = design.steps.findIndex(s => s.id === val);
newOpts[idx] = {
...newOpts[idx],
nextStepId: val,
nextStepIndex: stepIdx !== -1 ? stepIdx : undefined
};
onStepUpdate(containingStep.id, {
trigger: { ...containingStep.trigger, conditions: { ...containingStep.trigger.conditions, options: newOpts } }
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="Select step..." />
</SelectTrigger>
<SelectContent>
{design.steps.map((s) => (
<SelectItem key={s.id} value={s.id} disabled={s.id === containingStep.id}>
{s.order + 1}. {s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-between">
<Select
value={opt.variant || "default"}
onValueChange={(val) => {
const newOpts = [...((containingStep.trigger.conditions as any)?.options || [])];
newOpts[idx] = { ...newOpts[idx], variant: val };
onStepUpdate(containingStep.id, {
trigger: { ...containingStep.trigger, conditions: { ...containingStep.trigger.conditions, options: newOpts } }
});
}}
>
<SelectTrigger className="h-6 w-[120px] text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default (Next)</SelectItem>
<SelectItem value="destructive">Destructive (Red)</SelectItem>
<SelectItem value="outline">Outline</SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-red-500"
onClick={() => {
const newOpts = [...((containingStep.trigger.conditions as any)?.options || [])];
newOpts.splice(idx, 1);
onStepUpdate(containingStep.id, {
trigger: { ...containingStep.trigger, conditions: { ...containingStep.trigger.conditions, options: newOpts } }
});
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
{(!((containingStep.trigger.conditions as any)?.options?.length)) && (
<div className="text-center py-4 border border-dashed rounded text-xs text-muted-foreground">
No options defined.<br />Click + to add a branch.
</div>
)}
</div>
</div>
) : (
/* Standard Parameters */
def?.parameters.length ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="text-muted-foreground text-[10px] tracking-wide uppercase"> <div className="text-muted-foreground text-[10px] tracking-wide uppercase">
Parameters Parameters
@@ -304,6 +438,7 @@ export function PropertiesPanelBase({
<div className="text-muted-foreground text-xs"> <div className="text-muted-foreground text-xs">
No parameters for this action. No parameters for this action.
</div> </div>
)
)} )}
</div> </div>
); );

View File

@@ -29,6 +29,7 @@ import {
Trash2, Trash2,
GitBranch, GitBranch,
Edit3, Edit3,
CornerDownRight,
} from "lucide-react"; } from "lucide-react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { import {
@@ -96,6 +97,7 @@ interface StepRowProps {
registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void; registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void;
onReorderStep: (stepId: string, direction: 'up' | 'down') => void; onReorderStep: (stepId: string, direction: 'up' | 'down') => void;
onReorderAction?: (stepId: string, actionId: string, direction: 'up' | 'down') => void; onReorderAction?: (stepId: string, actionId: string, direction: 'up' | 'down') => void;
isChild?: boolean;
} }
function StepRow({ function StepRow({
@@ -115,8 +117,10 @@ function StepRow({
registerMeasureRef, registerMeasureRef,
onReorderStep, onReorderStep,
onReorderAction, onReorderAction,
isChild,
}: StepRowProps) { }: StepRowProps) {
// const step = item.step; // Removed local derivation // const step = item.step; // Removed local derivation
const allSteps = useDesignerStore((s) => s.steps);
const insertionProjection = useDesignerStore((s) => s.insertionProjection); const insertionProjection = useDesignerStore((s) => s.insertionProjection);
const displayActions = useMemo(() => { const displayActions = useMemo(() => {
@@ -149,9 +153,17 @@ function StepRow({
<div style={style} data-step-id={step.id}> <div style={style} data-step-id={step.id}>
<div <div
ref={(el) => registerMeasureRef(step.id, el)} ref={(el) => 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} data-step-id={step.id}
> >
{isChild && (
<div className="absolute left-[-24px] top-8 text-muted-foreground/40">
<CornerDownRight className="h-5 w-5" />
</div>
)}
<StepDroppableArea stepId={step.id} /> <StepDroppableArea stepId={step.id} />
<div <div
className={cn( className={cn(
@@ -281,6 +293,78 @@ function StepRow({
</div> </div>
</div> </div>
{/* Conditional Branching Visualization */}
{/* Conditional Branching Visualization */}
{step.type === "conditional" && (
<div className="mx-3 my-3 rounded-md border text-xs" style={{
backgroundColor: 'var(--validation-warning-bg)', // Semantic background
borderColor: 'var(--validation-warning-border)', // Semantic border
}}>
<div className="flex items-center gap-2 border-b px-3 py-2 font-medium" style={{
borderColor: 'var(--validation-warning-border)',
color: 'var(--validation-warning-text)'
}}>
<GitBranch className="h-3.5 w-3.5" />
<span>Branching Logic</span>
</div>
<div className="p-2 space-y-2">
{!(step.trigger.conditions as any)?.options?.length ? (
<div className="text-muted-foreground/60 italic text-center py-2 text-[11px]">
No branches configured. Add options in properties.
</div>
) : (
(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 (
<div key={idx} className="flex items-center justify-between rounded bg-background/50 shadow-sm border p-2">
<div className="flex items-center gap-2 min-w-0">
<Badge variant="outline" className={cn(
"text-[10px] uppercase font-bold tracking-wider px-1.5 py-0.5 min-w-[70px] justify-center bg-background",
opt.variant === "destructive"
? "border-red-500/30 text-red-600 dark:text-red-400"
: "border-slate-500/30 text-foreground"
)}>
{opt.label}
</Badge>
<span className="text-muted-foreground text-[10px]">then go to</span>
</div>
<div className="flex items-center gap-1.5 text-right min-w-0 max-w-[50%]">
<span className="font-medium truncate text-[11px] block text-foreground" title={targetName}>
{targetName}
</span>
{targetIndex !== -1 && (
<Badge variant="secondary" className="px-1 py-0 h-4 text-[9px] min-w-[20px] justify-center tabular-nums">
#{targetIndex + 1}
</Badge>
)}
<ChevronRight className="h-3 w-3 text-muted-foreground/50 flex-shrink-0" />
</div>
</div>
);
})
)}
</div>
</div>
)}
{/* Action List (Collapsible/Virtual content) */} {/* Action List (Collapsible/Virtual content) */}
{step.expanded && ( {step.expanded && (
<div className="bg-background/40 min-h-[3rem] space-y-2 p-2 pb-8"> <div className="bg-background/40 min-h-[3rem] space-y-2 p-2 pb-8">
@@ -787,6 +871,21 @@ export function FlowWorkspace({
return map; return map;
}, [steps]); }, [steps]);
/* Hierarchy detection for visual indentation */
const childStepIds = useMemo(() => {
const children = new Set<string>();
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 */ /* Resize observer for viewport and width changes */
useLayoutEffect(() => { useLayoutEffect(() => {
const el = containerRef.current; const el = containerRef.current;
@@ -1202,6 +1301,7 @@ export function FlowWorkspace({
registerMeasureRef={registerMeasureRef} registerMeasureRef={registerMeasureRef}
onReorderStep={handleReorderStep} onReorderStep={handleReorderStep}
onReorderAction={handleReorderAction} onReorderAction={handleReorderAction}
isChild={childStepIds.has(vi.step.id)}
/> />
), ),
)} )}

View File

@@ -203,8 +203,7 @@ function projectStepForDesign(
order: step.order, order: step.order,
trigger: { trigger: {
type: step.trigger.type, type: step.trigger.type,
// Only the sorted keys of conditions (structural presence) conditions: canonicalize(step.trigger.conditions),
conditionKeys: Object.keys(step.trigger.conditions).sort(),
}, },
actions: step.actions.map((a) => projectActionForDesign(a, options)), actions: step.actions.map((a) => projectActionForDesign(a, options)),
}; };
@@ -267,11 +266,35 @@ export async function computeDesignHash(
opts: DesignHashOptions = {}, opts: DesignHashOptions = {},
): Promise<string> { ): Promise<string> {
const options = { ...DEFAULT_OPTIONS, ...opts }; const options = { ...DEFAULT_OPTIONS, ...opts };
const projected = steps
.slice() // 1. Sort steps first to ensure order independence of input array
.sort((a, b) => a.order - b.order) const sortedSteps = steps.slice().sort((a, b) => a.order - b.order);
.map((s) => projectStepForDesign(s, options));
return hashObject({ steps: projected }); // 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, order: step.order,
trigger: { trigger: {
type: step.trigger.type, 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) ?? ""), actions: step.actions.map((a) => actionHashes.get(a.id) ?? ""),
...(options.includeStepNames ? { name: step.name } : {}), ...(options.includeStepNames ? { name: step.name } : {}),

View File

@@ -53,6 +53,7 @@ export interface ValidationResult {
// Parallel/conditional/loop execution happens at the ACTION level, not step level // Parallel/conditional/loop execution happens at the ACTION level, not step level
const VALID_STEP_TYPES: StepType[] = [ const VALID_STEP_TYPES: StepType[] = [
"sequential", "sequential",
"conditional",
]; ];
const VALID_TRIGGER_TYPES: TriggerType[] = [ const VALID_TRIGGER_TYPES: TriggerType[] = [
"trial_start", "trial_start",
@@ -391,6 +392,34 @@ export function validateParameters(
} }
break; 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: default:
// Unknown parameter type // Unknown parameter type
issues.push({ issues.push({

View File

@@ -29,12 +29,13 @@ interface WizardViewProps {
demographics: Record<string, unknown> | null; demographics: Record<string, unknown> | null;
}; };
}; };
userRole: string;
} }
export function WizardView({ trial }: WizardViewProps) { export function WizardView({ trial, userRole }: WizardViewProps) {
return ( return (
<div className="h-full"> <div className="h-full max-h-full w-full overflow-hidden">
<WizardInterface trial={trial} userRole="wizard" /> <WizardInterface trial={trial} userRole={userRole} />
</div> </div>
); );
} }

View File

@@ -155,7 +155,7 @@ export function RobotActionsPanel({
disconnect: disconnectRos, disconnect: disconnectRos,
executeRobotAction: executeRosAction, executeRobotAction: executeRosAction,
} = useWizardRos({ } = useWizardRos({
autoConnect: true, autoConnect: false, // Let WizardInterface handle connection
onActionCompleted: (execution) => { onActionCompleted: (execution) => {
toast.success(`Completed: ${execution.actionId}`, { toast.success(`Completed: ${execution.actionId}`, {
description: `Action executed in ${execution.endTime ? execution.endTime.getTime() - execution.startTime.getTime() : 0}ms`, description: `Action executed in ${execution.endTime ? execution.endTime.getTime() - execution.startTime.getTime() : 0}ms`,

View File

@@ -1,21 +1,35 @@
"use client"; "use client";
import React, { useState, useEffect, useCallback, useMemo } from "react"; 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 { useRouter } from "next/navigation";
import { cn } from "~/lib/utils";
import { Badge } from "~/components/ui/badge"; 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 { Progress } from "~/components/ui/progress";
import { Alert, AlertDescription } from "~/components/ui/alert"; 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 { WizardControlPanel } from "./panels/WizardControlPanel";
import { WizardExecutionPanel } from "./panels/WizardExecutionPanel"; import { WizardExecutionPanel } from "./panels/WizardExecutionPanel";
import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel"; import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel";
import { WizardObservationPane } from "./panels/WizardObservationPane"; import { WizardObservationPane } from "./panels/WizardObservationPane";
import { import { TrialStatusBar } from "./panels/TrialStatusBar";
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/ui/resizable";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { useWizardRos } from "~/hooks/useWizardRos"; import { useWizardRos } from "~/hooks/useWizardRos";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -68,8 +82,18 @@ interface StepData {
| "wizard_action" | "wizard_action"
| "robot_action" | "robot_action"
| "parallel_steps" | "parallel_steps"
| "conditional_branch"; | "conditional";
parameters: Record<string, unknown>; parameters: Record<string, unknown>;
conditions?: {
nextStepId?: string;
options?: {
label: string;
value: string;
nextStepId?: string;
nextStepIndex?: number;
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
}[];
};
order: number; order: number;
actions: ActionData[]; actions: ActionData[];
} }
@@ -87,24 +111,31 @@ export const WizardInterface = React.memo(function WizardInterface({
const [elapsedTime, setElapsedTime] = useState(0); const [elapsedTime, setElapsedTime] = useState(0);
const router = useRouter(); const router = useRouter();
// Persistent tab states to prevent resets from parent re-renders // UI State
const [controlPanelTab, setControlPanelTab] = useState< const [executionPanelTab, setExecutionPanelTab] = useState<"current" | "timeline" | "events">("timeline");
"control" | "step" | "actions" | "robot" const [obsTab, setObsTab] = useState<"notes" | "timeline">("notes");
>("control");
const [executionPanelTab, setExecutionPanelTab] = useState<
"current" | "timeline" | "events"
>(trial.status === "in_progress" ? "current" : "timeline");
const [isExecutingAction, setIsExecutingAction] = useState(false); const [isExecutingAction, setIsExecutingAction] = useState(false);
const [monitoringPanelTab, setMonitoringPanelTab] = useState< const [monitoringPanelTab, setMonitoringPanelTab] = useState<
"status" | "robot" | "events" "status" | "robot" | "events"
>("status"); >("status");
const [completedActionsCount, setCompletedActionsCount] = useState(0); 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 // Reset completed actions when step changes
useEffect(() => { useEffect(() => {
setCompletedActionsCount(0); setCompletedActionsCount(0);
}, [currentStepIndex]); }, [currentStepIndex]);
// Track the last response value from wizard_wait_for_response for branching
const [lastResponse, setLastResponse] = useState<string | null>(null);
// Get experiment steps from API // Get experiment steps from API
const { data: experimentSteps } = api.experiments.getSteps.useQuery( const { data: experimentSteps } = api.experiments.getSteps.useQuery(
{ experimentId: trial.experimentId }, { experimentId: trial.experimentId },
@@ -145,7 +176,7 @@ export const WizardInterface = React.memo(function WizardInterface({
case "parallel": case "parallel":
return "parallel_steps" as const; return "parallel_steps" as const;
case "conditional": case "conditional":
return "conditional_branch" as const; return "conditional" as const;
default: default:
return "wizard_action" as const; return "wizard_action" as const;
} }
@@ -276,9 +307,11 @@ export const WizardInterface = React.memo(function WizardInterface({
name: step.name ?? `Step ${index + 1}`, name: step.name ?? `Step ${index + 1}`,
description: step.description, description: step.description,
type: mapStepType(step.type), 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 ?? {}, parameters: step.parameters ?? {},
order: step.order ?? index, order: step.order ?? index,
actions: step.actions?.map((action) => ({ actions: step.actions?.filter(a => a.type !== 'branch').map((action) => ({
id: action.id, id: action.id,
name: action.name, name: action.name,
description: action.description, description: action.description,
@@ -414,11 +447,60 @@ export const WizardInterface = React.memo(function WizardInterface({
console.log("Pause trial"); console.log("Pause trial");
}; };
const handleNextStep = () => { const handleNextStep = (targetIndex?: number) => {
if (currentStepIndex < steps.length - 1) { // If explicit target provided (from branching choice), use it
setCompletedActionsCount(0); // Reset immediately to prevent flickering/double-click issues if (typeof targetIndex === 'number') {
setCurrentStepIndex(currentStepIndex + 1); // Find step by index to ensure safety
// Note: Step transitions can be enhanced later with database logging 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<string, unknown>, parameters?: Record<string, unknown>,
) => { ) => {
try { try {
// Log action execution
console.log("Executing action:", actionId, parameters); 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") { if (actionId === "acknowledge") {
await logEventMutation.mutateAsync({ await logEventMutation.mutateAsync({
trialId: trial.id, trialId: trial.id,
@@ -614,65 +713,102 @@ export const WizardInterface = React.memo(function WizardInterface({
[logRobotActionMutation, trial.id], [logRobotActionMutation, trial.id],
); );
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden bg-background">
{/* Compact Status Bar */} <PageHeader
<div className="bg-background border-b px-4 py-2"> title="Trial Execution"
<div className="flex items-center justify-between"> description={`Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
<div className="flex items-center gap-4"> icon={Play}
<Badge actions={
variant={statusConfig.variant} <div className="flex items-center gap-2">
className="flex items-center gap-1" {trial.status === "scheduled" && (
<Button
onClick={handleStartTrial}
size="sm"
className="gap-2"
> >
<StatusIcon className="h-3 w-3" /> <Play className="h-4 w-4" />
{trial.status.replace("_", " ")} Start Trial
</Badge> </Button>
)}
{trial.status === "in_progress" && ( {trial.status === "in_progress" && (
<div className="flex items-center gap-1 font-mono text-sm"> <>
<Clock className="h-3 w-3" /> <Button
{formatElapsedTime(elapsedTime)} variant="outline"
</div> size="sm"
onClick={handlePauseTrial}
className="gap-2"
>
<Pause className="h-4 w-4" />
Pause
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleNextStep()}
className="gap-2"
>
<SkipForward className="h-4 w-4" />
Next Step
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleAbortTrial}
className="gap-2"
>
<X className="h-4 w-4" />
Abort
</Button>
<Button
variant="default"
size="sm"
onClick={handleCompleteTrial}
className="gap-2 bg-green-600 hover:bg-green-700"
>
<CheckCircle className="h-4 w-4" />
Complete
</Button>
</>
)} )}
{steps.length > 0 && ( {_userRole !== "participant" && (
<div className="flex items-center gap-2 text-sm"> <Button asChild variant="ghost" size="sm">
<span className="text-muted-foreground"> <Link href={`/studies/${trial.experiment.studyId}/trials`}>
Step {currentStepIndex + 1} of {totalSteps} Exit
</span> </Link>
<div className="w-16"> </Button>
<Progress value={progressPercentage} className="h-2" />
</div>
</div>
)} )}
</div> </div>
}
className="flex-none px-2 pb-2"
/>
<div className="text-muted-foreground flex items-center gap-4 text-sm"> {/* Main Grid - 2 rows */}
<div>{trial.experiment.name}</div> <div className="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2">
<div>{trial.participant.participantCode}</div> {/* Top Row - 3 Column Layout */}
<Badge <div className="flex-1 min-h-0 flex gap-2">
variant={rosConnected ? "default" : "outline"} {/* Left Sidebar - Control Panel (Collapsible) */}
className="text-xs" {!leftCollapsed && (
<div className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm w-80">
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
<span className="text-sm font-medium">Control</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setLeftCollapsed(true)}
> >
{rosConnected ? "ROS Connected" : "ROS Offline"} <PanelLeftClose className="h-4 w-4" />
</Badge> </Button>
<button
onClick={() => startTour("wizard")}
className="hover:bg-muted p-1 rounded-full transition-colors"
title="Start Tour"
>
<HelpCircle className="h-4 w-4" />
</button>
</div> </div>
</div> <div className="flex-1 overflow-auto min-h-0 bg-muted/10">
</div>
{/* Main Content with Vertical Resizable Split */}
<div className="min-h-0 flex-1">
<ResizablePanelGroup direction="vertical">
<ResizablePanel defaultSize={75} minSize={30}>
<PanelsContainer
left={
<div id="tour-wizard-controls" className="h-full"> <div id="tour-wizard-controls" className="h-full">
<WizardControlPanel <WizardControlPanel
trial={trial} trial={trial}
@@ -688,15 +824,59 @@ export const WizardInterface = React.memo(function WizardInterface({
onExecuteRobotAction={handleExecuteRobotAction} onExecuteRobotAction={handleExecuteRobotAction}
studyId={trial.experiment.studyId} studyId={trial.experiment.studyId}
_isConnected={rosConnected} _isConnected={rosConnected}
activeTab={controlPanelTab}
onTabChange={setControlPanelTab}
isStarting={startTrialMutation.isPending} isStarting={startTrialMutation.isPending}
onSetAutonomousLife={setAutonomousLife} onSetAutonomousLife={setAutonomousLife}
readOnly={trial.status === 'completed' || _userRole === 'observer'} readOnly={trial.status === 'completed' || _userRole === 'observer'}
/> />
</div> </div>
} </div>
center={ </div>
)}
{/* Center - Tabbed Workspace */}
{/* Center - Execution Workspace */}
<div className="flex-1 flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm">
<div className="flex items-center border-b px-3 py-2 bg-muted/30 min-h-[45px]">
{leftCollapsed && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 mr-2"
onClick={() => setLeftCollapsed(false)}
title="Open Tools Panel"
>
<PanelLeftOpen className="h-4 w-4" />
</Button>
)}
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Trial Execution</span>
{currentStep && (
<Badge variant="outline" className="text-xs font-normal">
{currentStep.name}
</Badge>
)}
</div>
<div className="flex-1" />
<div className="mr-2 text-xs text-muted-foreground font-medium">
Step {currentStepIndex + 1} / {steps.length}
</div>
{rightCollapsed && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setRightCollapsed(false)}
title="Open Robot Status"
>
<PanelRightOpen className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
<div id="tour-wizard-timeline" className="h-full"> <div id="tour-wizard-timeline" className="h-full">
<WizardExecutionPanel <WizardExecutionPanel
trial={trial} trial={trial}
@@ -718,8 +898,24 @@ export const WizardInterface = React.memo(function WizardInterface({
readOnly={trial.status === 'completed' || _userRole === 'observer'} readOnly={trial.status === 'completed' || _userRole === 'observer'}
/> />
</div> </div>
} </div>
right={ </div>
{/* Right Sidebar - Robot Status (Collapsible) */}
{!rightCollapsed && (
<div className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm w-80">
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
<span className="text-sm font-medium">Robot Status</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setRightCollapsed(true)}
>
<PanelRightClose className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
<div id="tour-wizard-robot-status" className="h-full"> <div id="tour-wizard-robot-status" className="h-full">
<WizardMonitoringPanel <WizardMonitoringPanel
rosConnected={rosConnected} rosConnected={rosConnected}
@@ -732,25 +928,54 @@ export const WizardInterface = React.memo(function WizardInterface({
readOnly={trial.status === 'completed' || _userRole === 'observer'} readOnly={trial.status === 'completed' || _userRole === 'observer'}
/> />
</div> </div>
} </div>
showDividers={true} </div>
className="h-full" )}
/> </div>
</ResizablePanel>
<ResizableHandle /> {/* Bottom Row - Observations (Full Width, Collapsible) */}
{!obsCollapsed && (
<ResizablePanel defaultSize={25} minSize={10}> <Tabs value={obsTab} onValueChange={(v) => setObsTab(v as "notes" | "timeline")} className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm h-48 flex-none">
<div className="flex items-center border-b px-3 py-2 bg-muted/30 gap-3">
<span className="text-sm font-medium">Observations</span>
<TabsList className="h-7 bg-transparent border-0 p-0">
<TabsTrigger value="notes" className="text-xs h-7 px-3">Notes</TabsTrigger>
<TabsTrigger value="timeline" className="text-xs h-7 px-3">Timeline</TabsTrigger>
</TabsList>
<div className="flex-1" />
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setObsCollapsed(true)}
>
<ChevronDown className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
<WizardObservationPane <WizardObservationPane
onAddAnnotation={handleAddAnnotation} onAddAnnotation={handleAddAnnotation}
isSubmitting={addAnnotationMutation.isPending} isSubmitting={addAnnotationMutation.isPending}
trialEvents={trialEvents} trialEvents={trialEvents}
// Observation pane is where observers usually work, so not readOnly for them?
// But maybe we want 'readOnly' for completed trials.
readOnly={trial.status === 'completed'} readOnly={trial.status === 'completed'}
activeTab={obsTab}
/> />
</ResizablePanel> </div>
</ResizablePanelGroup> </Tabs>
)}
{
obsCollapsed && (
<Button
variant="outline"
size="sm"
onClick={() => setObsCollapsed(false)}
className="w-full flex-none"
>
<ChevronUp className="h-4 w-4 mr-2" />
Show Observations
</Button>
)
}
</div > </div >
</div > </div >
); );

View File

@@ -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 (
<div
className={cn(
"border-border/60 bg-muted/40 supports-[backdrop-filter]:bg-muted/30 backdrop-blur",
"flex h-9 w-full flex-shrink-0 items-center gap-4 border-t px-3 text-xs font-medium",
className,
)}
>
{/* Step Progress */}
<div className="flex items-center gap-2">
<span className="flex items-center gap-1.5 text-muted-foreground">
<GitBranch className="h-3.5 w-3.5 opacity-70" />
Step {currentStepIndex + 1}/{totalSteps}
</span>
<div className="w-20">
<Progress value={progressPercentage} className="h-1.5" />
</div>
<span className="text-muted-foreground/70">{Math.round(progressPercentage)}%</span>
</div>
<Separator orientation="vertical" className="h-4 opacity-50" />
{/* Action Progress */}
{totalActionsCount > 0 && (
<>
<div className="flex items-center gap-2">
<span className="flex items-center gap-1.5 text-muted-foreground">
<Sparkles className="h-3.5 w-3.5 opacity-70" />
{completedActionsCount}/{totalActionsCount} actions
</span>
<div className="w-16">
<Progress value={actionProgress} className="h-1.5" />
</div>
</div>
<Separator orientation="vertical" className="h-4 opacity-50" />
</>
)}
{/* Trial Stats */}
<div className="flex items-center gap-3 text-muted-foreground">
<span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 opacity-70" />
{eventsCount} events
</span>
{trialStatus === "in_progress" && (
<Badge variant="default" className="h-5 gap-1 bg-emerald-500 px-1.5 text-[10px] font-normal">
<Play className="h-2.5 w-2.5" />
Live
</Badge>
)}
{trialStatus === "completed" && (
<Badge variant="secondary" className="h-5 gap-1 px-1.5 text-[10px] font-normal">
<CheckCircle2 className="h-2.5 w-2.5" />
Completed
</Badge>
)}
</div>
<div className="flex-1" />
{/* Quick Actions */}
<div className="flex items-center gap-1">
{onAddNote && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={onAddNote}
title="Add Quick Note"
>
<StickyNote className="mr-1.5 h-3.5 w-3.5" />
Note
</Button>
)}
</div>
</div>
);
}
export default TrialStatusBar;

View File

@@ -207,9 +207,9 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
)} )}
</div> </div>
<div className="flex-1 overflow-hidden bg-black p-4 flex items-center justify-center relative"> <div className="flex-1 overflow-hidden bg-muted/50 p-4 flex items-center justify-center relative">
{isCameraEnabled ? ( {isCameraEnabled ? (
<div className="w-full relative rounded-lg overflow-hidden border border-slate-800"> <div className="w-full relative rounded-lg overflow-hidden border border-border shadow-sm bg-black">
<AspectRatio ratio={16 / 9}> <AspectRatio ratio={16 / 9}>
<Webcam <Webcam
ref={webcamRef} ref={webcamRef}
@@ -249,11 +249,13 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
)} )}
</div> </div>
) : ( ) : (
<div className="text-center text-slate-500"> <div className="text-center text-muted-foreground/50">
<CameraOff className="mx-auto mb-2 h-12 w-12 opacity-20" /> <div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<p className="text-sm">Camera is disabled</p> <CameraOff className="h-6 w-6 opacity-50" />
</div>
<p className="text-sm font-medium">Camera is disabled</p>
<Button <Button
variant="outline" variant="secondary"
size="sm" size="sm"
className="mt-4" className="mt-4"
onClick={handleEnableCamera} onClick={handleEnableCamera}

View File

@@ -22,7 +22,6 @@ import { Separator } from "~/components/ui/separator";
import { Switch } from "~/components/ui/switch"; import { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { Alert, AlertDescription } from "~/components/ui/alert"; import { Alert, AlertDescription } from "~/components/ui/alert";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import { ScrollArea } from "~/components/ui/scroll-area"; import { ScrollArea } from "~/components/ui/scroll-area";
import { RobotActionsPanel } from "../RobotActionsPanel"; import { RobotActionsPanel } from "../RobotActionsPanel";
@@ -34,8 +33,17 @@ interface StepData {
| "wizard_action" | "wizard_action"
| "robot_action" | "robot_action"
| "parallel_steps" | "parallel_steps"
| "conditional_branch"; | "conditional"; // Updated to match DB enum
parameters: Record<string, unknown>; parameters: Record<string, unknown>;
conditions?: {
options?: {
label: string;
value: string;
nextStepId?: string;
nextStepIndex?: number;
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
}[];
};
order: number; order: number;
actions?: { actions?: {
id: string; id: string;
@@ -80,7 +88,7 @@ interface WizardControlPanelProps {
currentStepIndex: number; currentStepIndex: number;
onStartTrial: () => void; onStartTrial: () => void;
onPauseTrial: () => void; onPauseTrial: () => void;
onNextStep: () => void; onNextStep: (targetIndex?: number) => void;
onCompleteTrial: () => void; onCompleteTrial: () => void;
onAbortTrial: () => void; onAbortTrial: () => void;
onExecuteAction: ( onExecuteAction: (
@@ -94,14 +102,13 @@ interface WizardControlPanelProps {
) => Promise<void>; ) => Promise<void>;
studyId?: string; studyId?: string;
_isConnected: boolean; _isConnected: boolean;
activeTab: "control" | "step" | "actions" | "robot";
onTabChange: (tab: "control" | "step" | "actions" | "robot") => void;
isStarting?: boolean; isStarting?: boolean;
onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>; onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>;
readOnly?: boolean; readOnly?: boolean;
} }
export function WizardControlPanel({ export const WizardControlPanel = React.memo(function WizardControlPanel({
trial, trial,
currentStep, currentStep,
steps, steps,
@@ -115,8 +122,6 @@ export function WizardControlPanel({
onExecuteRobotAction, onExecuteRobotAction,
studyId, studyId,
_isConnected, _isConnected,
activeTab,
onTabChange,
isStarting = false, isStarting = false,
onSetAutonomousLife, onSetAutonomousLife,
readOnly = false, readOnly = false,
@@ -141,237 +146,21 @@ export function WizardControlPanel({
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Tabbed Content */}
<Tabs
value={activeTab}
onValueChange={(value: string) => {
if (
value === "control" ||
value === "step" ||
value === "actions" ||
value === "robot"
) {
onTabChange(value as "control" | "step" | "actions");
}
}}
className="flex min-h-0 flex-1 flex-col"
>
<div className="border-b px-2 py-1">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="control" className="text-xs">
<Play className="mr-1 h-3 w-3" />
Control
</TabsTrigger>
<TabsTrigger value="step" className="text-xs">
<Eye className="mr-1 h-3 w-3" />
Step
</TabsTrigger>
<TabsTrigger value="actions" className="text-xs">
<Zap className="mr-1 h-3 w-3" />
Actions
</TabsTrigger>
</TabsList>
</div>
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
{/* Trial Control Tab */}
<TabsContent
value="control"
className="m-0 h-full data-[state=active]:flex data-[state=active]:flex-col"
>
<ScrollArea className="h-full"> <ScrollArea className="h-full">
<div className="space-y-3 p-3"> <div className="space-y-4 p-3">
{trial.status === "scheduled" && (
<Button
onClick={() => {
console.log("[WizardControlPanel] Start Trial clicked");
onStartTrial();
}}
className="w-full"
size="sm"
disabled={isStarting || readOnly}
>
<Play className="mr-2 h-4 w-4" />
{isStarting ? "Starting..." : "Start Trial"}
</Button>
)}
{trial.status === "in_progress" && (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<Button
onClick={onPauseTrial}
variant="outline"
size="sm"
disabled={readOnly}
>
<Pause className="mr-1 h-3 w-3" />
Pause
</Button>
<Button
onClick={onNextStep}
disabled={(currentStepIndex >= steps.length - 1) || readOnly}
size="sm"
>
<SkipForward className="mr-1 h-3 w-3" />
Next
</Button>
</div>
<Separator />
<Button
onClick={onCompleteTrial}
variant="outline"
className="w-full"
size="sm"
disabled={readOnly}
>
<CheckCircle className="mr-2 h-4 w-4" />
Complete Trial
</Button>
<Button
onClick={onAbortTrial}
variant="destructive"
className="w-full"
size="sm"
disabled={readOnly}
>
<X className="mr-2 h-4 w-4" />
Abort Trial
</Button>
</div>
)}
{(trial.status === "completed" ||
trial.status === "aborted") && (
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
Trial has ended. All controls are disabled.
</AlertDescription>
</Alert>
)}
<Separator />
<div className="space-y-4">
<div className="text-xs font-medium">Robot Status</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
Connection
</span>
{_isConnected ? (
<Badge variant="default" className="bg-green-600 text-xs">
Connected
</Badge>
) : (
<Badge variant="outline" className="text-yellow-600 border-yellow-600 text-xs">
Polling...
</Badge>
)}
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="autonomous-life" className="text-xs font-normal text-muted-foreground">Autonomous Life</Label>
</div>
<Switch
id="autonomous-life"
checked={autonomousLife}
onCheckedChange={handleAutonomousLifeChange}
disabled={!_isConnected || readOnly}
className="scale-75"
/>
</div>
</div>
</div>
</ScrollArea>
</TabsContent>
{/* Current Step Tab */}
<TabsContent
value="step"
className="m-0 h-full data-[state=active]:flex data-[state=active]:flex-col"
>
<ScrollArea className="h-full">
<div className="p-3">
{currentStep && trial.status === "in_progress" ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> {/* Decision Point UI removed as per user request (handled in Execution Panel) */}
<div className="text-sm font-medium">
{currentStep.name}
</div>
<Badge variant="outline" className="text-xs">
{currentStep.type.replace("_", " ")}
</Badge>
</div>
{currentStep.description && (
<div className="text-muted-foreground text-xs">
{currentStep.description}
</div>
)}
<Separator />
<div className="space-y-2">
<div className="text-xs font-medium">Step Progress</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Current</span>
<span>Step {currentStepIndex + 1}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Remaining</span>
<span>{steps.length - currentStepIndex - 1} steps</span>
</div>
</div>
{currentStep.type === "robot_action" && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
Robot is executing this step. Monitor progress in the
monitoring panel.
</AlertDescription>
</Alert>
)}
</div>
) : (
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-xs">
{trial.status === "scheduled"
? "Start trial to see current step"
: trial.status === "in_progress"
? "No current step"
: "Trial has ended"}
</div>
)}
</div>
</ScrollArea>
</TabsContent>
{/* Quick Actions Tab */}
<TabsContent
value="actions"
className="m-0 h-full data-[state=active]:flex data-[state=active]:flex-col"
>
<ScrollArea className="h-full">
<div className="space-y-2 p-3">
{trial.status === "in_progress" ? ( {trial.status === "in_progress" ? (
<> <div className="space-y-2">
<div className="mb-2 text-xs font-medium">
Quick Actions
</div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="w-full justify-start" className="w-full justify-start"
onClick={() => { onClick={() => onExecuteAction("acknowledge")}
console.log("[WizardControlPanel] Acknowledge clicked");
onExecuteAction("acknowledge");
}}
disabled={readOnly} disabled={readOnly}
> >
<CheckCircle className="mr-2 h-3 w-3" /> <CheckCircle className="mr-2 h-3 w-3" />
@@ -381,36 +170,26 @@ export function WizardControlPanel({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="w-full justify-start" className="w-full justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800"
onClick={() => { onClick={() => onExecuteAction("intervene")}
console.log("[WizardControlPanel] Intervene clicked");
onExecuteAction("intervene");
}}
disabled={readOnly} disabled={readOnly}
> >
<AlertCircle className="mr-2 h-3 w-3" /> <AlertCircle className="mr-2 h-3 w-3" />
Intervene Flag Intervention
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="w-full justify-start" className="w-full justify-start"
onClick={() => { onClick={() => onExecuteAction("note", { content: "Wizard note" })}
console.log("[WizardControlPanel] Add Note clicked");
onExecuteAction("note", { content: "Wizard note" });
}}
disabled={readOnly} disabled={readOnly}
> >
<User className="mr-2 h-3 w-3" /> <User className="mr-2 h-3 w-3" />
Add Note Add Note
</Button> </Button>
<Separator />
{currentStep?.type === "wizard_action" && ( {currentStep?.type === "wizard_action" && (
<div className="space-y-2">
<div className="text-xs font-medium">Step Actions</div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -419,31 +198,44 @@ export function WizardControlPanel({
disabled={readOnly} disabled={readOnly}
> >
<CheckCircle className="mr-2 h-3 w-3" /> <CheckCircle className="mr-2 h-3 w-3" />
Mark Complete Mark Step Complete
</Button> </Button>
</div>
)} )}
</> </div>
) : ( ) : (
<div className="flex h-32 items-center justify-center"> <div className="text-xs text-muted-foreground p-2 text-center border border-dashed rounded-md bg-muted/20">
<div className="text-muted-foreground text-center text-xs"> Controls available during trial
{trial.status === "scheduled"
? "Start trial to access actions"
: "Actions unavailable - trial not active"}
</div>
</div> </div>
)} )}
</div> </div>
</ScrollArea>
</TabsContent>
{/* Robot Actions Tab */} <Separator />
<TabsContent
value="robot" {/* Robot Controls (Merged from System & Robot Tab) */}
className="m-0 h-full data-[state=active]:flex data-[state=active]:flex-col" <div className="space-y-3">
> <div className="flex items-center justify-between">
<ScrollArea className="h-full"> <span className="text-muted-foreground text-xs">Connection</span>
<div className="p-3"> {_isConnected ? (
<Badge variant="default" className="bg-green-600 text-xs">Connected</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground border-muted-foreground/30 text-xs">Offline</Badge>
)}
</div>
<div className="flex items-center justify-between">
<Label htmlFor="autonomous-life" className="text-xs font-normal text-muted-foreground">Autonomous Life</Label>
<Switch
id="autonomous-life"
checked={!!autonomousLife}
onCheckedChange={handleAutonomousLifeChange}
disabled={!_isConnected || readOnly}
className="scale-75"
/>
</div>
<Separator />
{/* Robot Actions Panel Integration */}
{studyId && onExecuteRobotAction ? ( {studyId && onExecuteRobotAction ? (
<div className={readOnly ? "pointer-events-none opacity-50" : ""}> <div className={readOnly ? "pointer-events-none opacity-50" : ""}>
<RobotActionsPanel <RobotActionsPanel
@@ -453,19 +245,12 @@ export function WizardControlPanel({
/> />
</div> </div>
) : ( ) : (
<Alert> <div className="text-xs text-muted-foreground text-center py-2">Robot actions unavailable</div>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Robot actions are not available. Study ID or action
handler is missing.
</AlertDescription>
</Alert>
)} )}
</div> </div>
</ScrollArea>
</TabsContent>
</div> </div>
</Tabs> </ScrollArea>
</div >
</div > </div >
); );
} });

View File

@@ -3,16 +3,15 @@
import React from "react"; import React from "react";
import { import {
Play, Play,
Clock, SkipForward,
CheckCircle, CheckCircle,
AlertCircle, AlertCircle,
Bot,
User,
Activity,
Zap,
ArrowRight, ArrowRight,
AlertTriangle, Zap,
Loader2,
Clock,
RotateCcw, RotateCcw,
AlertTriangle,
} from "lucide-react"; } from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
@@ -26,8 +25,17 @@ interface StepData {
| "wizard_action" | "wizard_action"
| "robot_action" | "robot_action"
| "parallel_steps" | "parallel_steps"
| "conditional_branch"; | "conditional";
parameters: Record<string, unknown>; parameters: Record<string, unknown>;
conditions?: {
options?: {
label: string;
value: string;
nextStepId?: string;
nextStepIndex?: number;
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
}[];
};
order: number; order: number;
actions?: { actions?: {
id: string; id: string;
@@ -129,30 +137,31 @@ export function WizardExecutionPanel({
const activeActionIndex = completedActionsCount; const activeActionIndex = completedActionsCount;
// Auto-scroll to active action
const activeActionRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (activeActionRef.current) {
activeActionRef.current.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
}, [activeActionIndex, currentStepIndex]);
// Pre-trial state // Pre-trial state
if (trial.status === "scheduled") { if (trial.status === "scheduled") {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="border-b p-3"> <div className="flex-1 flex items-center justify-center p-6">
<h3 className="text-sm font-medium">Trial Ready</h3> <div className="w-full max-w-md space-y-4 text-center">
<p className="text-muted-foreground text-xs"> <Clock className="text-muted-foreground mx-auto h-12 w-12 opacity-20" />
{steps.length} steps prepared for execution
</p>
</div>
<div className="flex h-full flex-1 items-center justify-center p-6">
<div className="w-full max-w-md space-y-3 text-center">
<Clock className="text-muted-foreground mx-auto h-8 w-8" />
<div> <div>
<h4 className="text-sm font-medium">Ready to Begin</h4> <h4 className="text-lg font-medium">Ready to Begin</h4>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-sm">
Use the control panel to start this trial {steps.length} steps prepared. Use controls to start.
</p> </p>
</div> </div>
<div className="text-muted-foreground space-y-1 text-xs">
<div>Experiment: {trial.experiment.name}</div>
<div>Participant: {trial.participant.participantCode}</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -197,88 +206,111 @@ export function WizardExecutionPanel({
// Active trial state // Active trial state
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col overflow-hidden">
{/* Header */} <div className="flex-1 min-h-0 relative">
<div className="border-b p-3"> <ScrollArea className="h-full w-full">
<div className="flex items-center justify-between"> <div className="pr-4">
<h3 className="text-sm font-medium">Trial Execution</h3>
<Badge variant="secondary" className="text-xs">
{currentStepIndex + 1} / {steps.length}
</Badge>
</div>
{currentStep && (
<p className="text-muted-foreground mt-1 text-xs">
{currentStep.name}
</p>
)}
</div>
{/* Simplified Content - Sequential Focus */}
<div className="relative flex-1 overflow-hidden">
<ScrollArea className="h-full">
{currentStep ? ( {currentStep ? (
<div className="flex flex-col gap-6 p-6"> <div className="flex flex-col gap-4 p-4 max-w-2xl mx-auto">
{/* Header Info (Simplified) */} {/* Header Info */}
<div className="space-y-4"> <div className="space-y-1 pb-4 border-b">
<div className="flex items-start justify-between">
<div>
<h2 className="text-xl font-bold tracking-tight">{currentStep.name}</h2> <h2 className="text-xl font-bold tracking-tight">{currentStep.name}</h2>
{currentStep.description && ( {currentStep.description && (
<div className="text-muted-foreground text-sm mt-1">{currentStep.description}</div> <div className="text-muted-foreground">{currentStep.description}</div>
)} )}
</div> </div>
</div>
</div>
{/* Action Sequence */} {/* Action Sequence */}
{currentStep.actions && currentStep.actions.length > 0 && ( {currentStep.actions && currentStep.actions.length > 0 && (
<div className="space-y-4"> <div className="relative ml-3 space-y-0 pt-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
Execution Sequence
</h3>
</div>
<div className="grid gap-3">
{currentStep.actions.map((action, idx) => { {currentStep.actions.map((action, idx) => {
const isCompleted = idx < activeActionIndex; const isCompleted = idx < activeActionIndex;
const isActive = idx === activeActionIndex; const isActive = idx === activeActionIndex;
const isLast = idx === currentStep.actions!.length - 1;
return ( return (
<div <div
key={action.id} key={action.id}
className={`group relative flex items-center gap-4 rounded-xl border p-5 transition-all ${isActive ? "bg-card border-primary ring-1 ring-primary shadow-md" : className="relative pl-8 pb-10 last:pb-0"
isCompleted ? "bg-muted/30 border-transparent opacity-70" : ref={isActive ? activeActionRef : undefined}
"bg-card border-border opacity-50" >
{/* Connecting Line */}
{!isLast && (
<div
className={`absolute left-[11px] top-8 bottom-0 w-[2px] ${isCompleted ? "bg-primary/20" : "bg-border/40"}`}
/>
)}
{/* Marker */}
<div
className={`absolute left-0 top-1 h-6 w-6 rounded-full border-2 flex items-center justify-center z-10 bg-background transition-all duration-300 ${isCompleted
? "border-primary bg-primary text-primary-foreground"
: isActive
? "border-primary ring-4 ring-primary/10 scale-110"
: "border-muted-foreground/30 text-muted-foreground"
}`} }`}
> >
<div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full border text-sm font-medium ${isCompleted ? "bg-transparent text-green-600 border-green-600" : {isCompleted ? (
isActive ? "bg-transparent text-primary border-primary font-bold shadow-sm" : <CheckCircle className="h-3.5 w-3.5" />
"bg-transparent text-muted-foreground border-transparent" ) : (
}`}> <span className="text-[10px] font-bold">{idx + 1}</span>
{isCompleted ? <CheckCircle className="h-5 w-5" /> : idx + 1}
</div>
<div className="flex-1 min-w-0">
<div className={`font - medium truncate ${isCompleted ? "line-through text-muted-foreground" : ""} `}>{action.name}</div>
{action.description && (
<div className="text-xs text-muted-foreground line-clamp-1">
{action.description}
</div>
)} )}
</div> </div>
{action.pluginId && isActive && ( {/* Content Card */}
<div className="flex items-center gap-2"> <div
className={`rounded-lg border transition-all duration-300 ${isActive
? "bg-card border-primary/50 shadow-md p-5 translate-x-1"
: "bg-muted/5 border-transparent p-3 opacity-70 hover:opacity-100"
}`}
>
<div className="space-y-2">
<div className="flex items-start justify-between gap-4">
<div
className={`text-base font-medium leading-none ${isCompleted ? "line-through text-muted-foreground" : ""
}`}
>
{action.name}
</div>
</div>
{action.description && (
<div className="text-sm text-muted-foreground">
{action.description}
</div>
)}
{/* Active Action Controls */}
{isActive && (
<div className="pt-3 flex items-center gap-3">
{action.pluginId && !["hristudio-core", "hristudio-woz"].includes(action.pluginId) ? (
<>
<Button
size="sm"
className="shadow-sm min-w-[100px]"
onClick={(e) => {
e.preventDefault();
onExecuteRobotAction(
action.pluginId!,
action.type.includes(".")
? action.type.split(".").pop()!
: action.type,
action.parameters || {},
{ autoAdvance: false }
);
onActionCompleted();
}}
disabled={readOnly || isExecuting}
>
<Play className="mr-2 h-3.5 w-3.5" />
Execute
</Button>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-9 px-3 text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
console.log("Skip clicked");
// Fire and forget
onSkipAction( onSkipAction(
action.pluginId!, action.pluginId!,
action.type.includes(".") action.type.includes(".")
@@ -293,34 +325,8 @@ export function WizardExecutionPanel({
> >
Skip Skip
</Button> </Button>
<Button </>
size="default" ) : (
className="h-10 px-4 shadow-sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
console.log("Execute clicked");
onExecuteRobotAction(
action.pluginId!,
action.type.includes(".")
? action.type.split(".").pop()!
: action.type,
action.parameters || {},
{ autoAdvance: false },
);
onActionCompleted();
}}
disabled={readOnly || isExecuting}
>
<Play className="mr-2 h-4 w-4" />
Execute
</Button>
</div>
)}
{/* Fallback for actions with no plugin ID (e.g. manual steps) */}
{!action.pluginId && isActive && (
<div className="flex items-center gap-2">
<Button <Button
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
@@ -331,26 +337,57 @@ export function WizardExecutionPanel({
> >
Mark Done Mark Done
</Button> </Button>
)}
</div> </div>
)} )}
{/* Completed State Indicator */} {/* Wizard Wait For Response / Branching UI */}
{isCompleted && ( {isActive && action.type === 'wizard_wait_for_response' && action.parameters?.options && Array.isArray(action.parameters.options) && (
<div className="flex items-center gap-2 px-3"> <div className="pt-3 grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="text-xs font-medium text-green-600"> {(action.parameters.options as any[]).map((opt, optIdx) => {
Done // Handle both string options and object options
</div> const label = typeof opt === 'string' ? opt : opt.label;
{action.pluginId && ( const value = typeof opt === 'string' ? opt : opt.value;
<> const nextStepId = typeof opt === 'object' ? opt.nextStepId : undefined;
return (
<Button <Button
size="icon" key={optIdx}
variant="ghost" variant="outline"
className="h-7 w-7 text-muted-foreground hover:text-foreground" className="justify-start h-auto py-3 px-4 text-left border-primary/20 hover:border-primary hover:bg-primary/5"
title="Retry Action" onClick={(e) => {
e.preventDefault();
onExecuteAction(
action.id,
{
value,
label,
nextStepId
}
);
onActionCompleted();
}}
disabled={readOnly || isExecuting}
>
<div className="flex flex-col items-start gap-1">
<span className="font-medium">{String(label)}</span>
{typeof opt !== 'string' && value && <span className="text-xs text-muted-foreground font-mono bg-muted px-1.5 py-0.5 rounded-sm">{String(value)}</span>}
</div>
</Button>
);
})}
</div>
)}
{/* Completed State Actions */}
{isCompleted && action.pluginId && (
<div className="pt-1 flex items-center gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs text-muted-foreground hover:text-primary"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
// Execute again without advancing count
onExecuteRobotAction( onExecuteRobotAction(
action.pluginId!, action.pluginId!,
action.type.includes(".") ? action.type.split(".").pop()! : action.type, action.type.includes(".") ? action.type.split(".").pop()! : action.type,
@@ -360,41 +397,27 @@ export function WizardExecutionPanel({
}} }}
disabled={readOnly || isExecuting} disabled={readOnly || isExecuting}
> >
<RotateCcw className="h-3.5 w-3.5" /> <RotateCcw className="mr-1.5 h-3 w-3" />
Retry
</Button> </Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-amber-500 hover:text-amber-600 hover:bg-amber-100"
title="Mark Issue"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onExecuteAction("note", {
content: `Reported issue with action: ${action.name}`,
category: "system_issue"
});
}}
disabled={readOnly}
>
<AlertTriangle className="h-3.5 w-3.5" />
</Button>
</>
)}
</div> </div>
)} )}
</div> </div>
) </div>
</div>
);
})} })}
</div> </div>
)
}
{/* Manual Advance Button */} {/* Manual Advance Button */}
{activeActionIndex >= (currentStep.actions?.length || 0) && ( {activeActionIndex >= (currentStep.actions?.length || 0) && (
<div className="mt-6 flex justify-end"> <div className="mt-6 flex justify-center pb-8">
<Button <Button
size="lg" size="lg"
onClick={currentStepIndex === steps.length - 1 ? onCompleteTrial : onNextStep} onClick={currentStepIndex === steps.length - 1 ? onCompleteTrial : onNextStep}
className={`w-full text-white shadow-md transition-all hover:scale-[1.02] ${currentStepIndex === steps.length - 1 className={`w-full max-w-sm text-white shadow-lg transition-all hover:scale-[1.02] ${currentStepIndex === steps.length - 1
? "bg-blue-600 hover:bg-blue-700" ? "bg-blue-600 hover:bg-blue-700"
: "bg-green-600 hover:bg-green-700" : "bg-green-600 hover:bg-green-700"
}`} }`}
@@ -406,34 +429,14 @@ export function WizardExecutionPanel({
</div> </div>
)} )}
</div> </div>
)}
{/* Manual Wizard Controls (If applicable) */}
{currentStep.type === "wizard_action" && (
<div className="rounded-xl border border-dashed p-6 space-y-4">
<h3 className="text-sm font-medium text-muted-foreground">Manual Controls</h3>
<div className="grid grid-cols-1 gap-3">
<Button
variant="outline"
className="h-12 justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800"
onClick={() => onExecuteAction("intervene")}
disabled={readOnly}
>
<Zap className="mr-2 h-4 w-4" />
Flag Issue / Intervention
</Button>
</div>
</div>
)}
</div>
) : ( ) : (
<div className="flex h-full items-center justify-center text-muted-foreground"> <div className="flex h-full flex-col items-center justify-center text-muted-foreground space-y-3">
No active step <Loader2 className="h-8 w-8 animate-spin opacity-50" />
<div className="text-sm">Waiting for trial to start...</div>
</div> </div>
)} )}
</div>
</ScrollArea> </ScrollArea>
{/* Scroll Hint Fade */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-background to-transparent z-10" />
</div> </div>
</div> </div>
); );

View File

@@ -49,7 +49,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
return ( return (
<div className="flex h-full flex-col gap-2 p-2"> <div className="flex h-full flex-col gap-2 p-2">
{/* Camera View - Always Visible */} {/* Camera View - Always Visible */}
<div className="shrink-0 bg-black rounded-lg overflow-hidden border shadow-sm h-48 sm:h-56 relative group"> <div className="shrink-0 bg-muted/30 rounded-lg overflow-hidden border shadow-sm h-48 sm:h-56 relative group">
<WebcamPanel readOnly={readOnly} /> <WebcamPanel readOnly={readOnly} />
</div> </div>
@@ -69,7 +69,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
{rosConnected ? ( {rosConnected ? (
<Power className="h-3 w-3 text-green-600" /> <Power className="h-3 w-3 text-green-600" />
) : ( ) : (
<PowerOff className="h-3 w-3 text-gray-400" /> <Badge variant="outline" className="text-gray-500 border-gray-300 text-xs text-muted-foreground w-auto px-1.5 py-0">Offline</Badge>
)} )}
</div> </div>
</div> </div>

View File

@@ -31,6 +31,7 @@ interface WizardObservationPaneProps {
) => Promise<void>; ) => Promise<void>;
isSubmitting?: boolean; isSubmitting?: boolean;
readOnly?: boolean; readOnly?: boolean;
activeTab?: "notes" | "timeline";
} }
export function WizardObservationPane({ export function WizardObservationPane({
@@ -38,6 +39,7 @@ export function WizardObservationPane({
isSubmitting = false, isSubmitting = false,
trialEvents = [], trialEvents = [],
readOnly = false, readOnly = false,
activeTab = "notes",
}: WizardObservationPaneProps & { trialEvents?: TrialEvent[] }) { }: WizardObservationPaneProps & { trialEvents?: TrialEvent[] }) {
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const [category, setCategory] = useState("observation"); const [category, setCategory] = useState("observation");
@@ -68,20 +70,8 @@ export function WizardObservationPane({
}; };
return ( return (
<div className="flex h-full flex-col border-t bg-background"> <div className="flex h-full flex-col bg-background">
<Tabs defaultValue="notes" className="flex h-full flex-col"> <div className={`flex-1 flex flex-col p-4 m-0 ${activeTab !== "notes" ? "hidden" : ""}`}>
<div className="border-b px-4 bg-muted/30">
<TabsList className="h-9 -mb-px bg-transparent p-0">
<TabsTrigger value="notes" className="h-9 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 pb-2 pt-2 font-medium text-muted-foreground data-[state=active]:text-foreground shadow-none">
Notes & Observations
</TabsTrigger>
<TabsTrigger value="timeline" className="h-9 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 pb-2 pt-2 font-medium text-muted-foreground data-[state=active]:text-foreground shadow-none">
Timeline
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="notes" className="flex-1 flex flex-col p-4 m-0 data-[state=inactive]:hidden">
<div className="flex flex-1 flex-col gap-2"> <div className="flex flex-1 flex-col gap-2">
<Textarea <Textarea
placeholder={readOnly ? "Session is read-only" : "Type your observation here..."} placeholder={readOnly ? "Session is read-only" : "Type your observation here..."}
@@ -151,12 +141,11 @@ export function WizardObservationPane({
</div> </div>
)} )}
</div> </div>
</TabsContent> </div>
<TabsContent value="timeline" className="flex-1 m-0 min-h-0 p-4 data-[state=inactive]:hidden"> <div className={`flex-1 m-0 min-h-0 p-4 ${activeTab !== "timeline" ? "hidden" : ""}`}>
<HorizontalTimeline events={trialEvents} /> <HorizontalTimeline events={trialEvents} />
</TabsContent> </div>
</Tabs>
</div> </div>
); );
} }

View File

@@ -168,7 +168,6 @@ export function convertDatabaseToSteps(
const sortedSteps = [...dbSteps].sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0)); const sortedSteps = [...dbSteps].sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0));
return sortedSteps.map((dbStep, idx) => { return sortedSteps.map((dbStep, idx) => {
// console.log(`[block-converter] Step ${dbStep.name} OrderIndex:`, dbStep.orderIndex, dbStep.order_index);
return { return {
id: dbStep.id, id: dbStep.id,
name: dbStep.name, name: dbStep.name,

View File

@@ -24,7 +24,7 @@ export type TriggerType =
export interface ActionParameter { export interface ActionParameter {
id: string; id: string;
name: string; name: string;
type: "text" | "number" | "select" | "boolean"; type: "text" | "number" | "select" | "boolean" | "json" | "array";
placeholder?: string; placeholder?: string;
options?: string[]; options?: string[];
min?: number; min?: number;

View File

@@ -148,7 +148,11 @@ export class WizardRosService extends EventEmitter {
console.error("[WizardROS] WebSocket error:", error); console.error("[WizardROS] WebSocket error:", error);
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
this.isConnecting = false; this.isConnecting = false;
// Prevent unhandled error event if no listeners
if (this.listenerCount("error") > 0) {
this.emit("error", error); this.emit("error", error);
}
reject(error); reject(error);
}; };
}); });

View File

@@ -0,0 +1,51 @@
{
"id": "hristudio-core",
"name": "HRIStudio Core",
"version": "1.0.0",
"description": "Essential platform control flow and logic actions.",
"author": "HRIStudio",
"trustLevel": "official",
"actionDefinitions": [
{
"id": "wait",
"name": "Wait",
"description": "Wait for specified time",
"category": "control",
"icon": "Clock",
"color": "#f59e0b",
"parameters": {},
"parameterSchema": {
"type": "object",
"properties": {
"duration": {
"title": "Duration (seconds)",
"type": "number",
"minimum": 0.1,
"maximum": 300,
"default": 2,
"description": "Time to wait in seconds"
}
},
"required": [
"duration"
]
},
"timeout": 60000,
"retryable": false,
"nestable": false
},
{
"id": "branch",
"name": "Branch / Decision",
"description": "Prompt the wizard to choose a path.",
"category": "control",
"icon": "GitBranch",
"color": "#f97316",
"parameters": {},
"parameterSchema": {},
"timeout": 0,
"retryable": false,
"nestable": false
}
]
}

View File

@@ -0,0 +1,107 @@
{
"id": "hristudio-woz",
"name": "Wizard of Oz Features",
"version": "1.0.0",
"description": "Standard capabilities for Wizard of Oz studies.",
"author": "HRIStudio",
"trustLevel": "official",
"actionDefinitions": [
{
"id": "wizard_say",
"name": "Wizard Says",
"description": "Wizard speaks to participant",
"category": "wizard",
"icon": "MessageSquare",
"color": "#a855f7",
"parameters": {},
"parameterSchema": {
"type": "object",
"properties": {
"message": {
"title": "Message",
"type": "string",
"description": "Text to display/speak"
},
"tone": {
"title": "Tone",
"type": "string",
"enum": [
"neutral",
"friendly",
"encouraging"
],
"default": "neutral"
}
},
"required": [
"message"
]
},
"timeout": 30000,
"retryable": true,
"nestable": false
},
{
"id": "wizard_wait_for_response",
"name": "Wait for Wizard Input",
"description": "Pause execution until wizard provides input",
"category": "wizard",
"icon": "HandMetal",
"color": "#a855f7",
"parameters": {},
"parameterSchema": {
"type": "object",
"properties": {
"prompt_text": {
"title": "Prompt Text",
"type": "string",
"description": "What did the participant say?"
},
"options": {
"title": "Response Options",
"type": "array",
"items": {
"type": "string"
},
"description": "Choices for the Wizard"
}
},
"required": [
"prompt_text"
]
},
"timeout": 0,
"retryable": false,
"nestable": false
},
{
"id": "observe",
"name": "Observe",
"description": "Record participant behavior",
"category": "observation",
"icon": "Eye",
"color": "#8b5cf6",
"parameters": {},
"parameterSchema": {
"type": "object",
"properties": {
"behavior": {
"title": "Behavior to observe",
"type": "string",
"enum": [
"facial_expression",
"body_language",
"verbal_response"
]
}
},
"required": [
"behavior"
]
},
"timeout": 120000,
"retryable": false,
"nestable": false
}
]
}

View File

@@ -1543,7 +1543,8 @@ export const experimentsRouter = createTRPCRouter({
description: step.description, description: step.description,
order: step.orderIndex, order: step.orderIndex,
duration: step.durationEstimate, duration: step.durationEstimate,
parameters: step.conditions as Record<string, unknown>, parameters: {} as Record<string, unknown>, // No standard parameters on Step, only Conditions
conditions: step.conditions as Record<string, unknown>, // Correctly map conditions
parentId: undefined, // Not supported in current schema parentId: undefined, // Not supported in current schema
children: [], // TODO: implement hierarchical steps if needed children: [], // TODO: implement hierarchical steps if needed
actions: step.actions.map((action) => ({ actions: step.actions.map((action) => ({

View File

@@ -1046,6 +1046,19 @@ export const trialsRouter = createTRPCRouter({
createdBy: ctx.session.user.id, createdBy: ctx.session.user.id,
}); });
// Update execution variables if data provided
if (input.data) {
const executionEngine = getExecutionEngine();
Object.entries(input.data).forEach(([key, value]) => {
executionEngine.setVariable(input.trialId, key, value);
});
// Also set a generic "last_wizard_response" if response field exists
if ('response' in input.data) {
executionEngine.setVariable(input.trialId, "last_wizard_response", input.data.response);
}
}
return { success: true }; return { success: true };
}), }),

View File

@@ -47,6 +47,7 @@ export interface StepDefinition {
type: string; type: string;
orderIndex: number; orderIndex: number;
condition?: string; condition?: string;
conditions?: Record<string, any>;
actions: ActionDefinition[]; actions: ActionDefinition[];
} }
@@ -173,7 +174,8 @@ export class TrialExecutionEngine {
description: step.description || undefined, description: step.description || undefined,
type: step.type, type: step.type,
orderIndex: step.orderIndex, orderIndex: step.orderIndex,
condition: (step.conditions as string) || undefined, condition: typeof step.conditions === 'string' ? step.conditions : undefined,
conditions: typeof step.conditions === 'object' ? (step.conditions as Record<string, any>) : undefined,
actions: actionDefinitions, actions: actionDefinitions,
}); });
} }
@@ -399,20 +401,37 @@ export class TrialExecutionEngine {
switch (action.type) { switch (action.type) {
case "wait": case "wait":
case "hristudio-core.wait":
return await this.executeWaitAction(action); return await this.executeWaitAction(action);
case "branch":
case "hristudio-core.branch":
// Branch actions are logical markers; execution is just a pass-through
return {
success: true,
completed: true,
duration: 0,
data: { message: "Branch point reached" },
};
case "wizard_say": case "wizard_say":
case "hristudio-woz.wizard_say":
return await this.executeWizardAction(trialId, action);
case "wizard_wait_for_response":
case "hristudio-woz.wizard_wait_for_response":
return await this.executeWizardAction(trialId, action); return await this.executeWizardAction(trialId, action);
case "wizard_gesture": case "wizard_gesture":
return await this.executeWizardAction(trialId, action); return await this.executeWizardAction(trialId, action);
case "observe_behavior": case "observe_behavior":
case "hristudio-woz.observe":
return await this.executeObservationAction(trialId, action); return await this.executeObservationAction(trialId, action);
default: default:
// Check if it's a robot action (contains plugin prefix) // Check if it's a robot action (contains plugin prefix)
if (action.type.includes(".")) { if (action.type.includes(".") && !action.type.startsWith("hristudio-")) {
return await this.executeRobotAction(trialId, action); return await this.executeRobotAction(trialId, action);
} }
@@ -424,6 +443,7 @@ export class TrialExecutionEngine {
data: { data: {
message: `Action type '${action.type}' not implemented yet`, message: `Action type '${action.type}' not implemented yet`,
parameters: action.parameters, parameters: action.parameters,
localHandler: true // Indicate this fell through to default local handler
}, },
}; };
} }
@@ -813,6 +833,16 @@ export class TrialExecutionEngine {
} }
} }
/**
* Set a variable in the trial context
*/
setVariable(trialId: string, key: string, value: unknown): void {
const context = this.activeTrials.get(trialId);
if (context) {
context.variables[key] = value;
}
}
/** /**
* Advance to the next step * Advance to the next step
*/ */
@@ -827,12 +857,54 @@ export class TrialExecutionEngine {
return { success: false, error: "No steps loaded for trial" }; return { success: false, error: "No steps loaded for trial" };
} }
const currentStep = steps[context.currentStepIndex];
if (!currentStep) {
return { success: false, error: "Invalid current step" };
}
const previousStepIndex = context.currentStepIndex; const previousStepIndex = context.currentStepIndex;
context.currentStepIndex++; let nextStepIndex = context.currentStepIndex + 1;
// Check for branching conditions
if (currentStep.conditions && currentStep.conditions.options) {
const { variable, options } = currentStep.conditions as any;
// Default to "last_wizard_response" if variable not specified, for backward compatibility
const variableName = variable || "last_wizard_response";
const variableValue = context.variables[variableName];
console.log(`[TrialExecution] Checking branch condition for step ${currentStep.id}: variable=${variableName}, value=${variableValue}`);
if (variableValue !== undefined) {
// Find matching option
// option.value matches variableValue (e.g., label string)
const matchedOption = options.find((opt: any) => opt.value === variableValue || opt.label === variableValue);
if (matchedOption) {
if (matchedOption.nextStepId) {
// Find step by ID
const targetStepIndex = steps.findIndex(s => s.id === matchedOption.nextStepId);
if (targetStepIndex !== -1) {
nextStepIndex = targetStepIndex;
console.log(`[TrialExecution] Taking branch to step ID ${matchedOption.nextStepId} (Index ${nextStepIndex})`);
} else {
console.warn(`[TrialExecution] Branch target step ID ${matchedOption.nextStepId} not found`);
}
} else if (matchedOption.nextStepIndex !== undefined) {
// Fallback to relative/absolute index if ID not present (legacy)
nextStepIndex = matchedOption.nextStepIndex;
console.log(`[TrialExecution] Taking branch to index ${nextStepIndex}`);
}
}
}
}
context.currentStepIndex = nextStepIndex;
await this.logTrialEvent(trialId, "step_transition", { await this.logTrialEvent(trialId, "step_transition", {
fromStepIndex: previousStepIndex, fromStepIndex: previousStepIndex,
toStepIndex: context.currentStepIndex, toStepIndex: context.currentStepIndex,
reason: nextStepIndex !== previousStepIndex + 1 ? "branch" : "sequence"
}); });
// Check if we've completed all steps // Check if we've completed all steps