6 Commits

71 changed files with 5554 additions and 2937 deletions

Submodule robot-plugins updated: c6310d3144...d554891dab

65
scripts/check-db.ts Normal file
View File

@@ -0,0 +1,65 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "../src/server/db/schema";
import { eq } from "drizzle-orm";
const connectionString = process.env.DATABASE_URL!;
const connection = postgres(connectionString);
const db = drizzle(connection, { schema });
async function main() {
console.log("🔍 Checking Database State...");
// 1. Check Plugin
const plugins = await db.query.plugins.findMany();
console.log(`\nFound ${plugins.length} plugins.`);
const expectedKeys = new Set<string>();
for (const p of plugins) {
const meta = p.metadata as any;
const defs = p.actionDefinitions as any[];
console.log(`Plugin [${p.name}] (ID: ${p.id}):`);
console.log(` - Robot ID (Column): ${p.robotId}`);
console.log(` - Metadata.robotId: ${meta?.robotId}`);
console.log(` - Action Definitions: ${defs?.length ?? 0} found.`);
if (defs && meta?.robotId) {
defs.forEach(d => {
const key = `${meta.robotId}.${d.id}`;
expectedKeys.add(key);
// console.log(` -> Registers: ${key}`);
});
}
}
// 2. Check Actions
const actions = await db.query.actions.findMany();
console.log(`\nFound ${actions.length} actions.`);
let errorCount = 0;
for (const a of actions) {
// Only check plugin actions
if (a.sourceKind === 'plugin' || a.type.includes(".")) {
const isRegistered = expectedKeys.has(a.type);
const pluginIdMatch = a.pluginId === 'nao6-ros2';
console.log(`Action [${a.name}] (Type: ${a.type}):`);
console.log(` - PluginId: ${a.pluginId} ${pluginIdMatch ? '✅' : '❌'}`);
console.log(` - In Registry: ${isRegistered ? '✅' : '❌'}`);
if (!isRegistered || !pluginIdMatch) errorCount++;
}
}
if (errorCount > 0) {
console.log(`\n❌ Found ${errorCount} actions with issues.`);
} else {
console.log("\n✅ All plugin actions validated successfully against registry definitions.");
}
await connection.end();
}
main().catch(console.error);

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,
@@ -159,6 +214,7 @@ async function main() {
status: "ready", status: "ready",
robotId: naoRobot!.id, robotId: naoRobot!.id,
createdBy: adminUser.id, createdBy: adminUser.id,
// visualDesign will be auto-generated by designer from DB steps
}).returning(); }).returning();
// 5. Create Steps & Actions (The Interactive Storyteller Protocol) // 5. Create Steps & Actions (The Interactive Storyteller Protocol)
@@ -168,144 +224,258 @@ async function main() {
const [step1] = await db.insert(schema.steps).values({ const [step1] = await db.insert(schema.steps).values({
experimentId: experiment!.id, experimentId: experiment!.id,
name: "The Hook", name: "The Hook",
description: "Initial greeting and engagement", description: "Initial greeting and story introduction",
type: "robot", type: "robot",
orderIndex: 0, orderIndex: 0,
required: true, required: true,
durationEstimate: 30 durationEstimate: 25
}).returning(); }).returning();
await db.insert(schema.actions).values([ await db.insert(schema.actions).values([
{ {
stepId: step1!.id, stepId: step1!.id,
name: "Greet Participant", name: "Introduce Story",
type: "nao6-ros2.say_with_emotion", type: "nao6-ros2.say_text",
orderIndex: 0, orderIndex: 0,
parameters: { text: "Hello there! I have a wonderful story to share with you today.", emotion: "happy", speed: 1.0 }, parameters: { text: "Hello. I have a story to tell you about a space traveler. Are you ready?" },
pluginId: naoPlugin!.id, pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.1.0",
category: "interaction", category: "interaction",
retryable: true retryable: true
}, },
{ {
stepId: step1!.id, stepId: step1!.id,
name: "Wave Greeting", name: "Welcome Gesture",
type: "nao6-ros2.move_arm", type: "nao6-ros2.move_arm",
orderIndex: 1, orderIndex: 1,
// Raising right arm to wave position // Open hand/welcome position
parameters: { parameters: {
arm: "right", arm: "right",
shoulder_pitch: -1.0, shoulder_pitch: 1.0,
shoulder_roll: -0.3, shoulder_roll: -0.2,
elbow_yaw: 1.5, elbow_yaw: 0.5,
elbow_roll: 0.5, elbow_roll: -0.4,
speed: 0.5 speed: 0.4
}, },
pluginId: naoPlugin!.id, pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.1.0",
category: "movement", category: "movement",
retryable: true retryable: true
} }
]); ]);
// --- Step 2: The Narrative (Part 1) --- // --- Step 2: The Narrative ---
const [step2] = await db.insert(schema.steps).values({ const [step2] = await db.insert(schema.steps).values({
experimentId: experiment!.id, experimentId: experiment!.id,
name: "The Narrative - Part 1", name: "The Narrative",
description: "Robot tells the first part of the story", description: "Robot tells the space traveler story with gaze behavior",
type: "robot", type: "robot",
orderIndex: 1, orderIndex: 1,
required: true, required: true,
durationEstimate: 60
}).returning();
await db.insert(schema.actions).values([
{
stepId: step2!.id,
name: "Tell Story Part 1",
type: "nao6-ros2.say_text",
orderIndex: 0,
parameters: { text: "Once upon a time, in a land far away, there lived a curious robot named Alpha." },
pluginId: naoPlugin!.id,
category: "interaction"
},
{
stepId: step2!.id,
name: "Look at Audience",
type: "nao6-ros2.move_head",
orderIndex: 1,
parameters: { yaw: 0.0, pitch: -0.2, speed: 0.5 },
pluginId: naoPlugin!.id,
category: "movement"
}
]);
// --- Step 3: Comprehension Check (Wizard Decision) ---
// Note: In a real visual designer, this would be a Branch/Conditional.
// Here we model it as a Wizard Step where they explicitly choose the next robot action.
const [step3] = await db.insert(schema.steps).values({
experimentId: experiment!.id,
name: "Comprehension Check",
description: "Wizard verifies participant understanding",
type: "wizard",
orderIndex: 2,
required: true,
durationEstimate: 45 durationEstimate: 45
}).returning(); }).returning();
await db.insert(schema.actions).values([ await db.insert(schema.actions).values([
{ {
stepId: step3!.id, stepId: step2!.id,
name: "Ask Question", name: "Tell Story",
type: "nao6-ros2.say_with_emotion", type: "nao6-ros2.say_text",
orderIndex: 0, orderIndex: 0,
parameters: { text: "Did you understand the story so far?", emotion: "happy", speed: 1.0 }, parameters: { text: "The traveler flew to Mars. He found a red rock that glowed in the dark. He put it in his pocket." },
pluginId: naoPlugin!.id, pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
category: "interaction" pluginVersion: "2.1.0",
category: "interaction",
retryable: true
}, },
{ {
stepId: step3!.id, stepId: step2!.id,
name: "Wait for Wizard Input", name: "Look Away (Thinking)",
type: "wizard_wait_for_response", type: "nao6-ros2.turn_head",
orderIndex: 1, orderIndex: 1,
parameters: { parameters: { yaw: 1.5, pitch: 0.0, speed: 0.3 },
prompt_text: "Did participant answer 'Alpha'?", pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
response_type: "verbal", pluginVersion: "2.1.0",
timeout: 60 category: "movement",
retryable: true
}, },
sourceKind: "core", {
category: "wizard" stepId: step2!.id,
name: "Look Back at Participant",
type: "nao6-ros2.turn_head",
orderIndex: 2,
parameters: { yaw: 0.0, pitch: -0.1, speed: 0.4 },
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.1.0",
category: "movement",
retryable: true
} }
]); ]);
// --- Step 4: Feedback (Positive/Negative branches implied) --- // --- Step 3: Comprehension Check (Wizard Decision Point) ---
// For linear seed, we just add the Positive feedback step // Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect)
const [step4] = await db.insert(schema.steps).values({ // --- 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, experimentId: experiment!.id,
name: "Positive Feedback", name: "Branch A: Correct Response",
description: "Correct answer response", description: "Response when participant says 'Red'",
type: "robot", type: "robot",
orderIndex: 3, orderIndex: 3,
required: false,
durationEstimate: 20
}).returning();
// --- Step 4b: Incorrect Response Branch ---
const [step4b] = await db.insert(schema.steps).values({
experimentId: experiment!.id,
name: "Branch B: Incorrect Response",
description: "Response when participant gives wrong answer",
type: "robot",
orderIndex: 4,
required: false,
durationEstimate: 20
}).returning();
// --- Step 3: Comprehension Check (Wizard Decision Point) ---
// Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect)
const [step3] = await db.insert(schema.steps).values({
experimentId: experiment!.id,
name: "Comprehension Check",
description: "Ask participant about rock color and wait for wizard input",
type: "conditional",
orderIndex: 2,
required: true, required: true,
durationEstimate: 15 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([
{ {
stepId: step4!.id, stepId: step3!.id,
name: "Express Agreement", name: "Ask Question",
type: "nao6-ros2.say_with_emotion", type: "nao6-ros2.say_text",
orderIndex: 0, orderIndex: 0,
parameters: { text: "Yes, exactly!", emotion: "happy", speed: 1.0 }, parameters: { text: "What color was the rock the traveler found?" },
pluginId: naoPlugin!.id, pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
category: "interaction" pluginVersion: "2.1.0",
category: "interaction",
retryable: true
}, },
{ {
stepId: step4!.id, stepId: step3!.id,
name: "Say Correct", name: "Wait for Choice",
type: "nao6-ros2.say_text", type: "wizard_wait_for_response",
orderIndex: 1, orderIndex: 1,
parameters: { text: "That is correct! Well done." }, // Define the options that will be presented to the Wizard
pluginId: naoPlugin!.id, parameters: {
category: "interaction" prompt_text: "Did participant answer 'Red' correctly?",
options: ["Correct", "Incorrect"]
},
sourceKind: "core",
pluginId: "hristudio-woz", // Explicit link
category: "wizard"
},
{
stepId: step3!.id,
name: "Branch Decision",
type: "branch",
orderIndex: 2,
parameters: {},
sourceKind: "core",
pluginId: "hristudio-core", // Explicit link
category: "control"
}
]);
await db.insert(schema.actions).values([
{
stepId: step4a!.id,
name: "Confirm Correct Answer",
type: "nao6-ros2.say_with_emotion",
orderIndex: 0,
parameters: { text: "Yes! It was a glowing red rock.", emotion: "happy", speed: 1.0 },
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.1.0",
category: "interaction",
retryable: true
},
{
stepId: step4a!.id,
name: "Nod Head",
type: "nao6-ros2.turn_head",
orderIndex: 1,
parameters: { yaw: 0.0, pitch: -0.3, speed: 0.5 },
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.1.0",
category: "movement",
retryable: true
},
{
stepId: step4a!.id,
name: "Return to Neutral",
type: "nao6-ros2.turn_head",
orderIndex: 2,
parameters: { yaw: 0.0, pitch: 0.0, speed: 0.4 },
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.1.0",
category: "movement",
retryable: true
}
]);
await db.insert(schema.actions).values([
{
stepId: step4b!.id,
name: "Correct Participant",
type: "nao6-ros2.say_text",
orderIndex: 0,
parameters: { text: "Actually, it was red." },
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.1.0",
category: "interaction",
retryable: true
},
{
stepId: step4b!.id,
name: "Shake Head (Left)",
type: "nao6-ros2.turn_head",
orderIndex: 1,
parameters: { yaw: -0.5, pitch: 0.0, speed: 0.5 },
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.1.0",
category: "movement",
retryable: true
},
{
stepId: step4b!.id,
name: "Shake Head (Right)",
type: "nao6-ros2.turn_head",
orderIndex: 2,
parameters: { yaw: 0.5, pitch: 0.0, speed: 0.5 },
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.1.0",
category: "movement",
retryable: true
},
{
stepId: step4b!.id,
name: "Return to Center",
type: "nao6-ros2.turn_head",
orderIndex: 3,
parameters: { yaw: 0.0, pitch: 0.0, speed: 0.4 },
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.1.0",
category: "movement",
retryable: true
} }
]); ]);
@@ -313,31 +483,42 @@ async function main() {
const [step5] = await db.insert(schema.steps).values({ const [step5] = await db.insert(schema.steps).values({
experimentId: experiment!.id, experimentId: experiment!.id,
name: "Conclusion", name: "Conclusion",
description: "Wrap up the story", description: "End the story and thank participant",
type: "robot", type: "robot",
orderIndex: 4, orderIndex: 5,
required: true, required: true,
durationEstimate: 30 durationEstimate: 25
}).returning(); }).returning();
await db.insert(schema.actions).values([ await db.insert(schema.actions).values([
{ {
stepId: step5!.id, stepId: step5!.id,
name: "Finish Story", name: "End Story",
type: "nao6-ros2.say_text", type: "nao6-ros2.say_text",
orderIndex: 0, orderIndex: 0,
parameters: { text: "Alpha explored the world and learned many things. The end." }, parameters: { text: "The End. Thank you for listening." },
pluginId: naoPlugin!.id, pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
category: "interaction" pluginVersion: "2.1.0",
category: "interaction",
retryable: true
}, },
{ {
stepId: step5!.id, stepId: step5!.id,
name: "Say Goodbye", name: "Bow Gesture",
type: "nao6-ros2.say_with_emotion", type: "nao6-ros2.move_arm",
orderIndex: 1, orderIndex: 1,
parameters: { text: "Goodbye everyone!", emotion: "happy", speed: 1.0 }, parameters: {
pluginId: naoPlugin!.id, arm: "right",
category: "interaction" shoulder_pitch: 1.8,
shoulder_roll: 0.1,
elbow_yaw: 0.0,
elbow_roll: -0.3,
speed: 0.3
},
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
pluginVersion: "2.1.0",
category: "movement",
retryable: true
} }
]); ]);
@@ -360,7 +541,13 @@ async function main() {
console.log(`Summary:`); console.log(`Summary:`);
console.log(`- 1 Admin User (sean@soconnor.dev)`); console.log(`- 1 Admin User (sean@soconnor.dev)`);
console.log(`- Study: 'Comparative WoZ Study'`); console.log(`- Study: 'Comparative WoZ Study'`);
console.log(`- Experiment: 'The Interactive Storyteller' (${5} steps created)`); console.log(`- Experiment: 'The Interactive Storyteller' (6 steps created)`);
console.log(` - Step 1: The Hook (greeting + welcome gesture)`);
console.log(` - Step 2: The Narrative (story + gaze sequence)`);
console.log(` - Step 3: Comprehension Check (question + wizard wait)`);
console.log(` - Step 4a: Branch A - Correct Response (affirmation + nod)`);
console.log(` - Step 4b: Branch B - Incorrect Response (correction + head shake)`);
console.log(` - Step 5: Conclusion (ending + bow)`);
console.log(`- ${insertedParticipants.length} Participants`); console.log(`- ${insertedParticipants.length} Participants`);
} catch (error) { } catch (error) {

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

@@ -43,7 +43,7 @@ function ProfileContent({ user }: { user: ProfileUser }) {
{/* Profile Information */} {/* Profile Information */}
<div className="space-y-6 lg:col-span-2"> <div className="space-y-6 lg:col-span-2">
{/* Basic Information */} {/* Basic Information */}
<Card> <Card className="hover:shadow-md transition-shadow duration-200">
<CardHeader> <CardHeader>
<CardTitle>Basic Information</CardTitle> <CardTitle>Basic Information</CardTitle>
<CardDescription> <CardDescription>
@@ -63,7 +63,7 @@ function ProfileContent({ user }: { user: ProfileUser }) {
</Card> </Card>
{/* Password Change */} {/* Password Change */}
<Card> <Card className="hover:shadow-md transition-shadow duration-200">
<CardHeader> <CardHeader>
<CardTitle>Password</CardTitle> <CardTitle>Password</CardTitle>
<CardDescription>Change your account password</CardDescription> <CardDescription>Change your account password</CardDescription>
@@ -116,7 +116,7 @@ function ProfileContent({ user }: { user: ProfileUser }) {
{/* Sidebar */} {/* Sidebar */}
<div className="space-y-6"> <div className="space-y-6">
{/* User Summary */} {/* User Summary */}
<Card> <Card className="hover:shadow-md transition-shadow duration-200">
<CardHeader> <CardHeader>
<CardTitle>Account Summary</CardTitle> <CardTitle>Account Summary</CardTitle>
</CardHeader> </CardHeader>

View File

@@ -1,190 +1,15 @@
"use client"; "use client";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect } from "react";
import { import { BarChart3 } from "lucide-react";
BarChart3,
Search,
Filter,
PlayCircle,
Calendar,
Clock,
ChevronRight,
User,
LayoutGrid
} from "lucide-react";
import { PageHeader } from "~/components/ui/page-header"; import { PageHeader } from "~/components/ui/page-header";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useStudyContext } from "~/lib/study-context"; import { useStudyContext } from "~/lib/study-context";
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails"; import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { TrialAnalysisView } from "~/components/trials/views/TrialAnalysisView"; import { StudyAnalyticsDataTable } from "~/components/analytics/study-analytics-data-table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { ScrollArea } from "~/components/ui/scroll-area";
import { formatDistanceToNow } from "date-fns";
// -- Sub-Components --
function AnalyticsContent({
selectedTrialId,
setSelectedTrialId,
trialsList,
isLoadingList
}: {
selectedTrialId: string | null;
setSelectedTrialId: (id: string | null) => void;
trialsList: any[];
isLoadingList: boolean;
}) {
// Fetch full details of selected trial
const {
data: selectedTrial,
isLoading: isLoadingTrial,
error: trialError
} = api.trials.get.useQuery(
{ id: selectedTrialId! },
{ enabled: !!selectedTrialId }
);
// Transform trial data
const trialData = selectedTrial ? {
...selectedTrial,
startedAt: selectedTrial.startedAt ? new Date(selectedTrial.startedAt) : null,
completedAt: selectedTrial.completedAt ? new Date(selectedTrial.completedAt) : null,
eventCount: (selectedTrial as any).eventCount,
mediaCount: (selectedTrial as any).mediaCount,
} : null;
return (
<div className="h-[calc(100vh-140px)] flex flex-col">
{selectedTrialId ? (
isLoadingTrial ? (
<div className="flex-1 flex items-center justify-center bg-background/50 rounded-lg border border-dashed">
<div className="flex flex-col items-center gap-2 animate-pulse">
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
<span className="text-muted-foreground text-sm">Loading trial data...</span>
</div>
</div>
) : trialError ? (
<div className="flex-1 flex items-center justify-center p-8 bg-background/50 rounded-lg border border-dashed text-destructive">
<div className="max-w-md text-center">
<h3 className="font-semibold mb-2">Error Loading Trial</h3>
<p className="text-sm opacity-80">{trialError.message}</p>
<Button variant="outline" className="mt-4" onClick={() => setSelectedTrialId(null)}>
Return to Overview
</Button>
</div>
</div>
) : trialData ? (
<TrialAnalysisView trial={trialData} />
) : null
) : (
<div className="flex-1 bg-background/50 rounded-lg border shadow-sm overflow-hidden">
<StudyOverviewPlaceholder
trials={trialsList ?? []}
onSelect={(id) => setSelectedTrialId(id)}
/>
</div>
)}
</div>
);
}
function StudyOverviewPlaceholder({ trials, onSelect }: { trials: any[], onSelect: (id: string) => void }) {
const recentTrials = [...trials].sort((a, b) =>
new Date(b.startedAt || b.createdAt).getTime() - new Date(a.startedAt || a.createdAt).getTime()
).slice(0, 5);
return (
<div className="h-full p-8 grid place-items-center bg-muted/5">
<div className="max-w-3xl w-full grid gap-8 md:grid-cols-2">
{/* Left: Illustration / Prompt */}
<div className="flex flex-col justify-center space-y-4">
<div className="bg-primary/10 w-16 h-16 rounded-2xl flex items-center justify-center mb-2">
<BarChart3 className="h-8 w-8 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold tracking-tight">Analytics & Playback</h2>
<CardDescription className="text-base mt-2">
Select a session from the top right to review video recordings, event logs, and metrics.
</CardDescription>
</div>
<div className="flex gap-4 pt-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<PlayCircle className="h-4 w-4" />
Feature-rich playback
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
Synchronized timeline
</div>
</div>
</div>
{/* Right: Recent Sessions */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Recent Sessions</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ScrollArea className="h-[240px]">
<div className="px-4 pb-4 space-y-1">
{recentTrials.map(trial => (
<button
key={trial.id}
onClick={() => onSelect(trial.id)}
className="w-full flex items-center gap-3 p-3 rounded-md hover:bg-accent transition-colors text-left group"
>
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-mono font-medium text-primary">
{trial.sessionNumber}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">
{trial.participant?.participantCode ?? "Unknown"}
</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full border capitalize ${trial.status === 'completed' ? 'bg-green-500/10 text-green-500 border-green-500/20' :
trial.status === 'in_progress' ? 'bg-blue-500/10 text-blue-500 border-blue-500/20' :
'bg-slate-500/10 text-slate-500 border-slate-500/20'
}`}>
{trial.status.replace('_', ' ')}
</span>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-2 mt-0.5">
<Calendar className="h-3 w-3" />
{new Date(trial.createdAt).toLocaleDateString()}
<span className="text-muted-foreground top-[1px] relative text-[10px]"></span>
{formatDistanceToNow(new Date(trial.createdAt), { addSuffix: true })}
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground/30 group-hover:text-primary transition-colors" />
</button>
))}
{recentTrials.length === 0 && (
<div className="py-8 text-center text-sm text-muted-foreground">
No sessions found.
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
)
}
// -- Main Page --
export default function StudyAnalyticsPage() { export default function StudyAnalyticsPage() {
const params = useParams(); const params = useParams();
@@ -192,11 +17,8 @@ export default function StudyAnalyticsPage() {
const { setSelectedStudyId, selectedStudyId } = useStudyContext(); const { setSelectedStudyId, selectedStudyId } = useStudyContext();
const { study } = useSelectedStudyDetails(); const { study } = useSelectedStudyDetails();
// State lifted up // Fetch list of trials
const [selectedTrialId, setSelectedTrialId] = useState<string | null>(null); const { data: trialsList, isLoading } = api.trials.list.useQuery(
// Fetch list of trials for the selector
const { data: trialsList, isLoading: isLoadingList } = api.trials.list.useQuery(
{ studyId, limit: 100 }, { studyId, limit: 100 },
{ enabled: !!studyId } { enabled: !!studyId }
); );
@@ -217,50 +39,30 @@ export default function StudyAnalyticsPage() {
}, [studyId, selectedStudyId, setSelectedStudyId]); }, [studyId, selectedStudyId, setSelectedStudyId]);
return ( return (
<div className="h-[calc(100vh-64px)] flex flex-col p-6 gap-6"> <div className="space-y-6">
<div className="flex-none">
<PageHeader <PageHeader
title="Analytics" title="Analysis"
description="Analyze trial data and replay sessions" description="View and analyze session data across all trials"
icon={BarChart3} icon={BarChart3}
actions={
<div className="flex items-center gap-2">
{/* Session Selector in Header */}
<div className="w-[300px]">
<Select
value={selectedTrialId ?? "overview"}
onValueChange={(val) => setSelectedTrialId(val === "overview" ? null : val)}
>
<SelectTrigger className="w-full h-9 text-xs">
<LayoutGrid className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
<SelectValue placeholder="Select Session" />
</SelectTrigger>
<SelectContent className="max-h-[400px]" align="end">
<SelectItem value="overview" className="border-b mb-1 pb-1 font-medium text-xs">
Show Study Overview
</SelectItem>
{trialsList?.map((trial) => (
<SelectItem key={trial.id} value={trial.id} className="text-xs">
<span className="font-mono mr-2 text-muted-foreground">#{trial.sessionNumber}</span>
{trial.participant?.participantCode ?? "Unknown"} <span className="text-muted-foreground ml-1">({new Date(trial.createdAt).toLocaleDateString()})</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
}
/> />
</div>
<div className="flex-1 min-h-0 bg-transparent"> <div className="bg-transparent">
<Suspense fallback={<div>Loading analytics...</div>}> <Suspense fallback={<div>Loading analytics...</div>}>
<AnalyticsContent {isLoading ? (
selectedTrialId={selectedTrialId} <div className="flex items-center justify-center h-64">
setSelectedTrialId={setSelectedTrialId} <div className="flex flex-col items-center gap-2 animate-pulse">
trialsList={trialsList ?? []} <div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
isLoadingList={isLoadingList} <span className="text-muted-foreground text-sm">Loading session data...</span>
/> </div>
</div>
) : (
<StudyAnalyticsDataTable data={(trialsList ?? []).map(t => ({
...t,
startedAt: t.startedAt ? new Date(t.startedAt) : null,
completedAt: t.completedAt ? new Date(t.completedAt) : null,
createdAt: new Date(t.createdAt),
}))} />
)}
</Suspense> </Suspense>
</div> </div>
</div> </div>

View File

@@ -55,7 +55,5 @@ export function DesignerPageClient({
}, },
]); ]);
return ( return <DesignerRoot experimentId={experiment.id} initialDesign={initialDesign} />;
<DesignerRoot experimentId={experiment.id} initialDesign={initialDesign} />
);
} }

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

@@ -13,6 +13,8 @@ import { Button } from "~/components/ui/button";
import { Edit } from "lucide-react"; import { Edit } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { ParticipantConsentManager } from "~/components/participants/ParticipantConsentManager";
interface ParticipantDetailPageProps { interface ParticipantDetailPageProps {
params: Promise<{ id: string; participantId: string }>; params: Promise<{ id: string; participantId: string }>;
} }
@@ -61,6 +63,13 @@ export default async function ParticipantDetailPage({
<TabsContent value="overview"> <TabsContent value="overview">
<div className="grid gap-6 grid-cols-1"> <div className="grid gap-6 grid-cols-1">
<ParticipantConsentManager
studyId={studyId}
participantId={participantId}
consentGiven={participant.consentGiven}
consentDate={participant.consentDate}
existingConsent={participant.consents[0] ?? null}
/>
<EntityViewSection title="Participant Information" icon="Info"> <EntityViewSection title="Participant Information" icon="Info">
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4"> <div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
<div> <div>

View File

@@ -95,25 +95,10 @@ function AnalysisPageContent() {
}; };
return ( return (
<div className="flex h-full flex-col"> <TrialAnalysisView
<PageHeader trial={trialData}
title="Trial Analysis" backHref={`/studies/${studyId}/trials/${trialId}`}
description={`Analysis for Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
icon={LineChart}
actions={
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/trials/${trialId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trial Details
</Link>
</Button>
}
/> />
<div className="min-h-0 flex-1">
<TrialAnalysisView trial={trialData} />
</div>
</div>
); );
} }

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

@@ -185,7 +185,7 @@ export default function DashboardPage() {
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{scheduledTrials.map((trial) => ( {scheduledTrials.map((trial) => (
<div key={trial.id} className="flex items-center justify-between rounded-lg border p-3 hover:bg-muted/50 transition-colors"> <div key={trial.id} className="flex items-center justify-between rounded-lg border p-3 bg-muted/10 hover:bg-muted/50 hover:shadow-sm transition-all duration-200">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
<Calendar className="h-5 w-5" /> <Calendar className="h-5 w-5" />
@@ -302,7 +302,7 @@ function StatsCard({
trend?: string; trend?: string;
}) { }) {
return ( return (
<Card className="border-muted/40 shadow-sm"> <Card className="border-muted/40 shadow-sm hover:shadow-md transition-all duration-200 hover:border-primary/20">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle> <CardTitle className="text-sm font-medium">{title}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" /> <Icon className="h-4 w-4 text-muted-foreground" />

View File

@@ -0,0 +1,319 @@
"use client";
import {
type ColumnDef,
type ColumnFiltersState,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { useState } from "react";
import {
ArrowUpDown,
MoreHorizontal,
Calendar,
Clock,
Activity,
Eye,
Video
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Badge } from "~/components/ui/badge";
import Link from "next/link";
import { formatDistanceToNow } from "date-fns";
export type AnalyticsTrial = {
id: string;
sessionNumber: number;
status: string;
createdAt: Date;
startedAt: Date | null;
completedAt: Date | null;
duration: number | null;
eventCount: number;
mediaCount: number;
experimentId: string;
participant: {
participantCode: string;
};
experiment: {
name: string;
studyId: string;
};
};
export const columns: ColumnDef<AnalyticsTrial>[] = [
{
accessorKey: "sessionNumber",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Session
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => <div className="font-mono text-center">#{row.getValue("sessionNumber")}</div>,
},
{
accessorKey: "participant.participantCode",
header: "Participant",
cell: ({ row }) => (
<div className="font-medium">{row.original.participant?.participantCode ?? "Unknown"}</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.getValue("status") as string;
return (
<Badge
variant="outline"
className={`capitalize ${status === "completed"
? "bg-green-500/10 text-green-500 border-green-500/20"
: status === "in_progress"
? "bg-blue-500/10 text-blue-500 border-blue-500/20"
: "bg-slate-500/10 text-slate-500 border-slate-500/20"
}`}
>
{status.replace("_", " ")}
</Badge>
);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Date
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const date = new Date(row.getValue("createdAt"));
return (
<div className="flex flex-col">
<span className="text-sm">{date.toLocaleDateString()}</span>
<span className="text-xs text-muted-foreground">{formatDistanceToNow(date, { addSuffix: true })}</span>
</div>
)
},
},
{
accessorKey: "duration",
header: "Duration",
cell: ({ row }) => {
const duration = row.getValue("duration") as number | null;
if (!duration) return <span className="text-muted-foreground">-</span>;
const m = Math.floor(duration / 60);
const s = Math.floor(duration % 60);
return <div className="font-mono">{`${m}m ${s}s`}</div>;
},
},
{
accessorKey: "eventCount",
header: "Events",
cell: ({ row }) => {
return (
<div className="flex items-center gap-1">
<Activity className="h-3 w-3 text-muted-foreground" />
<span>{row.getValue("eventCount")}</span>
</div>
)
},
},
{
accessorKey: "mediaCount",
header: "Media",
cell: ({ row }) => {
const count = row.getValue("mediaCount") as number;
if (count === 0) return <span className="text-muted-foreground">-</span>;
return (
<div className="flex items-center gap-1">
<Video className="h-3 w-3 text-muted-foreground" />
<span>{count}</span>
</div>
)
},
},
{
id: "actions",
cell: ({ row }) => {
const trial = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link href={`/studies/${trial.experiment?.studyId}/trials/${trial.id}/analysis`}>
<Eye className="mr-2 h-4 w-4" />
View Analysis
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/experiments/${trial.experimentId}/trials/${trial.id}`}>
View Trial Details
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
interface StudyAnalyticsDataTableProps {
data: AnalyticsTrial[];
}
export function StudyAnalyticsDataTable({ data }: StudyAnalyticsDataTableProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return (
<div className="w-full">
<div className="flex items-center py-4">
<Input
placeholder="Filter participants..."
value={(table.getColumn("participant.participantCode")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("participant.participantCode")?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
</div>
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { type ColumnDef } from "@tanstack/react-table"; import { type ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, LayoutTemplate, PlayCircle, Archive } from "lucide-react"; import { ArrowUpDown, MoreHorizontal, Edit, LayoutTemplate, Trash2 } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
@@ -243,8 +243,17 @@ export const columns: ColumnDef<Experiment>[] = [
{ {
id: "actions", id: "actions",
enableHiding: false, enableHiding: false,
cell: ({ row }) => { cell: ({ row }) => <ExperimentActions experiment={row.original} />,
const experiment = row.original; },
];
function ExperimentActions({ experiment }: { experiment: Experiment }) {
const utils = api.useUtils();
const deleteMutation = api.experiments.delete.useMutation({
onSuccess: () => {
utils.experiments.list.invalidate();
},
});
return ( return (
<DropdownMenu> <DropdownMenu>
@@ -256,51 +265,34 @@ export const columns: ColumnDef<Experiment>[] = [
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(experiment.id)}
>
<Copy className="mr-2 h-4 w-4" />
Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>
<Eye className="mr-2 h-4 w-4" />
Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}> <Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Edit Edit Metadata
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}> <Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
<LayoutTemplate className="mr-2 h-4 w-4" /> <LayoutTemplate className="mr-2 h-4 w-4" />
Designer Design
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem
<Link className="text-red-600 focus:text-red-700"
href={`/studies/${experiment.studyId}/trials/new?experimentId=${experiment.id}`} onClick={() => {
if (confirm("Are you sure you want to delete this experiment?")) {
deleteMutation.mutate({ id: experiment.id });
}
}}
> >
<PlayCircle className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Start Trial Delete
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600">
<Archive className="mr-2 h-4 w-4" />
Archive
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
}, }
},
];
export function ExperimentsTable() { export function ExperimentsTable() {
const { selectedStudyId } = useStudyContext(); const { selectedStudyId } = useStudyContext();

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,281 +47,27 @@ 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 ---------------- */
loadPluginActions( loadPluginActions(
studyId: string, studyId: string,
studyPlugins: Array<{ studyPlugins: any[],
plugin: {
id: string;
robotId: string | null;
version: string | null;
actionDefinitions?: Array<{
id: string;
name: string;
description?: string;
category?: string;
icon?: string;
timeout?: number;
retryable?: boolean;
aliases?: string[];
parameterSchema?: unknown;
ros2?: {
topic?: string;
messageType?: string;
service?: string;
action?: string;
payloadMapping?: unknown;
qos?: {
reliability?: string;
durability?: string;
history?: string;
depth?: number;
};
};
rest?: {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
path: string;
headers?: Record<string, string>;
};
}>;
metadata?: Record<string, any>;
};
}>,
): void { ): void {
// console.log("ActionRegistry.loadPluginActions called with:", { studyId, pluginCount: studyPlugins?.length ?? 0 });
if (this.pluginActionsLoaded && this.loadedStudyId === studyId) return; if (this.pluginActionsLoaded && this.loadedStudyId === studyId) return;
if (this.loadedStudyId !== studyId) { if (this.loadedStudyId !== studyId) {
@@ -332,17 +76,30 @@ export class ActionRegistry {
let totalActionsLoaded = 0; let totalActionsLoaded = 0;
(studyPlugins ?? []).forEach((studyPlugin) => { (studyPlugins ?? []).forEach((plugin) => {
const { plugin } = studyPlugin; 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;
// console.log(`Plugin ${plugin.id}:`, { actionCount: actionDefs?.length ?? 0 });
if (!actionDefs) return; if (!actionDefs) return;
actionDefs.forEach((action) => { actionDefs.forEach((action: any) => {
const rawCategory = const rawCategory =
typeof action.category === "string" typeof action.category === "string"
? action.category.toLowerCase().trim() ? action.category.toLowerCase().trim()
@@ -353,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
? { ? {
@@ -386,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()) {
@@ -423,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(
@@ -458,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";
@@ -466,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 {
@@ -485,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

@@ -8,7 +8,17 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Play, RefreshCw, HelpCircle } from "lucide-react"; import {
Play,
RefreshCw,
HelpCircle,
PanelLeftClose,
PanelLeftOpen,
PanelRightClose,
PanelRightOpen,
Maximize2,
Minimize2
} from "lucide-react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { PageHeader } from "~/components/ui/page-header"; import { PageHeader } from "~/components/ui/page-header";
@@ -27,7 +37,7 @@ import {
MouseSensor, MouseSensor,
TouchSensor, TouchSensor,
KeyboardSensor, KeyboardSensor,
closestCorners, closestCenter,
type DragEndEvent, type DragEndEvent,
type DragStartEvent, type DragStartEvent,
type DragOverEvent, type DragOverEvent,
@@ -35,7 +45,8 @@ import {
import { BottomStatusBar } from "./layout/BottomStatusBar"; import { BottomStatusBar } from "./layout/BottomStatusBar";
import { ActionLibraryPanel } from "./panels/ActionLibraryPanel"; import { ActionLibraryPanel } from "./panels/ActionLibraryPanel";
import { InspectorPanel } from "./panels/InspectorPanel"; import { InspectorPanel } from "./panels/InspectorPanel";
import { FlowWorkspace } from "./flow/FlowWorkspace"; import { FlowWorkspace, SortableActionChip, StepCardPreview } from "./flow/FlowWorkspace";
import { GripVertical } from "lucide-react";
import { import {
type ExperimentDesign, type ExperimentDesign,
@@ -44,12 +55,13 @@ import {
} from "~/lib/experiment-designer/types"; } from "~/lib/experiment-designer/types";
import { useDesignerStore } from "./state/store"; import { useDesignerStore } from "./state/store";
import { actionRegistry } from "./ActionRegistry"; import { actionRegistry, useActionRegistry } from "./ActionRegistry";
import { computeDesignHash } from "./state/hashing"; import { computeDesignHash } from "./state/hashing";
import { import {
validateExperimentDesign, validateExperimentDesign,
groupIssuesByEntity, groupIssuesByEntity,
} from "./state/validators"; } from "./state/validators";
import { convertDatabaseToSteps } from "~/lib/experiment-designer/block-converter";
/** /**
* DesignerRoot * DesignerRoot
@@ -94,6 +106,7 @@ interface RawExperiment {
integrityHash?: string | null; integrityHash?: string | null;
pluginDependencies?: string[] | null; pluginDependencies?: string[] | null;
visualDesign?: unknown; visualDesign?: unknown;
steps?: unknown[]; // DB steps from relation
} }
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@@ -101,6 +114,37 @@ 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
// plugin provenance data (which might be missing from stale visualDesign snapshots).
// 1. Prefer database steps (Source of Truth) if valid.
if (Array.isArray(exp.steps) && exp.steps.length > 0) {
try {
// Check if steps are already converted (have trigger property) to avoid double-conversion data loss
const firstStep = exp.steps[0] as any;
let dbSteps: ExperimentStep[];
if (firstStep && typeof firstStep === 'object' && 'trigger' in firstStep) {
// Already converted by server
dbSteps = exp.steps as ExperimentStep[];
} else {
// Raw DB steps, need conversion
dbSteps = convertDatabaseToSteps(exp.steps);
}
return {
id: exp.id,
name: exp.name,
description: exp.description ?? "",
steps: dbSteps,
version: 1, // Reset version on re-hydration
lastSaved: new Date(),
};
} catch (err) {
console.warn('[DesignerRoot] Failed to convert/hydrate steps, falling back to visualDesign:', err);
}
}
// 2. Fallback to visualDesign blob if DB steps unavailable or conversion failed
if ( if (
!exp.visualDesign || !exp.visualDesign ||
typeof exp.visualDesign !== "object" || typeof exp.visualDesign !== "object" ||
@@ -114,6 +158,7 @@ function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
lastSaved?: string; lastSaved?: string;
}; };
if (!Array.isArray(vd.steps)) return undefined; if (!Array.isArray(vd.steps)) return undefined;
return { return {
id: exp.id, id: exp.id,
name: exp.name, name: exp.name,
@@ -152,6 +197,9 @@ export function DesignerRoot({
autoCompile = true, autoCompile = true,
onPersist, onPersist,
}: DesignerRootProps) { }: DesignerRootProps) {
// Subscribe to registry updates to ensure re-renders when actions load
useActionRegistry();
const { startTour } = useTour(); const { startTour } = useTour();
/* ----------------------------- Remote Experiment ------------------------- */ /* ----------------------------- Remote Experiment ------------------------- */
@@ -159,7 +207,18 @@ export function DesignerRoot({
data: experiment, data: experiment,
isLoading: loadingExperiment, isLoading: loadingExperiment,
refetch: refetchExperiment, refetch: refetchExperiment,
} = api.experiments.get.useQuery({ id: experimentId }); } = api.experiments.get.useQuery(
{ id: experimentId },
{
// Debug Mode: Disable all caching to ensure fresh data from DB
refetchOnMount: true,
refetchOnWindowFocus: true,
staleTime: 0,
gcTime: 0, // Garbage collect immediately
}
);
const updateExperiment = api.experiments.update.useMutation({ const updateExperiment = api.experiments.update.useMutation({
onError: (err) => { onError: (err) => {
@@ -199,6 +258,7 @@ export function DesignerRoot({
const upsertAction = useDesignerStore((s) => s.upsertAction); const upsertAction = useDesignerStore((s) => s.upsertAction);
const selectStep = useDesignerStore((s) => s.selectStep); const selectStep = useDesignerStore((s) => s.selectStep);
const selectAction = useDesignerStore((s) => s.selectAction); const selectAction = useDesignerStore((s) => s.selectAction);
const reorderStep = useDesignerStore((s) => s.reorderStep);
const setValidationIssues = useDesignerStore((s) => s.setValidationIssues); const setValidationIssues = useDesignerStore((s) => s.setValidationIssues);
const clearAllValidationIssues = useDesignerStore( const clearAllValidationIssues = useDesignerStore(
(s) => s.clearAllValidationIssues, (s) => s.clearAllValidationIssues,
@@ -258,6 +318,23 @@ export function DesignerRoot({
const [inspectorTab, setInspectorTab] = useState< const [inspectorTab, setInspectorTab] = useState<
"properties" | "issues" | "dependencies" "properties" | "issues" | "dependencies"
>("properties"); >("properties");
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
// Responsive initialization: Collapse left sidebar on smaller screens (<1280px)
useEffect(() => {
const checkWidth = () => {
if (window.innerWidth < 1280) {
setLeftCollapsed(true);
}
};
// Check once on mount
checkWidth();
// Optional: Add resize listener if we want live responsiveness
// window.addEventListener('resize', checkWidth);
// return () => window.removeEventListener('resize', checkWidth);
}, []);
/** /**
* Active action being dragged from the Action Library (for DragOverlay rendering). * Active action being dragged from the Action Library (for DragOverlay rendering).
* Captures a lightweight subset for visual feedback. * Captures a lightweight subset for visual feedback.
@@ -269,6 +346,11 @@ export function DesignerRoot({
description?: string; description?: string;
} | null>(null); } | null>(null);
const [activeSortableItem, setActiveSortableItem] = useState<{
type: 'step' | 'action';
data: any;
} | null>(null);
/* ----------------------------- Initialization ---------------------------- */ /* ----------------------------- Initialization ---------------------------- */
useEffect(() => { useEffect(() => {
if (initialized) return; if (initialized) return;
@@ -327,13 +409,14 @@ export function DesignerRoot({
.catch((err) => console.error("Core action load failed:", err)); .catch((err) => console.error("Core action load failed:", err));
}, []); }, []);
// Load plugin actions when study plugins available // Load plugin actions only after we have the flattened, processed plugin list
useEffect(() => { useEffect(() => {
if (!experiment?.studyId) return; if (!experiment?.studyId) return;
if (!studyPluginsRaw) return; if (!studyPlugins) return;
// @ts-expect-error - studyPluginsRaw type from tRPC is compatible but TypeScript can't infer it
actionRegistry.loadPluginActions(experiment.studyId, studyPluginsRaw); // Pass the flattened plugins which match the structure ActionRegistry expects
}, [experiment?.studyId, studyPluginsRaw]); actionRegistry.loadPluginActions(experiment.studyId, studyPlugins);
}, [experiment?.studyId, studyPlugins]);
/* ------------------------- Ready State Management ------------------------ */ /* ------------------------- Ready State Management ------------------------ */
// Mark as ready once initialized and plugins are loaded // Mark as ready once initialized and plugins are loaded
@@ -348,11 +431,10 @@ export function DesignerRoot({
// Small delay to ensure all components have rendered // Small delay to ensure all components have rendered
const timer = setTimeout(() => { const timer = setTimeout(() => {
setIsReady(true); setIsReady(true);
// console.log('[DesignerRoot] ✅ Designer ready (plugins loaded), fading in');
}, 150); }, 150);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [initialized, isReady, studyPluginsRaw]); }, [initialized, isReady, studyPlugins]);
/* ----------------------- Automatic Hash Recomputation -------------------- */ /* ----------------------- Automatic Hash Recomputation -------------------- */
// Automatically recompute hash when steps change (debounced to avoid excessive computation) // Automatically recompute hash when steps change (debounced to avoid excessive computation)
@@ -415,6 +497,7 @@ export function DesignerRoot({
const currentSteps = [...steps]; const currentSteps = [...steps];
// Ensure core actions are loaded before validating // Ensure core actions are loaded before validating
await actionRegistry.loadCoreActions(); await actionRegistry.loadCoreActions();
const result = validateExperimentDesign(currentSteps, { const result = validateExperimentDesign(currentSteps, {
steps: currentSteps, steps: currentSteps,
actionDefinitions: actionRegistry.getAllActions(), actionDefinitions: actionRegistry.getAllActions(),
@@ -482,6 +565,15 @@ export function DesignerRoot({
clearAllValidationIssues, clearAllValidationIssues,
]); ]);
// Trigger initial validation when ready (plugins loaded) to ensure no stale errors
// Trigger initial validation when ready (plugins loaded) to ensure no stale errors
// DISABLED: User prefers manual validation to avoid noise on improved sequential architecture
// useEffect(() => {
// if (isReady) {
// void validateDesign();
// }
// }, [isReady, validateDesign]);
/* --------------------------------- Save ---------------------------------- */ /* --------------------------------- Save ---------------------------------- */
const persist = useCallback(async () => { const persist = useCallback(async () => {
if (!initialized) return; if (!initialized) return;
@@ -535,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?.({
@@ -664,15 +759,21 @@ export function DesignerRoot({
useSensor(KeyboardSensor), useSensor(KeyboardSensor),
); );
/* ----------------------------- Drag Handlers ----------------------------- */
/* ----------------------------- Drag Handlers ----------------------------- */ /* ----------------------------- Drag Handlers ----------------------------- */
const handleDragStart = useCallback( const handleDragStart = useCallback(
(event: DragStartEvent) => { (event: DragStartEvent) => {
const { active } = event; const { active } = event;
const activeId = active.id.toString();
const activeData = active.data.current;
console.log("[DesignerRoot] DragStart", { activeId, activeData });
if ( if (
active.id.toString().startsWith("action-") && activeId.startsWith("action-") &&
active.data.current?.action activeData?.action
) { ) {
const a = active.data.current.action as { const a = activeData.action as {
id: string; id: string;
name: string; name: string;
category: string; category: string;
@@ -686,6 +787,18 @@ export function DesignerRoot({
category: a.category, category: a.category,
description: a.description, description: a.description,
}); });
} else if (activeId.startsWith("s-step-")) {
console.log("[DesignerRoot] Setting active sortable STEP", activeData);
setActiveSortableItem({
type: 'step',
data: activeData
});
} else if (activeId.startsWith("s-act-")) {
console.log("[DesignerRoot] Setting active sortable ACTION", activeData);
setActiveSortableItem({
type: 'action',
data: activeData
});
} }
}, },
[toggleLibraryScrollLock], [toggleLibraryScrollLock],
@@ -694,14 +807,7 @@ export function DesignerRoot({
const handleDragOver = useCallback((event: DragOverEvent) => { const handleDragOver = useCallback((event: DragOverEvent) => {
const { active, over } = event; const { active, over } = event;
const store = useDesignerStore.getState(); const store = useDesignerStore.getState();
const activeId = active.id.toString();
// Only handle Library -> Flow projection
if (!active.id.toString().startsWith("action-")) {
if (store.insertionProjection) {
store.setInsertionProjection(null);
}
return;
}
if (!over) { if (!over) {
if (store.insertionProjection) { if (store.insertionProjection) {
@@ -710,6 +816,16 @@ export function DesignerRoot({
return; return;
} }
// 3. Library -> Flow Projection (Action)
if (!activeId.startsWith("action-")) {
if (store.insertionProjection) {
store.setInsertionProjection(null);
}
return;
}
const overId = over.id.toString(); const overId = over.id.toString();
const activeDef = active.data.current?.action; const activeDef = active.data.current?.action;
@@ -804,6 +920,7 @@ export function DesignerRoot({
// Clear overlay immediately // Clear overlay immediately
toggleLibraryScrollLock(false); toggleLibraryScrollLock(false);
setDragOverlayAction(null); setDragOverlayAction(null);
setActiveSortableItem(null);
// Capture and clear projection // Capture and clear projection
const store = useDesignerStore.getState(); const store = useDesignerStore.getState();
@@ -814,6 +931,32 @@ export function DesignerRoot({
return; return;
} }
const activeId = active.id.toString();
// Handle Step Reordering (Active is a sortable step)
if (activeId.startsWith("s-step-")) {
const overId = over.id.toString();
// Allow reordering over both sortable steps (s-step-) and drop zones (step-)
if (!overId.startsWith("s-step-") && !overId.startsWith("step-")) return;
// Strip prefixes to get raw IDs
const rawActiveId = activeId.replace(/^s-step-/, "");
const rawOverId = overId.replace(/^s-step-/, "").replace(/^step-/, "");
console.log("[DesignerRoot] DragEnd - Step Sort", { activeId, overId, rawActiveId, rawOverId });
const oldIndex = steps.findIndex((s) => s.id === rawActiveId);
const newIndex = steps.findIndex((s) => s.id === rawOverId);
console.log("[DesignerRoot] Indices", { oldIndex, newIndex });
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
console.log("[DesignerRoot] Reordering...");
reorderStep(oldIndex, newIndex);
}
return;
}
// 1. Determine Target (Step, Parent, Index) // 1. Determine Target (Step, Parent, Index)
let stepId: string | null = null; let stepId: string | null = null;
let parentId: string | null = null; let parentId: string | null = null;
@@ -880,8 +1023,9 @@ export function DesignerRoot({
} }
: undefined; : undefined;
const newId = `action-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`;
const newAction: ExperimentAction = { const newAction: ExperimentAction = {
id: crypto.randomUUID(), id: newId,
type: actionDef.type, // this is the 'type' key type: actionDef.type, // this is the 'type' key
name: actionDef.name, name: actionDef.name,
category: actionDef.category as any, category: actionDef.category as any,
@@ -906,7 +1050,7 @@ export function DesignerRoot({
void recomputeHash(); void recomputeHash();
} }
}, },
[steps, upsertAction, selectAction, recomputeHash, toggleLibraryScrollLock], [steps, upsertAction, selectAction, recomputeHash, toggleLibraryScrollLock, reorderStep],
); );
// validation status badges removed (unused) // validation status badges removed (unused)
/* ------------------------------- Panels ---------------------------------- */ /* ------------------------------- Panels ---------------------------------- */
@@ -935,10 +1079,11 @@ export function DesignerRoot({
activeTab={inspectorTab} activeTab={inspectorTab}
onTabChange={setInspectorTab} onTabChange={setInspectorTab}
studyPlugins={studyPlugins} studyPlugins={studyPlugins}
onClearAll={clearAllValidationIssues}
/> />
</div> </div>
), ),
[inspectorTab, studyPlugins], [inspectorTab, studyPlugins, clearAllValidationIssues],
); );
/* ------------------------------- Render ---------------------------------- */ /* ------------------------------- Render ---------------------------------- */
@@ -982,69 +1127,87 @@ export function DesignerRoot({
); );
return ( return (
<div className="flex h-full w-full flex-col overflow-hidden"> <div className="flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden bg-background">
<PageHeader <PageHeader
title={designMeta.name} title={designMeta.name}
description={designMeta.description || "No description"} description={designMeta.description || "No description"}
icon={Play} icon={Play}
actions={actions} actions={actions}
className="pb-6" className="flex-none pb-4"
/> />
<div className="relative flex flex-1 flex-col overflow-hidden"> {/* Main Grid Container - 2-4-2 Split */}
{/* Loading Overlay */} {/* Main Grid Container - 2-4-2 Split */}
{!isReady && ( <div className="flex-1 min-h-0 w-full px-2 overflow-hidden">
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<RefreshCw className="h-8 w-8 animate-spin text-primary" />
<p className="text-muted-foreground text-sm">Loading designer...</p>
</div>
</div>
)}
{/* Main Content - Fade in when ready */}
<div
className={cn(
"flex flex-1 flex-col overflow-hidden transition-opacity duration-500",
isReady ? "opacity-100" : "opacity-0"
)}
>
<div className="flex h-[calc(100vh-12rem)] w-full max-w-full flex-col overflow-hidden rounded-md border">
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCorners} collisionDetection={closestCenter}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
onDragCancel={() => toggleLibraryScrollLock(false)} onDragCancel={() => toggleLibraryScrollLock(false)}
> >
<PanelsContainer <div className="grid grid-cols-8 gap-4 h-full w-full transition-all duration-300 ease-in-out">
showDividers {/* Left Panel (Library) */}
className="min-h-0 flex-1" {!leftCollapsed && (
left={leftPanel} <div className={cn(
center={centerPanel} "flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
right={rightPanel} rightCollapsed ? "col-span-3" : "col-span-2"
/> )}>
<DragOverlay> <div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
{dragOverlayAction ? ( <span className="text-sm font-medium">Action Library</span>
<div className="bg-background flex items-center gap-2 rounded border px-3 py-2 text-xs font-medium shadow-lg select-none"> <Button
<span variant="ghost"
className={cn( size="icon"
"h-2.5 w-2.5 rounded-full", className="h-6 w-6"
{ onClick={() => setLeftCollapsed(true)}
wizard: "bg-blue-500", >
robot: "bg-emerald-600", <PanelLeftClose className="h-4 w-4" />
control: "bg-amber-500", </Button>
observation: "bg-purple-600",
}[dragOverlayAction.category] || "bg-slate-400",
)}
/>
{dragOverlayAction.name}
</div> </div>
) : null} <div className="flex-1 overflow-hidden min-h-0 bg-muted/10">
</DragOverlay> {leftPanel}
</DndContext> </div>
<div className="flex-shrink-0 border-t"> </div>
)}
{/* Center Panel (Workspace) */}
<div className={cn(
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
leftCollapsed && rightCollapsed ? "col-span-8" :
leftCollapsed ? "col-span-6" :
rightCollapsed ? "col-span-5" :
"col-span-4"
)}>
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
{leftCollapsed && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 mr-2"
onClick={() => setLeftCollapsed(false)}
title="Open Library"
>
<PanelLeftOpen className="h-4 w-4" />
</Button>
)}
<span className="text-sm font-medium">Flow Workspace</span>
{rightCollapsed && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 ml-2"
onClick={() => setRightCollapsed(false)}
title="Open Inspector"
>
<PanelRightOpen className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex-1 overflow-hidden min-h-0 relative">
{centerPanel}
</div>
<div className="border-t">
<BottomStatusBar <BottomStatusBar
onSave={() => persist()} onSave={() => persist()}
onValidate={() => validateDesign()} onValidate={() => validateDesign()}
@@ -1057,7 +1220,67 @@ export function DesignerRoot({
/> />
</div> </div>
</div> </div>
{/* Right Panel (Inspector) */}
{!rightCollapsed && (
<div className={cn(
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
leftCollapsed ? "col-span-2" : "col-span-2"
)}>
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
<span className="text-sm font-medium">Inspector</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setRightCollapsed(true)}
>
<PanelRightClose className="h-4 w-4" />
</Button>
</div> </div>
<div className="flex-1 overflow-hidden min-h-0 bg-muted/10">
{rightPanel}
</div>
</div>
)}
</div>
<DragOverlay dropAnimation={null}>
{dragOverlayAction ? (
// Library Item Drag
<div className="bg-background pointer-events-none flex items-center gap-2 rounded border px-3 py-2 text-xs font-medium shadow-lg select-none ring-2 ring-blue-500/20">
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded text-white",
dragOverlayAction.category === "robot" && "bg-emerald-600",
dragOverlayAction.category === "control" && "bg-amber-500",
dragOverlayAction.category === "observation" &&
"bg-purple-600",
)}
/>
{dragOverlayAction.name}
</div>
) : activeSortableItem?.type === 'action' ? (
// Existing Action Sort
<div className="w-[300px] opacity-90 pointer-events-none">
<SortableActionChip
stepId={activeSortableItem.data.stepId}
action={activeSortableItem.data.action}
parentId={activeSortableItem.data.parentId}
selectedActionId={selectedActionId}
onSelectAction={() => { }}
onDeleteAction={() => { }}
dragHandle={true}
/>
</div>
) : activeSortableItem?.type === 'step' ? (
// Existing Step Sort
<div className="w-[400px] pointer-events-none opacity-90">
<StepCardPreview step={activeSortableItem.data.step} dragHandle />
</div>
) : null}
</DragOverlay>
</DndContext>
</div> </div>
</div> </div>
); );

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>
); );
@@ -388,17 +523,18 @@ export function PropertiesPanelBase({
onValueChange={(val) => { onValueChange={(val) => {
onStepUpdate(selectedStep.id, { type: val as StepType }); onStepUpdate(selectedStep.id, { type: val as StepType });
}} }}
disabled={true}
> >
<SelectTrigger className="mt-1 h-7 w-full text-xs"> <SelectTrigger className="mt-1 h-7 w-full text-xs">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="sequential">Sequential</SelectItem> <SelectItem value="sequential">Sequential</SelectItem>
<SelectItem value="parallel">Parallel</SelectItem>
<SelectItem value="conditional">Conditional</SelectItem>
<SelectItem value="loop">Loop</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-muted-foreground mt-1 text-[10px]">
Steps always execute sequentially. Use control flow actions for parallel/conditional logic.
</p>
</div> </div>
<div> <div>
<Label className="text-xs">Trigger</Label> <Label className="text-xs">Trigger</Label>

View File

@@ -46,6 +46,10 @@ export interface ValidationPanelProps {
* Called to clear all issues for an entity. * Called to clear all issues for an entity.
*/ */
onEntityClear?: (entityId: string) => void; onEntityClear?: (entityId: string) => void;
/**
* Called to clear all issues globally.
*/
onClearAll?: () => void;
/** /**
* Optional function to map entity IDs to human-friendly names (e.g., step/action names). * Optional function to map entity IDs to human-friendly names (e.g., step/action names).
*/ */
@@ -60,25 +64,25 @@ export interface ValidationPanelProps {
const severityConfig = { const severityConfig = {
error: { error: {
icon: AlertCircle, icon: AlertCircle,
color: "text-red-600 dark:text-red-400", color: "text-validation-error-text",
bgColor: "bg-red-100 dark:bg-red-950/60", bgColor: "bg-validation-error-bg",
borderColor: "border-red-300 dark:border-red-700", borderColor: "border-validation-error-border",
badgeVariant: "destructive" as const, badgeVariant: "destructive" as const,
label: "Error", label: "Error",
}, },
warning: { warning: {
icon: AlertTriangle, icon: AlertTriangle,
color: "text-amber-600 dark:text-amber-400", color: "text-validation-warning-text",
bgColor: "bg-amber-100 dark:bg-amber-950/60", bgColor: "bg-validation-warning-bg",
borderColor: "border-amber-300 dark:border-amber-700", borderColor: "border-validation-warning-border",
badgeVariant: "secondary" as const, badgeVariant: "outline" as const,
label: "Warning", label: "Warning",
}, },
info: { info: {
icon: Info, icon: Info,
color: "text-blue-600 dark:text-blue-400", color: "text-validation-info-text",
bgColor: "bg-blue-100 dark:bg-blue-950/60", bgColor: "bg-validation-info-bg",
borderColor: "border-blue-300 dark:border-blue-700", borderColor: "border-validation-info-border",
badgeVariant: "outline" as const, badgeVariant: "outline" as const,
label: "Info", label: "Info",
}, },
@@ -141,7 +145,7 @@ function IssueItem({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-[12px] leading-snug break-words whitespace-normal"> <p className="text-[12px] leading-snug break-words whitespace-normal text-foreground">
{issue.message} {issue.message}
</p> </p>
@@ -199,6 +203,7 @@ export function ValidationPanel({
onIssueClick, onIssueClick,
onIssueClear, onIssueClear,
onEntityClear: _onEntityClear, onEntityClear: _onEntityClear,
onClearAll,
entityLabelForId, entityLabelForId,
className, className,
}: ValidationPanelProps) { }: ValidationPanelProps) {

View File

@@ -8,6 +8,7 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { import {
useDndContext,
useDroppable, useDroppable,
useDndMonitor, useDndMonitor,
type DragEndEvent, type DragEndEvent,
@@ -28,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 {
@@ -80,21 +82,28 @@ export interface VirtualItem {
interface StepRowProps { interface StepRowProps {
item: VirtualItem; item: VirtualItem;
step: ExperimentStep; // Explicit pass for freshness
totalSteps: number;
selectedStepId: string | null | undefined; selectedStepId: string | null | undefined;
selectedActionId: string | null | undefined; selectedActionId: string | null | undefined;
renamingStepId: string | null; renamingStepId: string | null;
onSelectStep: (id: string | undefined) => void; onSelectStep: (id: string | undefined) => void;
onSelectAction: (stepId: string, actionId: string | undefined) => void; onSelectAction: (stepId: string, actionId: string | undefined) => void;
onToggleExpanded: (step: ExperimentStep) => void; onToggleExpanded: (step: ExperimentStep) => void;
onRenameStep: (step: ExperimentStep, name: string) => void; onRenameStep: (step: ExperimentStep, newName: string) => void;
onDeleteStep: (step: ExperimentStep) => void; onDeleteStep: (step: ExperimentStep) => void;
onDeleteAction: (stepId: string, actionId: string) => void; onDeleteAction: (stepId: string, actionId: string) => void;
setRenamingStepId: (id: string | null) => void; setRenamingStepId: (id: string | null) => void;
registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void; registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void;
onReorderStep: (stepId: string, direction: 'up' | 'down') => void;
onReorderAction?: (stepId: string, actionId: string, direction: 'up' | 'down') => void;
isChild?: boolean;
} }
const StepRow = React.memo(function StepRow({ function StepRow({
item, item,
step,
totalSteps,
selectedStepId, selectedStepId,
selectedActionId, selectedActionId,
renamingStepId, renamingStepId,
@@ -106,8 +115,12 @@ const StepRow = React.memo(function StepRow({
onDeleteAction, onDeleteAction,
setRenamingStepId, setRenamingStepId,
registerMeasureRef, registerMeasureRef,
onReorderStep,
onReorderAction,
isChild,
}: StepRowProps) { }: StepRowProps) {
const step = item.step; // 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(() => {
@@ -125,47 +138,39 @@ const StepRow = React.memo(function StepRow({
return step.actions; return step.actions;
}, [step.actions, step.id, insertionProjection]); }, [step.actions, step.id, insertionProjection]);
const {
setNodeRef,
transform,
transition,
attributes,
listeners,
isDragging,
} = useSortable({
id: sortableStepId(step.id),
data: {
type: "step",
step: step,
},
});
const style: React.CSSProperties = { const style: React.CSSProperties = {
position: "absolute", position: "absolute",
top: item.top, top: item.top,
left: 0, left: 0,
right: 0, right: 0,
width: "100%", width: "100%",
transform: CSS.Transform.toString(transform), transition: "top 300ms cubic-bezier(0.4, 0, 0.2, 1)",
transition, // transform: CSS.Transform.toString(transform), // Removed
zIndex: isDragging ? 25 : undefined, // zIndex: isDragging ? 25 : undefined,
}; };
return ( return (
<div ref={setNodeRef} 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(
"mb-2 rounded border shadow-sm transition-colors", "mb-2 rounded-lg border shadow-sm transition-colors",
selectedStepId === step.id selectedStepId === step.id
? "border-border bg-accent/30" ? "border-border bg-accent/30"
: "hover:bg-accent/30", : "hover:bg-accent/30"
isDragging && "opacity-80 ring-1 ring-blue-300",
)} )}
> >
<div <div
@@ -258,17 +263,108 @@ const StepRow = React.memo(function StepRow({
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
<div <Button
className="text-muted-foreground cursor-grab p-1" variant="ghost"
aria-label="Drag step" size="sm"
{...attributes} className="h-7 w-7 p-0 text-[11px] text-muted-foreground hover:text-foreground"
{...listeners} onClick={(e) => {
e.stopPropagation();
onReorderStep(step.id, 'up');
}}
disabled={item.index === 0}
aria-label="Move step up"
> >
<GripVertical className="h-4 w-4" /> <ChevronRight className="h-4 w-4 -rotate-90" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-[11px] text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onReorderStep(step.id, 'down');
}}
disabled={item.index === totalSteps - 1}
aria-label="Move step down"
>
<ChevronRight className="h-4 w-4 rotate-90" />
</Button>
</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>
<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">
@@ -282,7 +378,7 @@ const StepRow = React.memo(function StepRow({
Drop actions here Drop actions here
</div> </div>
) : ( ) : (
displayActions.map((action) => ( displayActions.map((action, index) => (
<SortableActionChip <SortableActionChip
key={action.id} key={action.id}
stepId={step.id} stepId={step.id}
@@ -291,6 +387,9 @@ const StepRow = React.memo(function StepRow({
selectedActionId={selectedActionId} selectedActionId={selectedActionId}
onSelectAction={onSelectAction} onSelectAction={onSelectAction}
onDeleteAction={onDeleteAction} onDeleteAction={onDeleteAction}
onReorderAction={onReorderAction}
isFirst={index === 0}
isLast={index === displayActions.length - 1}
/> />
)) ))
)} )}
@@ -300,9 +399,53 @@ const StepRow = React.memo(function StepRow({
)} )}
</div> </div>
</div> </div>
</div >
);
}
/* -------------------------------------------------------------------------- */
/* Step Card Preview (for DragOverlay) */
/* -------------------------------------------------------------------------- */
export function StepCardPreview({ step, dragHandle }: { step: ExperimentStep; dragHandle?: boolean }) {
return (
<div
className={cn(
"rounded-lg border bg-background shadow-xl ring-2 ring-blue-500/20",
dragHandle && "cursor-grabbing"
)}
>
<div className="flex items-center justify-between gap-2 border-b px-2 py-1.5 p-3">
<div className="flex items-center gap-2">
<div className="text-muted-foreground rounded p-1">
<ChevronRight className="h-4 w-4" />
</div>
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] font-normal"
>
{step.order + 1}
</Badge>
<div className="flex items-center gap-1">
<span className="text-sm font-medium">{step.name}</span>
</div>
<span className="text-muted-foreground hidden text-[11px] md:inline">
{step.actions.length} actions
</span>
</div>
<div className="flex items-center gap-1 text-muted-foreground">
<GripVertical className="h-4 w-4" />
</div>
</div>
{/* Preview optional: show empty body hint or just the header? Header is usually enough for sorting. */}
<div className="bg-muted/10 p-2 h-12 flex items-center justify-center border-t border-dashed">
<span className="text-[10px] text-muted-foreground">
{step.actions.length} actions hidden while dragging
</span>
</div>
</div> </div>
); );
}); }
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Utility */ /* Utility */
@@ -331,9 +474,19 @@ function parseSortableAction(id: string): string | null {
/* Droppable Overlay (for palette action drops) */ /* Droppable Overlay (for palette action drops) */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
function StepDroppableArea({ stepId }: { stepId: string }) { function StepDroppableArea({ stepId }: { stepId: string }) {
const { isOver } = useDroppable({ id: `step-${stepId}` }); const { active } = useDndContext();
const isStepDragging = active?.id.toString().startsWith("s-step-");
const { isOver, setNodeRef } = useDroppable({
id: `step-${stepId}`,
disabled: isStepDragging
});
if (isStepDragging) return null;
return ( return (
<div <div
ref={setNodeRef}
data-step-drop data-step-drop
className={cn( className={cn(
"pointer-events-none absolute inset-0 rounded-md transition-colors", "pointer-events-none absolute inset-0 rounded-md transition-colors",
@@ -348,26 +501,155 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
/* Sortable Action Chip */ /* Sortable Action Chip */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
interface ActionChipProps { export interface ActionChipProps {
stepId: string; stepId: string;
action: ExperimentAction; action: ExperimentAction;
parentId: string | null; parentId: string | null;
selectedActionId: string | null | undefined; selectedActionId: string | null | undefined;
onSelectAction: (stepId: string, actionId: string | undefined) => void; onSelectAction: (stepId: string, actionId: string | undefined) => void;
onDeleteAction: (stepId: string, actionId: string) => void; onDeleteAction: (stepId: string, actionId: string) => void;
onReorderAction?: (stepId: string, actionId: string, direction: 'up' | 'down') => void;
dragHandle?: boolean; dragHandle?: boolean;
isFirst?: boolean;
isLast?: boolean;
} }
function SortableActionChip({ /* -------------------------------------------------------------------------- */
/* Action Chip Visuals (Pure Component) */
/* -------------------------------------------------------------------------- */
export interface ActionChipVisualsProps {
action: ExperimentAction;
isSelected?: boolean;
isDragging?: boolean;
isOverNested?: boolean;
onSelect?: (e: React.MouseEvent) => void;
onDelete?: (e: React.MouseEvent) => void;
onReorder?: (direction: 'up' | 'down') => void;
dragHandleProps?: React.HTMLAttributes<HTMLElement>;
children?: React.ReactNode;
isFirst?: boolean;
isLast?: boolean;
validationStatus?: "error" | "warning" | "info";
}
export function ActionChipVisuals({
action,
isSelected,
isDragging,
isOverNested,
onSelect,
onDelete,
onReorder,
dragHandleProps,
children,
isFirst,
isLast,
validationStatus,
}: ActionChipVisualsProps) {
const def = actionRegistry.getAction(action.type);
return (
<div
className={cn(
"group relative flex w-full flex-col items-start gap-1 rounded border px-3 py-2 text-[11px]",
"bg-muted/40 hover:bg-accent/40 cursor-pointer",
isSelected && "border-border bg-accent/30",
isDragging && "opacity-70 shadow-lg",
isOverNested && !isDragging && "ring-2 ring-blue-400 ring-offset-1 bg-blue-50/50"
)}
onClick={onSelect}
role="button"
aria-pressed={isSelected}
tabIndex={0}
>
<div className="flex w-full items-center gap-2">
<span className="flex-1 leading-snug font-medium break-words flex items-center gap-2">
{action.name}
{validationStatus === "error" && (
<div className="h-2 w-2 rounded-full bg-red-500 ring-1 ring-red-600" aria-label="Error" />
)}
{validationStatus === "warning" && (
<div className="h-2 w-2 rounded-full bg-amber-500 ring-1 ring-amber-600" aria-label="Warning" />
)}
</span>
<div className="flex items-center gap-0.5 mr-1 bg-background/50 rounded-md border border-border/50 shadow-sm px-0.5">
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-[10px] text-muted-foreground hover:text-foreground z-20 pointer-events-auto"
onClick={(e) => {
e.stopPropagation();
onReorder?.('up');
}}
disabled={isFirst}
aria-label="Move action up"
>
<ChevronRight className="h-3 w-3 -rotate-90" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-[10px] text-muted-foreground hover:text-foreground z-20 pointer-events-auto"
onClick={(e) => {
e.stopPropagation();
onReorder?.('down');
}}
disabled={isLast}
aria-label="Move action down"
>
<ChevronRight className="h-3 w-3 rotate-90" />
</Button>
</div>
<button
type="button"
onClick={onDelete}
className="text-muted-foreground hover:text-foreground rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
aria-label="Delete action"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
{def?.description && (
<div className="text-muted-foreground line-clamp-3 w-full text-[10px] leading-snug">
{def.description}
</div>
)}
{def?.parameters.length ? (
<div className="flex flex-wrap gap-1 pt-0.5">
{def.parameters.slice(0, 4).map((p) => (
<span
key={p.id}
className="bg-background/70 text-muted-foreground ring-border rounded px-1 py-0.5 text-[9px] font-medium ring-1"
>
{p.name}
</span>
))}
{def.parameters.length > 4 && (
<span className="text-[9px] text-muted-foreground">+{def.parameters.length - 4}</span>
)}
</div>
) : null}
{children}
</div>
);
}
export function SortableActionChip({
stepId, stepId,
action, action,
parentId, parentId,
selectedActionId, selectedActionId,
onSelectAction, onSelectAction,
onDeleteAction, onDeleteAction,
onReorderAction,
dragHandle, dragHandle,
isFirst,
isLast,
}: ActionChipProps) { }: ActionChipProps) {
const def = actionRegistry.getAction(action.type);
const isSelected = selectedActionId === action.id; const isSelected = selectedActionId === action.id;
const insertionProjection = useDesignerStore((s) => s.insertionProjection); const insertionProjection = useDesignerStore((s) => s.insertionProjection);
@@ -388,35 +670,44 @@ function SortableActionChip({
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
const isPlaceholder = action.id === "projection-placeholder"; const isPlaceholder = action.id === "projection-placeholder";
const { // Compute validation status
attributes, const issues = useDesignerStore((s) => s.validationIssues[action.id]);
listeners, const validationStatus = useMemo(() => {
setNodeRef, if (!issues?.length) return undefined;
transform, if (issues.some((i) => i.severity === "error")) return "error";
transition, if (issues.some((i) => i.severity === "warning")) return "warning";
isDragging: isSortableDragging, return "info";
} = useSortable({ }, [issues]);
id: sortableActionId(action.id),
disabled: isPlaceholder, // Disable sortable for placeholder /* ------------------------------------------------------------------------ */
data: { /* Sortable (Local) DnD Monitoring */
type: "action", /* ------------------------------------------------------------------------ */
stepId, // useSortable disabled per user request to remove action drag-and-drop
parentId, // const { ... } = useSortable(...)
id: action.id,
},
});
// Use local dragging state or passed prop // Use local dragging state or passed prop
const isDragging = isSortableDragging || dragHandle; const isDragging = dragHandle || false;
const style = { const style = {
transform: CSS.Translate.toString(transform), // transform: CSS.Translate.toString(transform),
transition, // transition,
}; };
// We need a ref for droppable? Droppable is below.
// For the chip itself, if not sortable, we don't need setNodeRef.
// But we might need it for layout?
// Let's keep a simple div ref usage if needed, but useSortable provided setNodeRef.
// We can just use a normal ref or nothing if not measuring.
const setNodeRef = undefined; // No-op
const attributes = {};
const listeners = {};
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
/* Nested Droppable (for control flow containers) */ /* Nested Droppable (for control flow containers) */
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
const def = actionRegistry.getAction(action.type);
const nestedDroppableId = `container-${action.id}`; const nestedDroppableId = `container-${action.id}`;
const { const {
isOver: isOverNested, isOver: isOverNested,
@@ -472,80 +763,26 @@ function SortableActionChip({
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={cn( {...attributes}
"group relative flex w-full flex-col items-start gap-1 rounded border px-3 py-2 text-[11px]", >
"bg-muted/40 hover:bg-accent/40 cursor-pointer", <ActionChipVisuals
isSelected && "border-border bg-accent/30", action={action}
isDragging && "opacity-70 shadow-lg", isSelected={isSelected}
// Visual feedback for nested drop isDragging={isDragging}
isOverNested && !isDragging && "ring-2 ring-blue-400 ring-offset-1 bg-blue-50/50" isOverNested={isOverNested}
)} onSelect={(e) => {
onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onSelectAction(stepId, action.id); onSelectAction(stepId, action.id);
}} }}
{...attributes} onDelete={(e) => {
role="button"
aria-pressed={isSelected}
tabIndex={0}
>
<div className="flex w-full items-center gap-2">
<div
{...listeners}
className="text-muted-foreground/70 hover:text-foreground cursor-grab rounded p-0.5"
aria-label="Drag action"
>
<GripVertical className="h-3.5 w-3.5" />
</div>
<span
className={cn(
"h-2.5 w-2.5 rounded-full",
def
? {
wizard: "bg-blue-500",
robot: "bg-emerald-500",
control: "bg-amber-500",
observation: "bg-purple-500",
}[def.category]
: "bg-slate-400",
)}
/>
<span className="flex-1 leading-snug font-medium break-words">
{action.name}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onDeleteAction(stepId, action.id); onDeleteAction(stepId, action.id);
}} }}
className="text-muted-foreground hover:text-foreground rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100" onReorder={(direction) => onReorderAction?.(stepId, action.id, direction)}
aria-label="Delete action" dragHandleProps={listeners}
isLast={isLast}
validationStatus={validationStatus}
> >
<Trash2 className="h-3 w-3" />
</button>
</div>
{def?.description && (
<div className="text-muted-foreground line-clamp-3 w-full text-[10px] leading-snug">
{def.description}
</div>
)}
{def?.parameters.length ? (
<div className="flex flex-wrap gap-1 pt-0.5">
{def.parameters.slice(0, 4).map((p) => (
<span
key={p.id}
className="bg-background/70 text-muted-foreground ring-border rounded px-1 py-0.5 text-[9px] font-medium ring-1"
>
{p.name}
</span>
))}
{def.parameters.length > 4 && (
<span className="text-[9px] text-muted-foreground">+{def.parameters.length - 4}</span>
)}
</div>
) : null}
{/* Nested Actions Container */} {/* Nested Actions Container */}
{shouldRenderChildren && ( {shouldRenderChildren && (
<div <div
@@ -569,6 +806,7 @@ function SortableActionChip({
selectedActionId={selectedActionId} selectedActionId={selectedActionId}
onSelectAction={onSelectAction} onSelectAction={onSelectAction}
onDeleteAction={onDeleteAction} onDeleteAction={onDeleteAction}
onReorderAction={onReorderAction}
/> />
))} ))}
{(!displayChildren?.length && !action.children?.length) && ( {(!displayChildren?.length && !action.children?.length) && (
@@ -579,7 +817,7 @@ function SortableActionChip({
</SortableContext> </SortableContext>
</div> </div>
)} )}
</ActionChipVisuals>
</div> </div>
); );
} }
@@ -633,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;
@@ -796,6 +1049,52 @@ export function FlowWorkspace({
[removeAction, selectedActionId, selectAction, recomputeHash], [removeAction, selectedActionId, selectAction, recomputeHash],
); );
const handleReorderStep = useCallback(
(stepId: string, direction: 'up' | 'down') => {
console.log('handleReorderStep', stepId, direction);
const currentIndex = steps.findIndex((s) => s.id === stepId);
console.log('currentIndex', currentIndex, 'total', steps.length);
if (currentIndex === -1) return;
const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
console.log('newIndex', newIndex);
if (newIndex < 0 || newIndex >= steps.length) return;
reorderStep(currentIndex, newIndex);
},
[steps, reorderStep]
);
const handleReorderAction = useCallback(
(stepId: string, actionId: string, direction: 'up' | 'down') => {
const step = steps.find(s => s.id === stepId);
if (!step) return;
const findInTree = (list: ExperimentAction[], pId: string | null): { list: ExperimentAction[], parentId: string | null, index: number } | null => {
const idx = list.findIndex(a => a.id === actionId);
if (idx !== -1) return { list, parentId: pId, index: idx };
for (const a of list) {
if (a.children) {
const res = findInTree(a.children, a.id);
if (res) return res;
}
}
return null;
};
const context = findInTree(step.actions, null);
if (!context) return;
const { parentId, index, list } = context;
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= list.length) return;
moveAction(stepId, actionId, parentId, newIndex);
},
[steps, moveAction]
);
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
/* Sortable (Local) DnD Monitoring */ /* Sortable (Local) DnD Monitoring */
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
@@ -815,19 +1114,9 @@ export function FlowWorkspace({
} }
const activeId = active.id.toString(); const activeId = active.id.toString();
const overId = over.id.toString(); const overId = over.id.toString();
// Step reorder
if (activeId.startsWith("s-step-") && overId.startsWith("s-step-")) { // Step reorder is now handled globally in DesignerRoot
const fromStepId = parseSortableStep(activeId);
const toStepId = parseSortableStep(overId);
if (fromStepId && toStepId && fromStepId !== toStepId) {
const fromIndex = steps.findIndex((s) => s.id === fromStepId);
const toIndex = steps.findIndex((s) => s.id === toStepId);
if (fromIndex >= 0 && toIndex >= 0) {
reorderStep(fromIndex, toIndex);
void recomputeHash();
}
}
}
// Action reorder (supports nesting) // Action reorder (supports nesting)
if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) { if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) {
const activeData = active.data.current; const activeData = active.data.current;
@@ -839,8 +1128,9 @@ export function FlowWorkspace({
activeData.type === 'action' && overData.type === 'action' activeData.type === 'action' && overData.type === 'action'
) { ) {
const stepId = activeData.stepId as string; const stepId = activeData.stepId as string;
const activeActionId = activeData.action.id; // Fix: SortableActionChip puts 'id' directly on data, not inside 'action' property
const overActionId = overData.action.id; const activeActionId = activeData.id;
const overActionId = overData.id;
if (activeActionId !== overActionId) { if (activeActionId !== overActionId) {
const newParentId = overData.parentId as string | null; const newParentId = overData.parentId as string | null;
@@ -877,8 +1167,10 @@ export function FlowWorkspace({
activeData.type === 'action' && activeData.type === 'action' &&
overData.type === 'action' overData.type === 'action'
) { ) {
const activeActionId = activeData.action.id; // Fix: Access 'id' directly from data payload
const overActionId = overData.action.id; const activeActionId = activeData.id;
const overActionId = overData.id;
const activeStepId = activeData.stepId; const activeStepId = activeData.stepId;
const overStepId = overData.stepId; const overStepId = overData.stepId;
const activeParentId = activeData.parentId; const activeParentId = activeData.parentId;
@@ -956,7 +1248,8 @@ export function FlowWorkspace({
<div <div
ref={containerRef} ref={containerRef}
id="tour-designer-canvas" id="tour-designer-canvas"
className="relative h-0 min-h-0 flex-1 overflow-x-hidden overflow-y-auto" // Removed 'border' class to fix double border issue
className="relative h-0 min-h-0 flex-1 overflow-x-hidden overflow-y-auto rounded-md"
onScroll={onScroll} onScroll={onScroll}
> >
{steps.length === 0 ? ( {steps.length === 0 ? (
@@ -990,6 +1283,8 @@ export function FlowWorkspace({
<StepRow <StepRow
key={vi.key} key={vi.key}
item={vi} item={vi}
step={vi.step}
totalSteps={steps.length}
selectedStepId={selectedStepId} selectedStepId={selectedStepId}
selectedActionId={selectedActionId} selectedActionId={selectedActionId}
renamingStepId={renamingStepId} renamingStepId={renamingStepId}
@@ -1004,6 +1299,9 @@ export function FlowWorkspace({
onDeleteAction={deleteAction} onDeleteAction={deleteAction}
setRenamingStepId={setRenamingStepId} setRenamingStepId={setRenamingStepId}
registerMeasureRef={registerMeasureRef} registerMeasureRef={registerMeasureRef}
onReorderStep={handleReorderStep}
onReorderAction={handleReorderAction}
isChild={childStepIds.has(vi.step.id)}
/> />
), ),
)} )}

View File

@@ -5,14 +5,11 @@ import {
Save, Save,
RefreshCw, RefreshCw,
Download, Download,
Hash,
AlertTriangle, AlertTriangle,
CheckCircle2, CheckCircle2,
UploadCloud, Hash,
Wand2,
Sparkles,
GitBranch, GitBranch,
Keyboard, Sparkles,
} 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";
@@ -20,21 +17,6 @@ import { Separator } from "~/components/ui/separator";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { useDesignerStore } from "../state/store"; import { useDesignerStore } from "../state/store";
/**
* BottomStatusBar
*
* Compact, persistent status + quick-action bar for the Experiment Designer.
* Shows:
* - Validation / drift / unsaved state
* - Short design hash & version
* - Aggregate counts (steps / actions)
* - Last persisted hash (if available)
* - Quick actions (Save, Validate, Export, Command Palette)
*
* The bar is intentionally UI-only: callback props are used so that higher-level
* orchestration (e.g. DesignerRoot / Shell) controls actual side effects.
*/
export interface BottomStatusBarProps { export interface BottomStatusBarProps {
onSave?: () => void; onSave?: () => void;
onValidate?: () => void; onValidate?: () => void;
@@ -45,9 +27,6 @@ export interface BottomStatusBarProps {
saving?: boolean; saving?: boolean;
validating?: boolean; validating?: boolean;
exporting?: boolean; exporting?: boolean;
/**
* Optional externally supplied last saved Date for relative display.
*/
lastSavedAt?: Date; lastSavedAt?: Date;
} }
@@ -55,24 +34,16 @@ export function BottomStatusBar({
onSave, onSave,
onValidate, onValidate,
onExport, onExport,
onOpenCommandPalette,
onRecalculateHash,
className, className,
saving, saving,
validating, validating,
exporting, exporting,
lastSavedAt,
}: BottomStatusBarProps) { }: BottomStatusBarProps) {
/* ------------------------------------------------------------------------ */
/* Store Selectors */
/* ------------------------------------------------------------------------ */
const steps = useDesignerStore((s) => s.steps); const steps = useDesignerStore((s) => s.steps);
const lastPersistedHash = useDesignerStore((s) => s.lastPersistedHash); const lastPersistedHash = useDesignerStore((s) => s.lastPersistedHash);
const currentDesignHash = useDesignerStore((s) => s.currentDesignHash); const currentDesignHash = useDesignerStore((s) => s.currentDesignHash);
const lastValidatedHash = useDesignerStore((s) => s.lastValidatedHash); const lastValidatedHash = useDesignerStore((s) => s.lastValidatedHash);
const pendingSave = useDesignerStore((s) => s.pendingSave); const pendingSave = useDesignerStore((s) => s.pendingSave);
const versionStrategy = useDesignerStore((s) => s.versionStrategy);
const autoSaveEnabled = useDesignerStore((s) => s.autoSaveEnabled);
const actionCount = useMemo( const actionCount = useMemo(
() => steps.reduce((sum, st) => sum + st.actions.length, 0), () => steps.reduce((sum, st) => sum + st.actions.length, 0),
@@ -93,64 +64,28 @@ export function BottomStatusBar({
return "valid"; return "valid";
}, [currentDesignHash, lastValidatedHash]); }, [currentDesignHash, lastValidatedHash]);
const shortHash = useMemo(
() => (currentDesignHash ? currentDesignHash.slice(0, 8) : "—"),
[currentDesignHash],
);
const lastPersistedShort = useMemo(
() => (lastPersistedHash ? lastPersistedHash.slice(0, 8) : null),
[lastPersistedHash],
);
/* ------------------------------------------------------------------------ */
/* Derived Display Helpers */
/* ------------------------------------------------------------------------ */
function formatRelative(date?: Date): string {
if (!date) return "—";
const now = Date.now();
const diffMs = now - date.getTime();
if (diffMs < 30_000) return "just now";
const mins = Math.floor(diffMs / 60_000);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
const relSaved = formatRelative(lastSavedAt);
const validationBadge = (() => { const validationBadge = (() => {
switch (validationStatus) { switch (validationStatus) {
case "valid": case "valid":
return ( return (
<Badge <div className="flex items-center gap-1.5 text-green-600 dark:text-green-400">
variant="outline" <CheckCircle2 className="h-3.5 w-3.5" />
className="border-green-400 text-green-600 dark:text-green-400" <span className="hidden sm:inline">Valid</span>
title="Validated (hash stable)" </div>
>
<CheckCircle2 className="mr-1 h-3 w-3" />
<span className="hidden sm:inline">Validated</span>
</Badge>
); );
case "drift": case "drift":
return ( return (
<Badge <div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
variant="destructive" <AlertTriangle className="h-3.5 w-3.5" />
className="border-amber-400 bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400" <span className="hidden sm:inline">Modified</span>
title="Drift since last validation" </div>
>
<AlertTriangle className="mr-1 h-3 w-3" />
<span className="hidden sm:inline">Drift</span>
</Badge>
); );
default: default:
return ( return (
<Badge variant="outline" title="Not validated yet"> <div className="flex items-center gap-1.5 text-muted-foreground">
<Hash className="mr-1 h-3 w-3" /> <Hash className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Unvalidated</span> <span className="hidden sm:inline">Unvalidated</span>
</Badge> </div>
); );
} }
})(); })();
@@ -159,190 +94,63 @@ export function BottomStatusBar({
hasUnsaved && !pendingSave ? ( hasUnsaved && !pendingSave ? (
<Badge <Badge
variant="outline" variant="outline"
className="border-orange-300 text-orange-600 dark:text-orange-400" className="h-5 gap-1 border-orange-300 px-1.5 text-[10px] font-normal text-orange-600 dark:text-orange-400"
title="Unsaved changes"
> >
<AlertTriangle className="mr-1 h-3 w-3" /> Unsaved
<span className="hidden sm:inline">Unsaved</span>
</Badge> </Badge>
) : null; ) : null;
const savingIndicator = const savingIndicator =
pendingSave || saving ? ( pendingSave || saving ? (
<Badge <div className="flex items-center gap-1.5 text-muted-foreground animate-pulse">
variant="secondary" <RefreshCw className="h-3 w-3 animate-spin" />
className="animate-pulse" <span>Saving...</span>
title="Saving changes" </div>
>
<RefreshCw className="mr-1 h-3 w-3 animate-spin" />
Saving
</Badge>
) : null; ) : null;
/* ------------------------------------------------------------------------ */
/* Handlers */
/* ------------------------------------------------------------------------ */
const handleSave = useCallback(() => {
if (onSave) onSave();
}, [onSave]);
const handleValidate = useCallback(() => {
if (onValidate) onValidate();
}, [onValidate]);
const handleExport = useCallback(() => {
if (onExport) onExport();
}, [onExport]);
const handlePalette = useCallback(() => {
if (onOpenCommandPalette) onOpenCommandPalette();
}, [onOpenCommandPalette]);
const handleRecalculateHash = useCallback(() => {
if (onRecalculateHash) onRecalculateHash();
}, [onRecalculateHash]);
/* ------------------------------------------------------------------------ */
/* Render */
/* ------------------------------------------------------------------------ */
return ( return (
<div <div
className={cn( className={cn(
"border-border/60 bg-muted/40 supports-[backdrop-filter]:bg-muted/30 backdrop-blur", "border-border/60 bg-muted/40 supports-[backdrop-filter]:bg-muted/30 backdrop-blur",
"flex h-10 w-full flex-shrink-0 items-center gap-3 border-t px-3 text-xs", "flex h-9 w-full flex-shrink-0 items-center gap-4 border-t px-3 text-xs font-medium",
"font-medium",
className, className,
)} )}
aria-label="Designer status bar"
> >
{/* Left Cluster: Validation & Hash */} {/* Status Indicators */}
<div className="flex min-w-0 items-center gap-2"> <div className="flex items-center gap-3 min-w-0">
{validationBadge} {validationBadge}
{unsavedBadge} {unsavedBadge}
{savingIndicator} {savingIndicator}
<Separator orientation="vertical" className="h-4" />
<div
className="flex items-center gap-1 font-mono text-[11px]"
title="Current design hash"
>
<Hash className="text-muted-foreground h-3 w-3" />
{shortHash}
{lastPersistedShort && lastPersistedShort !== shortHash && (
<span
className="text-muted-foreground/70"
title="Last persisted hash"
>
/ {lastPersistedShort}
</span>
)}
</div>
</div> </div>
{/* Middle Cluster: Aggregate Counts */} <Separator orientation="vertical" className="h-4 opacity-50" />
<div className="text-muted-foreground flex min-w-0 items-center gap-3 truncate">
<div {/* Stats */}
className="flex items-center gap-1" <div className="text-muted-foreground flex items-center gap-3 truncate">
title="Steps in current design" <span className="flex items-center gap-1.5">
> <GitBranch className="h-3.5 w-3.5 opacity-70" />
<GitBranch className="h-3 w-3" />
{steps.length} {steps.length}
<span className="hidden sm:inline"> steps</span> </span>
</div> <span className="flex items-center gap-1.5">
<div <Sparkles className="h-3.5 w-3.5 opacity-70" />
className="flex items-center gap-1"
title="Total actions across all steps"
>
<Sparkles className="h-3 w-3" />
{actionCount} {actionCount}
<span className="hidden sm:inline"> actions</span> </span>
</div>
<div
className="hidden items-center gap-1 sm:flex"
title="Auto-save setting"
>
<UploadCloud className="h-3 w-3" />
{autoSaveEnabled ? "auto-save on" : "auto-save off"}
</div>
<div
className="hidden items-center gap-1 font-mono text-[10px] sm:flex"
title={`Current design hash: ${currentDesignHash ?? 'Not computed'}`}
>
<Hash className="h-3 w-3" />
{currentDesignHash?.slice(0, 16) ?? '—'}
<Button
variant="ghost"
size="sm"
className="h-5 px-1 ml-1"
onClick={handleRecalculateHash}
aria-label="Recalculate hash"
title="Recalculate hash"
>
<RefreshCw className="h-3 w-3" />
</Button>
</div>
<div
className="text-muted-foreground/80 hidden items-center gap-1 text-[10px] font-normal tracking-wide md:flex"
title="Relative time since last save"
>
Saved {relSaved}
</div>
</div> </div>
{/* Flexible Spacer */}
<div className="flex-1" /> <div className="flex-1" />
{/* Right Cluster: Quick Actions */} {/* Actions */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 px-2" className="h-7 px-2 text-xs"
disabled={!hasUnsaved && !pendingSave} onClick={onExport}
onClick={handleSave}
aria-label="Save (s)"
title="Save (s)"
>
<Save className="mr-1 h-3 w-3" />
<span className="hidden sm:inline">Save</span>
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleValidate}
disabled={validating}
aria-label="Validate (v)"
title="Validate (v)"
>
<RefreshCw
className={cn("mr-1 h-3 w-3", validating && "animate-spin")}
/>
<span className="hidden sm:inline">Validate</span>
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleExport}
disabled={exporting} disabled={exporting}
aria-label="Export (e)" title="Export JSON"
title="Export (e)"
> >
<Download className="mr-1 h-3 w-3" /> <Download className="mr-1.5 h-3.5 w-3.5" />
<span className="hidden sm:inline">Export</span> Export
</Button>
<Separator orientation="vertical" className="mx-1 h-4" />
<Button
variant="outline"
size="sm"
className="h-7 px-2"
onClick={handlePalette}
aria-label="Command Palette (⌘K)"
title="Command Palette (⌘K)"
>
<Keyboard className="mr-1 h-3 w-3" />
<span className="hidden sm:inline">Commands</span>
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,9 @@
import * as React from "react"; import * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
import { Button } from "~/components/ui/button";
import { PanelLeft, Settings2 } from "lucide-react";
type Edge = "left" | "right"; type Edge = "left" | "right";
export interface PanelsContainerProps { export interface PanelsContainerProps {
@@ -36,6 +38,14 @@ export interface PanelsContainerProps {
/** Keyboard resize step (fractional) per arrow press; Shift increases by 2x */ /** Keyboard resize step (fractional) per arrow press; Shift increases by 2x */
keyboardStepPct?: number; keyboardStepPct?: number;
/**
* Controlled collapse state
*/
leftCollapsed?: boolean;
rightCollapsed?: boolean;
onLeftCollapseChange?: (collapsed: boolean) => void;
onRightCollapseChange?: (collapsed: boolean) => void;
} }
/** /**
@@ -43,6 +53,7 @@ export interface PanelsContainerProps {
* *
* Tailwind-first, grid-based panel layout with: * Tailwind-first, grid-based panel layout with:
* - Drag-resizable left/right panels (no persistence) * - Drag-resizable left/right panels (no persistence)
* - Collapsible side panels
* - Strict overflow containment (no page-level x-scroll) * - Strict overflow containment (no page-level x-scroll)
* - Internal y-scroll for each panel * - Internal y-scroll for each panel
* - Optional visual dividers on the center panel only (prevents double borders) * - Optional visual dividers on the center panel only (prevents double borders)
@@ -64,7 +75,7 @@ const Panel: React.FC<React.PropsWithChildren<{
children, children,
}) => ( }) => (
<section <section
className={cn("min-w-0 overflow-hidden", panelCls, panelClassName)} className={cn("min-w-0 overflow-hidden transition-[width,opacity] duration-300 ease-in-out", panelCls, panelClassName)}
> >
<div <div
className={cn( className={cn(
@@ -91,6 +102,10 @@ export function PanelsContainer({
minRightPct = 0.12, minRightPct = 0.12,
maxRightPct = 0.33, maxRightPct = 0.33,
keyboardStepPct = 0.02, keyboardStepPct = 0.02,
leftCollapsed = false,
rightCollapsed = false,
onLeftCollapseChange,
onRightCollapseChange,
}: PanelsContainerProps) { }: PanelsContainerProps) {
const hasLeft = Boolean(left); const hasLeft = Boolean(left);
const hasRight = Boolean(right); const hasRight = Boolean(right);
@@ -116,20 +131,39 @@ export function PanelsContainer({
(lp: number, rp: number) => { (lp: number, rp: number) => {
if (!hasCenter) return { l: 0, c: 0, r: 0 }; if (!hasCenter) return { l: 0, c: 0, r: 0 };
// Effective widths (0 if collapsed)
const effectiveL = leftCollapsed ? 0 : lp;
const effectiveR = rightCollapsed ? 0 : rp;
// When logic runs, we must clamp the *underlying* percentages (lp, rp)
// but return 0 for the CSS vars if collapsed.
// Actually, if collapsed, we just want the CSS var to be 0.
// But we maintain the state `leftPct` so it restores correctly.
if (hasLeft && hasRight) { if (hasLeft && hasRight) {
const l = clamp(lp, minLeftPct, maxLeftPct); // Standard clamp (on the state values)
const r = clamp(rp, minRightPct, maxRightPct); const lState = clamp(lp, minLeftPct, maxLeftPct);
const c = Math.max(0.1, 1 - (l + r)); // always preserve some center space const rState = clamp(rp, minRightPct, maxRightPct);
// Effective output
const l = leftCollapsed ? 0 : lState;
const r = rightCollapsed ? 0 : rState;
// Center takes remainder
const c = 1 - (l + r);
return { l, c, r }; return { l, c, r };
} }
if (hasLeft && !hasRight) { if (hasLeft && !hasRight) {
const l = clamp(lp, minLeftPct, maxLeftPct); const lState = clamp(lp, minLeftPct, maxLeftPct);
const c = Math.max(0.2, 1 - l); const l = leftCollapsed ? 0 : lState;
const c = 1 - l;
return { l, c, r: 0 }; return { l, c, r: 0 };
} }
if (!hasLeft && hasRight) { if (!hasLeft && hasRight) {
const r = clamp(rp, minRightPct, maxRightPct); const rState = clamp(rp, minRightPct, maxRightPct);
const c = Math.max(0.2, 1 - r); const r = rightCollapsed ? 0 : rState;
const c = 1 - r;
return { l: 0, c, r }; return { l: 0, c, r };
} }
// Center only // Center only
@@ -143,6 +177,8 @@ export function PanelsContainer({
maxLeftPct, maxLeftPct,
minRightPct, minRightPct,
maxRightPct, maxRightPct,
leftCollapsed,
rightCollapsed
], ],
); );
@@ -157,10 +193,10 @@ export function PanelsContainer({
const deltaPx = e.clientX - d.startX; const deltaPx = e.clientX - d.startX;
const deltaPct = deltaPx / d.containerWidth; const deltaPct = deltaPx / d.containerWidth;
if (d.edge === "left" && hasLeft) { if (d.edge === "left" && hasLeft && !leftCollapsed) {
const nextLeft = clamp(d.startLeft + deltaPct, minLeftPct, maxLeftPct); const nextLeft = clamp(d.startLeft + deltaPct, minLeftPct, maxLeftPct);
setLeftPct(nextLeft); setLeftPct(nextLeft);
} else if (d.edge === "right" && hasRight) { } else if (d.edge === "right" && hasRight && !rightCollapsed) {
// Dragging the right edge moves leftwards as delta increases // Dragging the right edge moves leftwards as delta increases
const nextRight = clamp( const nextRight = clamp(
d.startRight - deltaPct, d.startRight - deltaPct,
@@ -170,7 +206,7 @@ export function PanelsContainer({
setRightPct(nextRight); setRightPct(nextRight);
} }
}, },
[hasLeft, hasRight, minLeftPct, maxLeftPct, minRightPct, maxRightPct], [hasLeft, hasRight, minLeftPct, maxLeftPct, minRightPct, maxRightPct, leftCollapsed, rightCollapsed],
); );
const endDrag = React.useCallback(() => { const endDrag = React.useCallback(() => {
@@ -213,14 +249,14 @@ export function PanelsContainer({
const step = (e.shiftKey ? 2 : 1) * keyboardStepPct; const step = (e.shiftKey ? 2 : 1) * keyboardStepPct;
if (edge === "left" && hasLeft) { if (edge === "left" && hasLeft && !leftCollapsed) {
const next = clamp( const next = clamp(
leftPct + (e.key === "ArrowRight" ? step : -step), leftPct + (e.key === "ArrowRight" ? step : -step),
minLeftPct, minLeftPct,
maxLeftPct, maxLeftPct,
); );
setLeftPct(next); setLeftPct(next);
} else if (edge === "right" && hasRight) { } else if (edge === "right" && hasRight && !rightCollapsed) {
const next = clamp( const next = clamp(
rightPct + (e.key === "ArrowLeft" ? step : -step), rightPct + (e.key === "ArrowLeft" ? step : -step),
minRightPct, minRightPct,
@@ -231,23 +267,33 @@ export function PanelsContainer({
}; };
// CSS variables for the grid fractions // CSS variables for the grid fractions
// We use FR units instead of % to let the browser handle exact pixel fitting without rounding errors causing overflow
const styleVars: React.CSSProperties & Record<string, string> = hasCenter const styleVars: React.CSSProperties & Record<string, string> = hasCenter
? { ? {
"--col-left": `${(hasLeft ? l : 0) * 100}%`, "--col-left": `${hasLeft ? l : 0}fr`,
"--col-center": `${c * 100}%`, "--col-center": `${c}fr`,
"--col-right": `${(hasRight ? r : 0) * 100}%`, "--col-right": `${hasRight ? r : 0}fr`,
} }
: {}; : {};
// Explicit grid template depending on which side panels exist // Explicit grid template depending on which side panels exist
const gridAreas =
hasLeft && hasRight
? '"left center right"'
: hasLeft && !hasRight
? '"left center"'
: !hasLeft && hasRight
? '"center right"'
: '"center"';
const gridCols = const gridCols =
hasLeft && hasRight hasLeft && hasRight
? "[grid-template-columns:minmax(0,var(--col-left))_minmax(0,var(--col-center))_minmax(0,var(--col-right))]" ? "[grid-template-columns:var(--col-left)_var(--col-center)_var(--col-right)]"
: hasLeft && !hasRight : hasLeft && !hasRight
? "[grid-template-columns:minmax(0,var(--col-left))_minmax(0,var(--col-center))]" ? "[grid-template-columns:var(--col-left)_var(--col-center)]"
: !hasLeft && hasRight : !hasLeft && hasRight
? "[grid-template-columns:minmax(0,var(--col-center))_minmax(0,var(--col-right))]" ? "[grid-template-columns:var(--col-center)_var(--col-right)]"
: "[grid-template-columns:minmax(0,1fr)]"; : "[grid-template-columns:1fr]";
// Dividers on the center panel only (prevents double borders if children have their own borders) // Dividers on the center panel only (prevents double borders if children have their own borders)
const centerDividers = const centerDividers =
@@ -261,17 +307,77 @@ export function PanelsContainer({
return ( return (
<>
{/* Mobile Layout (Flex + Sheets) */}
<div className={cn("flex flex-col h-full w-full md:hidden", className)}>
{/* Mobile Header/Toolbar for access to panels */}
<div className="flex items-center justify-between border-b px-4 py-2 bg-background">
<div className="flex items-center gap-2">
{hasLeft && (
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8">
<PanelLeft className="h-4 w-4" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-[85vw] p-0 sm:max-w-md">
<div className="h-full overflow-hidden">
{left}
</div>
</SheetContent>
</Sheet>
)}
<span className="text-sm font-medium">Designer</span>
</div>
{hasRight && (
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8">
<Settings2 className="h-4 w-4" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[85vw] p-0 sm:max-w-md">
<div className="h-full overflow-hidden">
{right}
</div>
</SheetContent>
</Sheet>
)}
</div>
{/* Main Content (Center) */}
<div className="flex-1 min-h-0 min-w-0 overflow-hidden relative">
{center}
</div>
</div>
{/* Desktop Layout (Grid) */}
<div <div
ref={rootRef} ref={rootRef}
aria-label={ariaLabel} aria-label={ariaLabel}
style={styleVars}
className={cn( className={cn(
"relative grid h-full min-h-0 w-full overflow-hidden select-none", "relative hidden md:grid h-full min-h-0 w-full max-w-full overflow-hidden select-none",
gridCols, // 2-3-2 ratio for left-center-right panels when all visible
hasLeft && hasRight && !leftCollapsed && !rightCollapsed && "grid-cols-[2fr_3fr_2fr]",
// Left collapsed: center + right (3:2 ratio)
hasLeft && hasRight && leftCollapsed && !rightCollapsed && "grid-cols-[3fr_2fr]",
// Right collapsed: left + center (2:3 ratio)
hasLeft && hasRight && !leftCollapsed && rightCollapsed && "grid-cols-[2fr_3fr]",
// Both collapsed: center only
hasLeft && hasRight && leftCollapsed && rightCollapsed && "grid-cols-1",
// Only left and center
hasLeft && !hasRight && !leftCollapsed && "grid-cols-[2fr_3fr]",
hasLeft && !hasRight && leftCollapsed && "grid-cols-1",
// Only center and right
!hasLeft && hasRight && !rightCollapsed && "grid-cols-[3fr_2fr]",
!hasLeft && hasRight && rightCollapsed && "grid-cols-1",
// Only center
!hasLeft && !hasRight && "grid-cols-1",
className, className,
)} )}
> >
{hasLeft && ( {hasLeft && !leftCollapsed && (
<Panel <Panel
panelClassName={panelClassName} panelClassName={panelClassName}
contentClassName={contentClassName} contentClassName={contentClassName}
@@ -290,7 +396,7 @@ export function PanelsContainer({
</Panel> </Panel>
)} )}
{hasRight && ( {hasRight && !rightCollapsed && (
<Panel <Panel
panelClassName={panelClassName} panelClassName={panelClassName}
contentClassName={contentClassName} contentClassName={contentClassName}
@@ -299,43 +405,29 @@ export function PanelsContainer({
</Panel> </Panel>
)} )}
{/* Resize handles (only render where applicable) */} {/* Resize Handles */}
{hasCenter && hasLeft && ( {hasLeft && !leftCollapsed && (
<button <button
type="button" type="button"
role="separator" className="absolute top-0 bottom-0 w-1.5 -ml-0.75 z-50 cursor-col-resize hover:bg-blue-400/50 transition-colors focus:outline-none"
aria-label="Resize left panel" style={{ left: "var(--col-left)" }}
aria-orientation="vertical"
onPointerDown={startDrag("left")} onPointerDown={startDrag("left")}
onKeyDown={onKeyResize("left")} onKeyDown={onKeyResize("left")}
className={cn( aria-label="Resize left panel"
"absolute inset-y-0 z-10 w-1 cursor-col-resize outline-none",
"focus-visible:ring-ring focus-visible:ring-2",
)}
// Position at the boundary between left and center
style={{ left: "var(--col-left)", transform: "translateX(-0.5px)" }}
tabIndex={0}
/> />
)} )}
{hasRight && !rightCollapsed && (
{hasCenter && hasRight && (
<button <button
type="button" type="button"
role="separator" className="absolute top-0 bottom-0 w-1.5 -mr-0.75 z-50 cursor-col-resize hover:bg-blue-400/50 transition-colors focus:outline-none"
aria-label="Resize right panel" style={{ right: "var(--col-right)" }}
aria-orientation="vertical"
onPointerDown={startDrag("right")} onPointerDown={startDrag("right")}
onKeyDown={onKeyResize("right")} onKeyDown={onKeyResize("right")}
className={cn( aria-label="Resize right panel"
"absolute inset-y-0 z-10 w-1 cursor-col-resize outline-none",
"focus-visible:ring-ring focus-visible:ring-2",
)}
// Position at the boundary between center and right (offset from the right)
style={{ right: "var(--col-right)", transform: "translateX(0.5px)" }}
tabIndex={0}
/> />
)} )}
</div> </div>
</>
); );
} }

View File

@@ -22,6 +22,7 @@ import {
Eye, Eye,
X, X,
Layers, Layers,
PanelLeftClose,
} from "lucide-react"; } from "lucide-react";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@@ -108,7 +109,7 @@ function DraggableAction({
{...listeners} {...listeners}
style={style} style={style}
className={cn( className={cn(
"group bg-background/60 hover:bg-accent/50 relative flex w-full cursor-grab touch-none flex-col gap-1 rounded border px-2 text-left transition-colors select-none", "group bg-background/60 hover:bg-accent/50 relative flex w-full cursor-grab touch-none flex-col gap-1 rounded-lg border px-2 text-left transition-colors select-none",
compact ? "py-1.5 text-[11px]" : "py-2 text-[12px]", compact ? "py-1.5 text-[11px]" : "py-2 text-[12px]",
isDragging && "opacity-50", isDragging && "opacity-50",
)} )}
@@ -168,7 +169,12 @@ function DraggableAction({
); );
} }
export function ActionLibraryPanel() { export interface ActionLibraryPanelProps {
collapsed?: boolean;
onCollapse?: (collapsed: boolean) => void;
}
export function ActionLibraryPanel({ collapsed, onCollapse }: ActionLibraryPanelProps = {}) {
const registry = useActionRegistry(); const registry = useActionRegistry();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");

View File

@@ -2,6 +2,7 @@
import React, { useMemo, useState, useCallback } from "react"; import React, { useMemo, useState, useCallback } from "react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { useDesignerStore } from "../state/store"; import { useDesignerStore } from "../state/store";
@@ -18,6 +19,7 @@ import {
AlertTriangle, AlertTriangle,
GitBranch, GitBranch,
PackageSearch, PackageSearch,
PanelRightClose,
} from "lucide-react"; } from "lucide-react";
/** /**
@@ -47,6 +49,11 @@ export interface InspectorPanelProps {
* Called when user changes tab (only if activeTab not externally controlled). * Called when user changes tab (only if activeTab not externally controlled).
*/ */
onTabChange?: (tab: "properties" | "issues" | "dependencies") => void; onTabChange?: (tab: "properties" | "issues" | "dependencies") => void;
/**
* Collapse state and handler
*/
collapsed?: boolean;
onCollapse?: (collapsed: boolean) => void;
/** /**
* If true, auto-switch to "properties" when a selection occurs. * If true, auto-switch to "properties" when a selection occurs.
*/ */
@@ -60,6 +67,10 @@ export interface InspectorPanelProps {
name: string; name: string;
version: string; version: string;
}>; }>;
/**
* Called to clear all validation issues.
*/
onClearAll?: () => void;
} }
export function InspectorPanel({ export function InspectorPanel({
@@ -68,6 +79,9 @@ export function InspectorPanel({
onTabChange, onTabChange,
autoFocusOnSelection = true, autoFocusOnSelection = true,
studyPlugins, studyPlugins,
collapsed,
onCollapse,
onClearAll,
}: InspectorPanelProps) { }: InspectorPanelProps) {
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
/* Store Selectors */ /* Store Selectors */
@@ -314,6 +328,7 @@ export function InspectorPanel({
> >
<ValidationPanel <ValidationPanel
issues={validationIssues} issues={validationIssues}
onClearAll={onClearAll}
entityLabelForId={(entityId) => { entityLabelForId={(entityId) => {
if (entityId.startsWith("action-")) { if (entityId.startsWith("action-")) {
for (const s of steps) { for (const s of steps) {

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

@@ -167,8 +167,6 @@ function cloneSteps(steps: ExperimentStep[]): ExperimentStep[] {
function reindexSteps(steps: ExperimentStep[]): ExperimentStep[] { function reindexSteps(steps: ExperimentStep[]): ExperimentStep[] {
return steps return steps
.slice()
.sort((a, b) => a.order - b.order)
.map((s, idx) => ({ ...s, order: idx })); .map((s, idx) => ({ ...s, order: idx }));
} }

View File

@@ -49,11 +49,11 @@ export interface ValidationResult {
/* Validation Rule Sets */ /* Validation Rule Sets */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
// Steps should ALWAYS execute sequentially
// Parallel/conditional/loop execution happens at the ACTION level, not step level
const VALID_STEP_TYPES: StepType[] = [ const VALID_STEP_TYPES: StepType[] = [
"sequential", "sequential",
"parallel",
"conditional", "conditional",
"loop",
]; ];
const VALID_TRIGGER_TYPES: TriggerType[] = [ const VALID_TRIGGER_TYPES: TriggerType[] = [
"trial_start", "trial_start",
@@ -144,48 +144,8 @@ export function validateStructural(
}); });
} }
// Conditional step must have conditions // All steps must be sequential type (parallel/conditional/loop removed)
if (step.type === "conditional") { // Control flow and parallelism should be implemented at the ACTION level
const conditionKeys = Object.keys(step.trigger.conditions || {});
if (conditionKeys.length === 0) {
issues.push({
severity: "error",
message: "Conditional step must define at least one condition",
category: "structural",
field: "trigger.conditions",
stepId,
suggestion: "Add conditions to define when this step should execute",
});
}
}
// Loop step should have termination conditions
if (step.type === "loop") {
const conditionKeys = Object.keys(step.trigger.conditions || {});
if (conditionKeys.length === 0) {
issues.push({
severity: "warning",
message:
"Loop step should define termination conditions to prevent infinite loops",
category: "structural",
field: "trigger.conditions",
stepId,
suggestion: "Add conditions to control when the loop should exit",
});
}
}
// Parallel step should have multiple actions
if (step.type === "parallel" && step.actions.length < 2) {
issues.push({
severity: "warning",
message:
"Parallel step has fewer than 2 actions - consider using sequential type",
category: "structural",
stepId,
suggestion: "Add more actions or change to sequential execution",
});
}
// Action-level structural validation // Action-level structural validation
step.actions.forEach((action) => { step.actions.forEach((action) => {
@@ -234,6 +194,7 @@ export function validateStructural(
} }
// Plugin actions need plugin metadata // Plugin actions need plugin metadata
/* VALIDATION DISABLED BY USER REQUEST
if (action.source?.kind === "plugin") { if (action.source?.kind === "plugin") {
if (!action.source.pluginId) { if (!action.source.pluginId) {
issues.push({ issues.push({
@@ -258,6 +219,7 @@ export function validateStructural(
}); });
} }
} }
*/
// Execution descriptor validation // Execution descriptor validation
if (!action.execution?.transport) { if (!action.execution?.transport) {
@@ -430,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({
@@ -532,10 +522,9 @@ export function validateSemantic(
// Check for empty steps // Check for empty steps
steps.forEach((step) => { steps.forEach((step) => {
if (step.actions.length === 0) { if (step.actions.length === 0) {
const severity = step.type === "parallel" ? "error" : "warning";
issues.push({ issues.push({
severity, severity: "warning",
message: `${step.type} step has no actions`, message: "Step has no actions",
category: "semantic", category: "semantic",
stepId: step.id, stepId: step.id,
suggestion: "Add actions to this step or remove it", suggestion: "Add actions to this step or remove it",
@@ -635,25 +624,9 @@ export function validateExecution(
): ValidationIssue[] { ): ValidationIssue[] {
const issues: ValidationIssue[] = []; const issues: ValidationIssue[] = [];
// Check for unreachable steps (basic heuristic) // Note: Trigger validation removed - convertDatabaseToSteps() automatically assigns
if (steps.length > 1) { // correct triggers (trial_start for first step, previous_step for others) based on orderIndex.
const trialStartSteps = steps.filter( // Manual trigger configuration is intentional for advanced workflows.
(s) => s.trigger.type === "trial_start",
);
if (trialStartSteps.length > 1) {
trialStartSteps.slice(1).forEach((step) => {
issues.push({
severity: "info",
message:
"This step will start immediately at trial start. For sequential flow, use 'Previous Step' trigger.",
category: "execution",
field: "trigger.type",
stepId: step.id,
suggestion: "Change trigger to 'Previous Step' if this step should follow the previous one",
});
});
}
}
// Check for missing robot dependencies // Check for missing robot dependencies
const robotActions = steps.flatMap((step) => const robotActions = steps.flatMap((step) =>

View File

@@ -27,6 +27,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { api } from "~/trpc/react";
export type Experiment = { export type Experiment = {
id: string; id: string;
@@ -78,27 +79,23 @@ const statusConfig = {
}; };
function ExperimentActionsCell({ experiment }: { experiment: Experiment }) { function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
const handleDelete = async () => { const utils = api.useUtils();
const deleteMutation = api.experiments.delete.useMutation({
onSuccess: () => {
toast.success("Experiment deleted successfully");
utils.experiments.list.invalidate();
},
onError: (error) => {
toast.error(`Failed to delete experiment: ${error.message}`);
},
});
const handleDelete = () => {
if ( if (
window.confirm(`Are you sure you want to delete "${experiment.name}"?`) window.confirm(`Are you sure you want to delete "${experiment.name}"?`)
) { ) {
try { deleteMutation.mutate({ id: experiment.id });
// TODO: Implement delete experiment mutation
toast.success("Experiment deleted successfully");
} catch {
toast.error("Failed to delete experiment");
} }
}
};
const handleCopyId = () => {
void navigator.clipboard.writeText(experiment.id);
toast.success("Experiment ID copied to clipboard");
};
const handleStartTrial = () => {
// Navigate to new trial creation with this experiment pre-selected
window.location.href = `/studies/${experiment.studyId}/trials/new?experimentId=${experiment.id}`;
}; };
return ( return (
@@ -111,45 +108,20 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}> <Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
<Eye className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
View Details Edit Metadata
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}> <Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
<FlaskConical className="mr-2 h-4 w-4" /> <FlaskConical className="mr-2 h-4 w-4" />
Open Designer Design
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
{experiment.canEdit && (
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Experiment
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{experiment.status === "ready" && (
<DropdownMenuItem onClick={handleStartTrial}>
<Play className="mr-2 h-4 w-4" />
Start New Trial
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleCopyId}>
<Copy className="mr-2 h-4 w-4" />
Copy Experiment ID
</DropdownMenuItem>
{experiment.canDelete && ( {experiment.canDelete && (
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -158,7 +130,7 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
className="text-red-600 focus:text-red-600" className="text-red-600 focus:text-red-600"
> >
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Delete Experiment Delete
</DropdownMenuItem> </DropdownMenuItem>
</> </>
)} )}
@@ -315,20 +287,7 @@ export const experimentsColumns: ColumnDef<Experiment>[] = [
}, },
enableSorting: false, enableSorting: false,
}, },
{
accessorKey: "createdAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Created" />
),
cell: ({ row }) => {
const date = row.getValue("createdAt");
return (
<div className="text-sm whitespace-nowrap">
{formatDistanceToNow(date as Date, { addSuffix: true })}
</div>
);
},
},
{ {
accessorKey: "updatedAt", accessorKey: "updatedAt",
header: ({ column }) => ( header: ({ column }) => (

View File

@@ -0,0 +1,190 @@
"use client";
import { useState } from "react";
import { Upload, X, FileText, CheckCircle, AlertCircle, Loader2 } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Progress } from "~/components/ui/progress";
import { api } from "~/trpc/react";
import { toast } from "~/components/ui/use-toast";
import { cn } from "~/lib/utils";
import axios from "axios";
interface ConsentUploadFormProps {
studyId: string;
participantId: string;
consentFormId: string;
onSuccess: () => void;
onCancel: () => void;
}
export function ConsentUploadForm({
studyId,
participantId,
consentFormId,
onSuccess,
onCancel,
}: ConsentUploadFormProps) {
const [file, setFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
// Mutations
const getUploadUrlMutation = api.participants.getConsentUploadUrl.useMutation();
const recordConsentMutation = api.participants.recordConsent.useMutation();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const selectedFile = e.target.files[0];
// Validate size (10MB)
if (selectedFile.size > 10 * 1024 * 1024) {
toast({
title: "File too large",
description: "Maximum file size is 10MB",
variant: "destructive",
});
return;
}
// Validate type
const allowedTypes = ["application/pdf", "image/png", "image/jpeg", "image/jpg"];
if (!allowedTypes.includes(selectedFile.type)) {
toast({
title: "Invalid file type",
description: "Please upload a PDF, PNG, or JPG file",
variant: "destructive",
});
return;
}
setFile(selectedFile);
}
};
const handleUpload = async () => {
if (!file) return;
try {
setIsUploading(true);
setUploadProgress(0);
// 1. Get Presigned URL
const { url, key } = await getUploadUrlMutation.mutateAsync({
studyId,
participantId,
filename: file.name,
contentType: file.type,
size: file.size,
});
// 2. Upload to MinIO
await axios.put(url, file, {
headers: {
"Content-Type": file.type,
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
setUploadProgress(percentCompleted);
}
},
});
// 3. Record Consent in DB
await recordConsentMutation.mutateAsync({
participantId,
consentFormId,
storagePath: key,
});
toast({
title: "Consent Recorded",
description: "The consent form has been uploaded and recorded successfully.",
});
onSuccess();
} catch (error) {
console.error("Upload failed:", error);
toast({
title: "Upload Failed",
description: error instanceof Error ? error.message : "An unexpected error occurred",
variant: "destructive",
});
setIsUploading(false);
}
};
return (
<div className="space-y-4">
{!file ? (
<div className="flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-6 bg-muted/5 hover:bg-muted/10 transition-colors">
<Upload className="h-8 w-8 text-muted-foreground mb-4" />
<h3 className="font-semibold text-sm mb-1">Upload Signed Consent</h3>
<p className="text-xs text-muted-foreground mb-4 text-center">
Drag and drop or click to select<br />
PDF, PNG, JPG up to 10MB
</p>
<input
type="file"
id="consent-file-upload"
className="hidden"
accept=".pdf,.png,.jpg,.jpeg"
onChange={handleFileChange}
/>
<Button variant="secondary" size="sm" onClick={() => document.getElementById("consent-file-upload")?.click()}>
Select File
</Button>
</div>
) : (
<div className="border rounded-lg p-4 bg-muted/5">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 bg-primary/10 rounded flex items-center justify-center">
<FileText className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm font-medium line-clamp-1 break-all">{file.name}</p>
<p className="text-xs text-muted-foreground">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
{!isUploading && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setFile(null)}>
<X className="h-4 w-4" />
</Button>
)}
</div>
{isUploading && (
<div className="space-y-2 mb-4">
<div className="flex justify-between text-xs text-muted-foreground">
<span>Uploading...</span>
<span>{uploadProgress}%</span>
</div>
<Progress value={uploadProgress} className="h-2" />
</div>
)}
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={onCancel} disabled={isUploading}>
Cancel
</Button>
<Button size="sm" onClick={handleUpload} disabled={isUploading}>
{isUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Uploading
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Upload & Record
</>
)}
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,161 @@
"use client";
import { useState } from "react";
import { api } from "~/trpc/react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { ConsentUploadForm } from "./ConsentUploadForm";
import { FileText, Download, CheckCircle, AlertCircle, Upload } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "~/components/ui/badge";
import { cn } from "~/lib/utils";
interface ParticipantConsentManagerProps {
studyId: string;
participantId: string;
consentGiven: boolean;
consentDate: Date | null;
existingConsent: {
id: string;
storagePath: string | null;
signedAt: Date;
consentForm: {
title: string;
version: number;
};
} | null;
}
export function ParticipantConsentManager({
studyId,
participantId,
consentGiven,
consentDate,
existingConsent,
}: ParticipantConsentManagerProps) {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
// Fetch active consent forms to know which form to sign/upload against
const { data: consentForms } = api.participants.getConsentForms.useQuery({ studyId });
const activeForm = consentForms?.find((f) => f.active) ?? consentForms?.[0];
// Helper to get download URL
const { refetch: fetchDownloadUrl } = api.files.getDownloadUrl.useQuery(
{ storagePath: existingConsent?.storagePath ?? "" },
{ enabled: false }
);
const handleDownload = async () => {
if (!existingConsent?.storagePath) return;
try {
const result = await fetchDownloadUrl();
if (result.data?.url) {
window.open(result.data.url, "_blank");
} else {
toast.error("Error", { description: "Could not retrieve document" });
}
} catch (error) {
toast.error("Error", { description: "Failed to get download URL" });
}
};
const handleSuccess = () => {
setIsOpen(false);
utils.participants.get.invalidate({ id: participantId });
toast.success("Success", { description: "Consent recorded successfully" });
};
return (
<div className="rounded-lg border bg-card text-card-foreground shadow-sm">
<div className="p-6 flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex flex-col space-y-1.5">
<h3 className="font-semibold leading-none tracking-tight flex items-center gap-2">
<FileText className="h-5 w-5" />
Consent Status
</h3>
<p className="text-sm text-muted-foreground">
Manage participant consent and forms.
</p>
</div>
<Badge variant={consentGiven ? "default" : "destructive"}>
{consentGiven ? "Consent Given" : "Not Recorded"}
</Badge>
</div>
<div className="p-6 pt-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
{consentGiven ? (
<>
<div className="flex items-center gap-2 text-sm font-medium">
<CheckCircle className="h-4 w-4 text-green-600" />
Signed on {consentDate ? new Date(consentDate).toLocaleDateString() : "Unknown date"}
</div>
{existingConsent && (
<p className="text-xs text-muted-foreground">
Form: {existingConsent.consentForm.title} (v{existingConsent.consentForm.version})
</p>
)}
</>
) : (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertCircle className="h-4 w-4" />
No consent recorded for this participant.
</div>
)}
</div>
<div className="flex gap-2">
{consentGiven && existingConsent?.storagePath && (
<Button variant="outline" size="sm" onClick={handleDownload}>
<Download className="mr-2 h-4 w-4" />
Download PDF
</Button>
)}
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button size="sm" variant={consentGiven ? "secondary" : "default"}>
<Upload className="mr-2 h-4 w-4" />
{consentGiven ? "Update Consent" : "Record Consent"}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Upload Signed Consent Form</DialogTitle>
<DialogDescription>
Upload the signed PDF or image of the consent form for this participant.
{activeForm && (
<span className="block mt-1 font-medium text-foreground">
Active Form: {activeForm.title} (v{activeForm.version})
</span>
)}
</DialogDescription>
</DialogHeader>
{activeForm ? (
<ConsentUploadForm
studyId={studyId}
participantId={participantId}
consentFormId={activeForm.id}
onSuccess={handleSuccess}
onCancel={() => setIsOpen(false)}
/>
) : (
<div className="py-4 text-center text-muted-foreground">
No active consent form found for this study. Please create one first.
</div>
)}
</DialogContent>
</Dialog>
</div>
</div>
</div>
</div>
);
}

View File

@@ -256,14 +256,15 @@ export function ParticipantForm({
<> <>
<FormSection <FormSection
title="Participant Information" title="Participant Information"
description="Basic information about the research participant." description="Basic identity and study association."
> >
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<FormField> <FormField>
<Label htmlFor="participantCode">Participant Code *</Label> <Label htmlFor="participantCode">Participant Code *</Label>
<Input <Input
id="participantCode" id="participantCode"
{...form.register("participantCode")} {...form.register("participantCode")}
placeholder="e.g., P001, SUBJ_01, etc." placeholder="e.g., P001"
className={ className={
form.formState.errors.participantCode ? "border-red-500" : "" form.formState.errors.participantCode ? "border-red-500" : ""
} }
@@ -273,9 +274,6 @@ export function ParticipantForm({
{form.formState.errors.participantCode.message} {form.formState.errors.participantCode.message}
</p> </p>
)} )}
<p className="text-muted-foreground text-xs">
Unique identifier for this participant within the study
</p>
</FormField> </FormField>
<FormField> <FormField>
@@ -283,7 +281,7 @@ export function ParticipantForm({
<Input <Input
id="name" id="name"
{...form.register("name")} {...form.register("name")}
placeholder="Optional: Participant's full name" placeholder="Optional name"
className={form.formState.errors.name ? "border-red-500" : ""} className={form.formState.errors.name ? "border-red-500" : ""}
/> />
{form.formState.errors.name && ( {form.formState.errors.name && (
@@ -291,9 +289,6 @@ export function ParticipantForm({
{form.formState.errors.name.message} {form.formState.errors.name.message}
</p> </p>
)} )}
<p className="text-muted-foreground text-xs">
Optional: Real name for contact purposes
</p>
</FormField> </FormField>
<FormField> <FormField>
@@ -310,11 +305,17 @@ export function ParticipantForm({
{form.formState.errors.email.message} {form.formState.errors.email.message}
</p> </p>
)} )}
<p className="text-muted-foreground text-xs">
Optional: For scheduling and communication
</p>
</FormField> </FormField>
</div>
</FormSection>
<div className="my-6" />
<FormSection
title="Demographics & Study"
description="study association and demographic details."
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<FormField> <FormField>
<Label htmlFor="studyId">Study *</Label> <Label htmlFor="studyId">Study *</Label>
<Select <Select
@@ -323,11 +324,13 @@ export function ParticipantForm({
disabled={studiesLoading || mode === "edit"} disabled={studiesLoading || mode === "edit"}
> >
<SelectTrigger <SelectTrigger
className={form.formState.errors.studyId ? "border-red-500" : ""} className={
form.formState.errors.studyId ? "border-red-500" : ""
}
> >
<SelectValue <SelectValue
placeholder={ placeholder={
studiesLoading ? "Loading studies..." : "Select a study" studiesLoading ? "Loading..." : "Select study"
} }
/> />
</SelectTrigger> </SelectTrigger>
@@ -344,18 +347,8 @@ export function ParticipantForm({
{form.formState.errors.studyId.message} {form.formState.errors.studyId.message}
</p> </p>
)} )}
{mode === "edit" && (
<p className="text-muted-foreground text-xs">
Study cannot be changed after registration
</p>
)}
</FormField> </FormField>
</FormSection>
<FormSection
title="Demographics"
description="Optional demographic information for research purposes."
>
<FormField> <FormField>
<Label htmlFor="age">Age</Label> <Label htmlFor="age">Age</Label>
<Input <Input
@@ -372,9 +365,6 @@ export function ParticipantForm({
{form.formState.errors.age.message} {form.formState.errors.age.message}
</p> </p>
)} )}
<p className="text-muted-foreground text-xs">
Optional: Age in years (minimum 18)
</p>
</FormField> </FormField>
<FormField> <FormField>
@@ -394,7 +384,7 @@ export function ParticipantForm({
} }
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select gender (optional)" /> <SelectValue placeholder="Select gender" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="male">Male</SelectItem> <SelectItem value="male">Male</SelectItem>
@@ -406,10 +396,8 @@ export function ParticipantForm({
<SelectItem value="other">Other</SelectItem> <SelectItem value="other">Other</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-muted-foreground text-xs">
Optional: Gender identity for demographic analysis
</p>
</FormField> </FormField>
</div>
</FormSection> </FormSection>
{mode === "create" && ( {mode === "create" && (
@@ -505,8 +493,7 @@ export function ParticipantForm({
error={error} error={error}
onDelete={mode === "edit" ? onDelete : undefined} onDelete={mode === "edit" ? onDelete : undefined}
isDeleting={isDeleting} isDeleting={isDeleting}
sidebar={mode === "create" ? sidebar : undefined}
// sidebar={sidebar} // Removed for cleaner UI per user request
submitText={mode === "create" ? "Register Participant" : "Save Changes"} submitText={mode === "create" ? "Register Participant" : "Save Changes"}
> >
{formFields} {formFields}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { type ColumnDef } from "@tanstack/react-table"; import { type ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, Mail, Trash2 } from "lucide-react"; import { ArrowUpDown, MoreHorizontal, Edit, Trash2 } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
@@ -24,6 +24,12 @@ import {
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { useStudyContext } from "~/lib/study-context"; import { useStudyContext } from "~/lib/study-context";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
export type Participant = { export type Participant = {
id: string; id: string;
@@ -101,16 +107,32 @@ export const columns: ColumnDef<Participant>[] = [
const name = row.getValue("name"); const name = row.getValue("name");
const email = row.original.email; const email = row.original.email;
return ( return (
<TooltipProvider>
<div> <div>
<div className="truncate font-medium"> <div className="truncate font-medium max-w-[200px]">
{String(name) || "No name provided"} <Tooltip>
<TooltipTrigger asChild>
<span>{String(name) || "No name provided"}</span>
</TooltipTrigger>
<TooltipContent>
<p>{String(name) || "No name provided"}</p>
</TooltipContent>
</Tooltip>
</div> </div>
{email && ( {email && (
<div className="text-muted-foreground truncate text-sm"> <div className="text-muted-foreground truncate text-sm max-w-[200px]">
{email} <Tooltip>
<TooltipTrigger asChild>
<span>{email}</span>
</TooltipTrigger>
<TooltipContent>
<p>{email}</p>
</TooltipContent>
</Tooltip>
</div> </div>
)} )}
</div> </div>
</TooltipProvider>
); );
}, },
}, },
@@ -120,11 +142,30 @@ export const columns: ColumnDef<Participant>[] = [
cell: ({ row }) => { cell: ({ row }) => {
const consentGiven = row.getValue("consentGiven"); const consentGiven = row.getValue("consentGiven");
if (consentGiven) { return (
return <Badge className="bg-green-100 text-green-800">Consented</Badge>; <TooltipProvider>
} <Tooltip>
<TooltipTrigger>
return <Badge className="bg-red-100 text-red-800">Pending</Badge>; {consentGiven ? (
<Badge className="bg-green-100 text-green-800 hover:bg-green-200">
Consented
</Badge>
) : (
<Badge className="bg-red-100 text-red-800 hover:bg-red-200">
Pending
</Badge>
)}
</TooltipTrigger>
<TooltipContent>
<p>
{consentGiven
? "Participant has signed the consent form."
: "Consent form has not been recorded."}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}, },
}, },
{ {
@@ -148,30 +189,7 @@ export const columns: ColumnDef<Participant>[] = [
); );
}, },
}, },
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Created
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const date = row.getValue("createdAt");
return (
<div className="text-muted-foreground text-sm">
{formatDistanceToNow(new Date(date as string | number | Date), {
addSuffix: true,
})}
</div>
);
},
},
{ {
id: "actions", id: "actions",
enableHiding: false, enableHiding: false,
@@ -195,23 +213,12 @@ export const columns: ColumnDef<Participant>[] = [
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(participant.id)}
>
<Copy className="mr-2 h-4 w-4" />
Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/studies/${studyId}/participants/${participant.id}/edit`}> <Link href={`/studies/${studyId}/participants/${participant.id}/edit`}>
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Edit participant Edit participant
</Link > </Link >
</DropdownMenuItem > </DropdownMenuItem >
<DropdownMenuItem disabled>
<Mail className="mr-2 h-4 w-4" />
Send consent
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600"> <DropdownMenuItem className="text-red-600">
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />

View File

@@ -14,6 +14,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "~/components/ui/select"; } from "~/components/ui/select";
import { Separator } from "~/components/ui/separator";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
import { import {
EntityForm, EntityForm,
@@ -165,10 +166,12 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
// Form fields // Form fields
const formFields = ( const formFields = (
<div className="space-y-6">
<FormSection <FormSection
title="Study Details" title="Study Details"
description="Basic information about your research study." description="Basic information and status of your research study."
> >
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField> <FormField>
<Label htmlFor="tour-study-name">Study Name *</Label> <Label htmlFor="tour-study-name">Study Name *</Label>
<Input <Input
@@ -184,6 +187,36 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
)} )}
</FormField> </FormField>
<FormField>
<Label htmlFor="status">Status</Label>
<Select
value={form.watch("status")}
onValueChange={(value) =>
form.setValue(
"status",
value as "draft" | "active" | "completed" | "archived",
)
}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft - Study in preparation</SelectItem>
<SelectItem value="active">
Active - Currently recruiting/running
</SelectItem>
<SelectItem value="completed">
Completed - Data collection finished
</SelectItem>
<SelectItem value="archived">
Archived - Study concluded
</SelectItem>
</SelectContent>
</Select>
</FormField>
<div className="md:col-span-2">
<FormField> <FormField>
<Label htmlFor="tour-study-description">Description *</Label> <Label htmlFor="tour-study-description">Description *</Label>
<Textarea <Textarea
@@ -191,7 +224,9 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
{...form.register("description")} {...form.register("description")}
placeholder="Describe the research objectives, methodology, and expected outcomes..." placeholder="Describe the research objectives, methodology, and expected outcomes..."
rows={4} rows={4}
className={form.formState.errors.description ? "border-red-500" : ""} className={
form.formState.errors.description ? "border-red-500" : ""
}
/> />
{form.formState.errors.description && ( {form.formState.errors.description && (
<p className="text-sm text-red-600"> <p className="text-sm text-red-600">
@@ -199,14 +234,26 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
</p> </p>
)} )}
</FormField> </FormField>
</div>
</div>
</FormSection>
<Separator />
<FormSection
title="Configuration"
description="Institutional details and ethics approval."
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField> <FormField>
<Label htmlFor="institution">Institution *</Label> <Label htmlFor="institution">Institution *</Label>
<Input <Input
id="institution" id="institution"
{...form.register("institution")} {...form.register("institution")}
placeholder="e.g., University of Technology" placeholder="e.g., University of Technology"
className={form.formState.errors.institution ? "border-red-500" : ""} className={
form.formState.errors.institution ? "border-red-500" : ""
}
/> />
{form.formState.errors.institution && ( {form.formState.errors.institution && (
<p className="text-sm text-red-600"> <p className="text-sm text-red-600">
@@ -234,34 +281,9 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
Optional: Institutional Review Board protocol number if applicable Optional: Institutional Review Board protocol number if applicable
</p> </p>
</FormField> </FormField>
</div>
<FormField>
<Label htmlFor="status">Status</Label>
<Select
value={form.watch("status")}
onValueChange={(value) =>
form.setValue(
"status",
value as "draft" | "active" | "completed" | "archived",
)
}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft - Study in preparation</SelectItem>
<SelectItem value="active">
Active - Currently recruiting/running
</SelectItem>
<SelectItem value="completed">
Completed - Data collection finished
</SelectItem>
<SelectItem value="archived">Archived - Study concluded</SelectItem>
</SelectContent>
</Select>
</FormField>
</FormSection> </FormSection>
</div>
); );
// Sidebar content // Sidebar content
@@ -324,7 +346,7 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
error={error} error={error}
onDelete={mode === "edit" ? onDelete : undefined} onDelete={mode === "edit" ? onDelete : undefined}
isDeleting={isDeleting} isDeleting={isDeleting}
sidebar={sidebar} sidebar={mode === "create" ? sidebar : undefined}
submitButtonId="tour-study-submit" submitButtonId="tour-study-submit"
extraActions={ extraActions={
<Button variant="ghost" size="sm" onClick={() => startTour("study_creation")}> <Button variant="ghost" size="sm" onClick={() => startTour("study_creation")}>

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { type ColumnDef } from "@tanstack/react-table"; import { type ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, ChevronDown, MoreHorizontal, Copy, Eye, Play, Gamepad2, LineChart, Ban } from "lucide-react"; import { ArrowUpDown, ChevronDown, MoreHorizontal, Play, Gamepad2, LineChart, Ban } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { format, formatDistanceToNow } from "date-fns"; import { format, formatDistanceToNow } from "date-fns";
@@ -331,33 +331,7 @@ export const columns: ColumnDef<Trial>[] = [
); );
}, },
}, },
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Created
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const date = row.getValue("createdAt");
if (!date)
return <span className="text-muted-foreground text-sm">Unknown</span>;
return (
<div className="text-muted-foreground text-sm">
{formatDistanceToNow(new Date(date as string | number | Date), {
addSuffix: true,
})}
</div>
);
},
},
{ {
id: "actions", id: "actions",
enableHiding: false, enableHiding: false,
@@ -393,19 +367,6 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(trial.id)}
>
<Copy className="mr-2 h-4 w-4" />
Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}>
<Eye className="mr-2 h-4 w-4" />
Details
</Link>
</DropdownMenuItem>
{trial.status === "scheduled" && ( {trial.status === "scheduled" && (
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}> <Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
@@ -431,11 +392,6 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => duplicateMutation.mutate({ id: trial.id })}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
{(trial.status === "scheduled" || trial.status === "failed") && ( {(trial.status === "scheduled" || trial.status === "failed") && (
<DropdownMenuItem className="text-red-600"> <DropdownMenuItem className="text-red-600">
<Ban className="mr-2 h-4 w-4" /> <Ban className="mr-2 h-4 w-4" />

View File

@@ -0,0 +1,107 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { Badge } from "~/components/ui/badge";
import { cn } from "~/lib/utils";
import { CheckCircle, AlertTriangle, Info, Bot, User, Flag, MessageSquare, Activity } from "lucide-react";
// Define the shape of our data (matching schema)
export interface TrialEvent {
id: string;
trialId: string;
eventType: string;
timestamp: Date | string;
data: any;
createdBy: string | null;
}
// Helper to format timestamp relative to start
function formatRelativeTime(timestamp: Date | string, startTime?: Date) {
if (!startTime) return "--:--";
const date = new Date(timestamp);
const diff = date.getTime() - startTime.getTime();
if (diff < 0) return "0:00";
const totalSeconds = Math.floor(diff / 1000);
const m = Math.floor(totalSeconds / 60);
const s = Math.floor(totalSeconds % 60);
// Optional: extended formatting for longer durations
const h = Math.floor(m / 60);
if (h > 0) {
return `${h}:${(m % 60).toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
return `${m}:${s.toString().padStart(2, "0")}`;
}
export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
{
id: "timestamp",
header: "Time",
accessorKey: "timestamp",
cell: ({ row }) => {
const date = new Date(row.original.timestamp);
return (
<div className="flex flex-col">
<span className="font-mono font-medium">
{formatRelativeTime(row.original.timestamp, startTime)}
</span>
<span className="text-[10px] text-muted-foreground hidden group-hover:block">
{date.toLocaleTimeString()}
</span>
</div>
);
},
},
{
accessorKey: "eventType",
header: "Event Type",
cell: ({ row }) => {
const type = row.getValue("eventType") as string;
const isError = type.includes("error");
const isIntervention = type.includes("intervention");
const isRobot = type.includes("robot");
const isStep = type.includes("step");
let Icon = Activity;
if (isError) Icon = AlertTriangle;
else if (isIntervention) Icon = User; // Wizard/Intervention often User
else if (isRobot) Icon = Bot;
else if (isStep) Icon = Flag;
else if (type.includes("note")) Icon = MessageSquare;
else if (type.includes("completed")) Icon = CheckCircle;
return (
<Badge variant="outline" className={cn(
"capitalize font-medium flex w-fit items-center gap-1.5 px-2 py-0.5",
isError && "border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
isIntervention && "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/50 dark:bg-orange-900/20 dark:text-orange-400",
isRobot && "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-900/50 dark:bg-purple-900/20 dark:text-purple-400",
isStep && "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-400"
)}>
<Icon className="h-3 w-3" />
{type.replace(/_/g, " ")}
</Badge>
);
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
},
{
accessorKey: "data",
header: "Details",
cell: ({ row }) => {
const data = row.original.data;
if (!data || Object.keys(data).length === 0) return <span className="text-muted-foreground text-xs">-</span>;
// Simplistic view for now: JSON stringify but truncated?
// Or meaningful extraction based on event type.
return (
<code className="text-[10px] font-mono text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded border block max-w-[400px] truncate">
{JSON.stringify(data).replace(/[{""}]/g, " ").trim()}
</code>
);
},
},
];

View File

@@ -0,0 +1,101 @@
"use client";
import * as React from "react";
import { DataTable } from "~/components/ui/data-table";
import { type TrialEvent, eventsColumns } from "./events-columns";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Input } from "~/components/ui/input";
interface EventsDataTableProps {
data: TrialEvent[];
startTime?: Date;
}
export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
const [eventTypeFilter, setEventTypeFilter] = React.useState<string>("all");
const [globalFilter, setGlobalFilter] = React.useState<string>("");
const columns = React.useMemo(() => eventsColumns(startTime), [startTime]);
// Enhanced filtering logic
const filteredData = React.useMemo(() => {
return data.filter(event => {
// Type filter
if (eventTypeFilter !== "all" && !event.eventType.includes(eventTypeFilter)) {
return false;
}
// Global text search (checks type and data)
if (globalFilter) {
const searchLower = globalFilter.toLowerCase();
const typeMatch = event.eventType.toLowerCase().includes(searchLower);
// Safe JSON stringify check
const dataString = event.data ? JSON.stringify(event.data).toLowerCase() : "";
const dataMatch = dataString.includes(searchLower);
return typeMatch || dataMatch;
}
return true;
});
}, [data, eventTypeFilter, globalFilter]);
// Custom Filters UI
const filters = (
<div className="flex items-center gap-2">
<Select value={eventTypeFilter} onValueChange={setEventTypeFilter}>
<SelectTrigger className="h-8 w-[160px]">
<SelectValue placeholder="All Events" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Events</SelectItem>
<SelectItem value="intervention">Interventions</SelectItem>
<SelectItem value="robot">Robot Actions</SelectItem>
<SelectItem value="step">Step Changes</SelectItem>
<SelectItem value="error">Errors</SelectItem>
</SelectContent>
</Select>
</div>
);
return (
<div className="space-y-4">
{/* We instruct DataTable to use our filtered data, but DataTable also has internal filtering.
Since we implemented custom external filtering for "type" dropdown and "global" search,
we pass the filtered data directly.
However, the shared DataTable component has a `searchKey` prop that drives an internal Input.
If we want to use OUR custom search input (to search JSON data), we should probably NOT use
DataTable's internal search or pass a custom filter.
The shared DataTable's `searchKey` only filters a specific column string value.
Since "data" is an object, we can't easily use the built-in single-column search.
So we'll implement our own search input and pass `filters={filters}` which creates
additional dropdowns, but we might want to REPLACE the standard search input.
Looking at `DataTable` implementation:
It renders `<Input ... />` if `searchKey` is provided. If we don't provide `searchKey`,
no input is rendered, and we can put ours in `filters`.
*/}
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
<Input
placeholder="Search event data..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="h-8 w-[150px] lg:w-[250px]"
/>
{filters}
</div>
</div>
<DataTable
columns={columns}
data={filteredData}
// No searchKey, we handle it externally
isLoading={false}
/>
</div>
);
}

View File

@@ -2,17 +2,21 @@
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { LineChart, BarChart, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info } from "lucide-react"; import { Button } from "~/components/ui/button";
import Link from "next/link";
import { LineChart, BarChart, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info, Bot, Activity, ArrowLeft } from "lucide-react";
import { PlaybackProvider } from "../playback/PlaybackContext"; import { PlaybackProvider } from "../playback/PlaybackContext";
import { PlaybackPlayer } from "../playback/PlaybackPlayer"; import { PlaybackPlayer } from "../playback/PlaybackPlayer";
import { EventTimeline } from "../playback/EventTimeline"; import { EventTimeline } from "../playback/EventTimeline";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { ScrollArea } from "~/components/ui/scroll-area"; import { ScrollArea } from "~/components/ui/scroll-area";
import { cn } from "~/lib/utils";
import { import {
ResizableHandle, ResizableHandle,
ResizablePanel, ResizablePanel,
ResizablePanelGroup, ResizablePanelGroup,
} from "~/components/ui/resizable"; } from "~/components/ui/resizable";
import { EventsDataTable } from "../analysis/events-data-table";
interface TrialAnalysisViewProps { interface TrialAnalysisViewProps {
trial: { trial: {
@@ -27,9 +31,10 @@ interface TrialAnalysisViewProps {
mediaCount?: number; mediaCount?: number;
media?: { url: string; contentType: string }[]; media?: { url: string; contentType: string }[];
}; };
backHref: string;
} }
export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) { export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
// Fetch events for timeline // Fetch events for timeline
const { data: events = [] } = api.trials.getEvents.useQuery({ const { data: events = [] } = api.trials.getEvents.useQuery({
trialId: trial.id, trialId: trial.id,
@@ -39,139 +44,153 @@ export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) {
const videoMedia = trial.media?.find(m => m.contentType.startsWith("video/")); const videoMedia = trial.media?.find(m => m.contentType.startsWith("video/"));
const videoUrl = videoMedia?.url; const videoUrl = videoMedia?.url;
// Metrics
const interventionCount = events.filter(e => e.eventType.includes("intervention")).length;
const errorCount = events.filter(e => e.eventType.includes("error")).length;
const robotActionCount = events.filter(e => e.eventType.includes("robot_action")).length;
return ( return (
<PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}> <PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}>
<div className="h-[calc(100vh-8rem)] flex flex-col bg-background rounded-lg border shadow-sm overflow-hidden"> <div className="flex h-full flex-col gap-4 p-4 text-sm">
{/* Header Context */} {/* Header Context */}
<div className="flex items-center justify-between p-3 border-b bg-muted/20 flex-none h-14"> <div className="flex items-center justify-between pb-2 border-b">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild className="-ml-2">
<Link href={backHref}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div className="flex flex-col"> <div className="flex flex-col">
<h1 className="text-base font-semibold leading-none"> <h1 className="text-lg font-semibold leading-none tracking-tight">
{trial.experiment.name} {trial.experiment.name}
</h1> </h1>
<p className="text-xs text-muted-foreground mt-1"> <div className="flex items-center gap-2 text-muted-foreground mt-1">
{trial.participant.participantCode} Session {trial.id.slice(0, 4)}... <span className="font-mono">{trial.participant.participantCode}</span>
</p> <span></span>
<span>Session {trial.id.slice(0, 4)}</span>
</div> </div>
<div className="h-8 w-px bg-border" /> </div>
<div className="flex items-center gap-3 text-xs text-muted-foreground"> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-muted-foreground bg-muted/30 px-3 py-1 rounded-full border">
<Clock className="h-3.5 w-3.5" /> <Clock className="h-3.5 w-3.5" />
<span>{trial.startedAt?.toLocaleDateString()} {trial.startedAt?.toLocaleTimeString()}</span> <span className="text-xs font-mono">
{trial.startedAt?.toLocaleDateString()} {trial.startedAt?.toLocaleTimeString()}
</span>
</div> </div>
{trial.duration && ( </div>
<Badge variant="secondary" className="text-[10px] font-mono"> </div>
{Math.floor(trial.duration / 60)}m {trial.duration % 60}s
</Badge> {/* Metrics Header */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Card className="bg-gradient-to-br from-blue-50 to-transparent dark:from-blue-950/20">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">Duration</CardTitle>
<Clock className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{trial.duration ? (
<span>{Math.floor(trial.duration / 60)}m {trial.duration % 60}s</span>
) : (
"--:--"
)} )}
</div> </div>
<p className="text-xs text-muted-foreground">Total session time</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-purple-50 to-transparent dark:from-purple-950/20">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">Robot Actions</CardTitle>
<Bot className="h-4 w-4 text-purple-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{robotActionCount}</div>
<p className="text-xs text-muted-foreground">Executed autonomous behaviors</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-orange-50 to-transparent dark:from-orange-950/20">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">Interventions</CardTitle>
<AlertTriangle className="h-4 w-4 text-orange-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{interventionCount}</div>
<p className="text-xs text-muted-foreground">Manual wizard overrides</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-green-50 to-transparent dark:from-green-950/20">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">Completeness</CardTitle>
<Activity className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{trial.status === 'completed' ? '100%' : 'Incomplete'}
</div> </div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className={cn(
"inline-block h-2 w-2 rounded-full",
trial.status === 'completed' ? "bg-green-500" : "bg-yellow-500"
)} />
{trial.status.charAt(0).toUpperCase() + trial.status.slice(1)}
</div>
</CardContent>
</Card>
</div> </div>
{/* Main Resizable Workspace */} {/* Main Workspace: Vertical Layout */}
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0 rounded-xl border shadow-sm overflow-hidden bg-background">
<ResizablePanelGroup direction="horizontal">
{/* LEFT: Video & Timeline */}
<ResizablePanel defaultSize={65} minSize={30} className="flex flex-col min-h-0">
<ResizablePanelGroup direction="vertical"> <ResizablePanelGroup direction="vertical">
{/* Top: Video Player */}
<ResizablePanel defaultSize={75} minSize={20} className="bg-black relative"> {/* TOP: Video & Timeline */}
<ResizablePanel defaultSize={50} minSize={30} className="flex flex-col min-h-0 bg-black/5 dark:bg-black/40">
<div className="relative flex-1 min-h-0 flex items-center justify-center">
{videoUrl ? ( {videoUrl ? (
<div className="absolute inset-0"> <div className="absolute inset-0">
<PlaybackPlayer src={videoUrl} /> <PlaybackPlayer src={videoUrl} />
</div> </div>
) : ( ) : (
<div className="h-full w-full flex flex-col items-center justify-center text-slate-500"> <div className="flex flex-col items-center justify-center text-muted-foreground p-8 text-center">
<VideoOff className="h-12 w-12 mb-3 opacity-20" /> <div className="bg-muted rounded-full p-4 mb-4">
<p className="text-sm">No recording available.</p> <VideoOff className="h-8 w-8 opacity-50" />
</div>
<h3 className="font-semibold text-lg">No playback media available</h3>
<p className="text-sm max-w-sm mt-2">
There is no video recording associated with this trial session.
</p>
</div> </div>
)} )}
</ResizablePanel>
<ResizableHandle withHandle />
{/* Bottom: Timeline Track */}
<ResizablePanel defaultSize={25} minSize={10} className="bg-background flex flex-col min-h-0">
<div className="p-2 border-b flex-none bg-muted/10 flex items-center gap-2">
<Info className="h-3 w-3 text-muted-foreground" />
<span className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Timeline Track</span>
</div> </div>
<div className="flex-1 min-h-0 relative">
<div className="absolute inset-0 p-2 overflow-hidden"> {/* Timeline Control */}
<div className="shrink-0 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 p-4">
<EventTimeline /> <EventTimeline />
</div> </div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel> </ResizablePanel>
<ResizableHandle withHandle /> <ResizableHandle withHandle className="bg-border/50" />
{/* RIGHT: Logs & Metrics */} {/* BOTTOM: Events Table */}
<ResizablePanel defaultSize={35} minSize={20} className="flex flex-col min-h-0 border-l bg-muted/5"> <ResizablePanel defaultSize={50} minSize={20} className="flex flex-col min-h-0 bg-background">
{/* Metrics Strip */} <div className="flex items-center justify-between px-4 py-3 border-b">
<div className="grid grid-cols-2 gap-2 p-3 border-b bg-background flex-none"> <div className="flex items-center gap-2">
<Card className="shadow-none border-dashed bg-transparent"> <FileText className="h-4 w-4 text-primary" />
<CardContent className="p-3 py-2"> <h3 className="font-semibold text-sm">Event Log</h3>
<div className="text-[10px] uppercase text-muted-foreground font-semibold mb-0.5">Interventions</div>
<div className="text-xl font-mono font-bold flex items-center gap-2">
{events.filter(e => e.eventType.includes("intervention")).length}
<AlertTriangle className="h-3.5 w-3.5 text-yellow-500" />
</div> </div>
</CardContent> <Badge variant="secondary" className="text-xs">{events.length} Events</Badge>
</Card>
<Card className="shadow-none border-dashed bg-transparent">
<CardContent className="p-3 py-2">
<div className="text-[10px] uppercase text-muted-foreground font-semibold mb-0.5">Status</div>
<div className="text-xl font-mono font-bold flex items-center gap-2">
{trial.status === 'completed' ? 'PASS' : 'INC'}
<div className={`h-2 w-2 rounded-full ${trial.status === 'completed' ? 'bg-green-500' : 'bg-orange-500'}`} />
</div> </div>
</CardContent> <ScrollArea className="flex-1">
</Card> <div className="p-4">
</div> <EventsDataTable
data={events.map(e => ({ ...e, timestamp: new Date(e.timestamp) }))}
{/* Log Title */} startTime={trial.startedAt ?? undefined}
<div className="p-2 px-3 border-b bg-muted/20 flex items-center justify-between flex-none"> />
<span className="text-xs font-semibold flex items-center gap-2">
<FileText className="h-3.5 w-3.5 text-primary" />
Event Log
</span>
<Badge variant="outline" className="text-[10px] h-5">{events.length} Events</Badge>
</div>
{/* Scrollable Event List */}
<div className="flex-1 min-h-0 relative bg-background/50">
<ScrollArea className="h-full">
<div className="divide-y divide-border/50">
{events.map((event, i) => (
<div key={i} className="p-3 py-2 text-sm hover:bg-accent/50 transition-colors cursor-pointer group flex gap-3 items-start">
<div className="font-mono text-[10px] text-muted-foreground mt-0.5 min-w-[3rem]">
{formatTime(new Date(event.timestamp).getTime() - (trial.startedAt?.getTime() ?? 0))}
</div>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center justify-between">
<span className="font-medium text-xs text-foreground group-hover:text-primary transition-colors">
{event.eventType.replace(/_/g, " ")}
</span>
</div>
{!!event.data && (
<div className="text-[10px] text-muted-foreground bg-muted p-1.5 rounded border font-mono whitespace-pre-wrap break-all opacity-80 group-hover:opacity-100">
{JSON.stringify(event.data as object, null, 1).replace(/"/g, '').replace(/[{}]/g, '').trim()}
</div>
)}
</div>
</div>
))}
{events.length === 0 && (
<div className="p-8 text-center text-xs text-muted-foreground italic">
No events found in log.
</div>
)}
</div> </div>
</ScrollArea> </ScrollArea>
</div>
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
</div> </div>
@@ -187,3 +206,4 @@ function formatTime(ms: number) {
const s = Math.floor(totalSeconds % 60); const s = Math.floor(totalSeconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`; return `${m}:${s.toString().padStart(2, "0")}`;
} }

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,
@@ -373,6 +406,32 @@ export const WizardInterface = React.memo(function WizardInterface({
}, },
}); });
const pauseTrialMutation = api.trials.pause.useMutation({
onSuccess: () => {
toast.success("Trial paused");
// Optionally update local state if needed, though status might not change on backend strictly to "paused"
// depending on enum. But we logged the event.
},
onError: (error) => {
toast.error("Failed to pause trial", { description: error.message });
},
});
const archiveTrialMutation = api.trials.archive.useMutation({
onSuccess: () => {
console.log("Trial archived successfully");
},
onError: (error) => {
console.error("Failed to archive trial", error);
},
});
const logEventMutation = api.trials.logEvent.useMutation({
onSuccess: () => {
// toast.success("Event logged"); // Too noisy
},
});
// Action handlers // Action handlers
const handleStartTrial = async () => { const handleStartTrial = async () => {
console.log( console.log(
@@ -410,21 +469,88 @@ export const WizardInterface = React.memo(function WizardInterface({
}; };
const handlePauseTrial = async () => { const handlePauseTrial = async () => {
// TODO: Implement pause functionality try {
console.log("Pause trial"); await pauseTrialMutation.mutateAsync({ id: trial.id });
} catch (error) {
console.error("Failed to pause trial:", error);
}
}; };
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) {
// Log step change
logEventMutation.mutate({
trialId: trial.id,
type: "step_changed",
data: {
fromIndex: currentStepIndex,
toIndex: nextIndex,
fromStepId: currentStep?.id,
toStepId: steps[nextIndex]?.id,
stepName: steps[nextIndex]?.name,
}
});
setCurrentStepIndex(nextIndex);
} else {
handleCompleteTrial();
} }
}; };
const handleCompleteTrial = async () => { const handleCompleteTrial = async () => {
try { try {
await completeTrialMutation.mutateAsync({ id: trial.id }); await completeTrialMutation.mutateAsync({ id: trial.id });
// Trigger archive in background
archiveTrialMutation.mutate({ id: trial.id });
} catch (error) { } catch (error) {
console.error("Failed to complete trial:", error); console.error("Failed to complete trial:", error);
} }
@@ -461,10 +587,7 @@ export const WizardInterface = React.memo(function WizardInterface({
}); });
}; };
// Mutation for events (Acknowledge)
const logEventMutation = api.trials.logEvent.useMutation({
onSuccess: () => toast.success("Event logged"),
});
// Mutation for interventions // Mutation for interventions
const addInterventionMutation = api.trials.addIntervention.useMutation({ const addInterventionMutation = api.trials.addIntervention.useMutation({
@@ -476,8 +599,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 +754,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 +865,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 +939,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,27 +969,56 @@ 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>
</ResizablePanelGroup>
</div>
</div> </div>
</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 >
); );
}); });

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>
</div>
</ScrollArea> </ScrollArea>
</TabsContent> </div >
</div> </div >
</Tabs>
</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,35 +429,15 @@ 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>
)} )}
</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 > </ScrollArea>
</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

@@ -211,7 +211,7 @@ export function DataTable<TData, TValue>({
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </div>
<div className="min-w-0 overflow-hidden rounded-md border"> <div className="min-w-0 overflow-hidden rounded-md border shadow-sm bg-card">
<div className="min-w-0 overflow-x-auto overflow-y-hidden"> <div className="min-w-0 overflow-x-auto overflow-y-hidden">
<Table className="min-w-[600px]"> <Table className="min-w-[600px]">
<TableHeader> <TableHeader>

View File

@@ -127,12 +127,14 @@ export function EntityForm<T extends FieldValues = FieldValues>({
<div <div
className={cn( className={cn(
"grid gap-8 w-full", "grid gap-8 w-full",
layout === "default" && "grid-cols-1 lg:grid-cols-3", // Keep the column split but remove max-width // If sidebar exists, use 2-column layout. If not, use full width (max-w-7xl centered).
layout === "full-width" && "grid-cols-1", sidebar && layout === "default"
? "grid-cols-1 lg:grid-cols-3"
: "grid-cols-1 max-w-7xl mx-auto",
)} )}
> >
{/* Main Form */} {/* Main Form */}
<div className={layout === "default" ? "lg:col-span-2" : "col-span-1"}> <div className={sidebar && layout === "default" ? "lg:col-span-2" : "col-span-1"}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>

View File

@@ -20,7 +20,7 @@ export function Logo({
}: LogoProps) { }: LogoProps) {
return ( return (
<div className={cn("flex items-center gap-2", className)}> <div className={cn("flex items-center gap-2", className)}>
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square items-center justify-center rounded-lg p-1"> <div className="bg-primary text-primary-foreground flex aspect-square items-center justify-center rounded-lg p-1">
<Bot className={iconSizes[iconSize]} /> <Bot className={iconSizes[iconSize]} />
</div> </div>
{showText && ( {showText && (

View File

@@ -309,7 +309,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main <main
data-slot="sidebar-inset" data-slot="sidebar-inset"
className={cn( className={cn(
"bg-background relative flex w-full flex-1 flex-col", "bg-background relative flex w-full flex-1 flex-col overflow-x-hidden",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className, className,
)} )}

View File

@@ -18,7 +18,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return ( return (
<thead <thead
data-slot="table-header" data-slot="table-header"
className={cn("[&_tr]:border-b", className)} className={cn("[&_tr]:border-b bg-secondary/30", className)}
{...props} {...props}
/> />
); );

View File

@@ -158,3 +158,88 @@ export function convertActionToDatabase(
category: action.category, category: action.category,
}; };
} }
// Reconstruct designer steps from database records
export function convertDatabaseToSteps(
dbSteps: any[] // Typing as any[] because Drizzle types are complex to import here without circular deps
): ExperimentStep[] {
// Paranoid Sort: Ensure steps are strictly ordered by index before assigning Triggers.
// This safeguards against API returning unsorted data.
const sortedSteps = [...dbSteps].sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0));
return sortedSteps.map((dbStep, idx) => {
return {
id: dbStep.id,
name: dbStep.name,
description: dbStep.description ?? undefined,
type: mapDatabaseToStepType(dbStep.type),
order: dbStep.orderIndex ?? idx, // Fallback to array index if missing
trigger: {
// Enforce Sequential Architecture: Validated by user requirement.
// Index 0 is Trial Start, all others are Previous Step.
type: idx === 0 ? "trial_start" : "previous_step",
conditions: (dbStep.conditions as Record<string, unknown>) || {},
},
expanded: true, // Default to expanded in designer
actions: (dbStep.actions || []).map((dbAction: any) =>
convertDatabaseToAction(dbAction)
),
};
});
}
function mapDatabaseToStepType(type: string): ExperimentStep["type"] {
switch (type) {
case "wizard":
return "sequential";
case "parallel":
return "parallel";
case "conditional":
return "conditional"; // Loop is also stored as conditional, distinction lost unless encoded in metadata
default:
return "sequential";
}
}
export function convertDatabaseToAction(dbAction: any): ExperimentAction {
// Reconstruct nested source object
const source: ExperimentAction["source"] = {
kind: (dbAction.sourceKind || dbAction.source_kind || "core") as "core" | "plugin",
pluginId: dbAction.pluginId || dbAction.plugin_id || undefined,
pluginVersion: dbAction.pluginVersion || dbAction.plugin_version || undefined,
robotId: dbAction.robotId || dbAction.robot_id || undefined,
baseActionId: dbAction.baseActionId || dbAction.base_action_id || undefined,
};
// Robust Inference: If properties are missing but Type suggests a plugin (e.g., "nao6-ros2.say_text"),
// assume/infer the pluginId to ensure validation passes.
if (dbAction.type && dbAction.type.includes(".") && !source.pluginId) {
const parts = dbAction.type.split(".");
if (parts.length === 2) {
source.kind = "plugin";
source.pluginId = parts[0];
// Fallback robotId if missing
if (!source.robotId) source.robotId = parts[0];
}
}
// Reconstruct execution object
const execution: ExecutionDescriptor = {
transport: dbAction.transport as ExecutionDescriptor["transport"],
ros2: dbAction.ros2 as ExecutionDescriptor["ros2"],
rest: dbAction.rest as ExecutionDescriptor["rest"],
retryable: dbAction.retryable ?? false,
};
return {
id: dbAction.id,
name: dbAction.name,
description: dbAction.description ?? undefined,
type: dbAction.type,
category: dbAction.category ?? "general",
parameters: (dbAction.parameters as Record<string, unknown>) || {},
source,
execution,
parameterSchemaRaw: dbAction.parameterSchemaRaw,
};
}

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;
@@ -119,26 +119,13 @@ export const TRIGGER_OPTIONS = [
]; ];
// Step type options for UI // Step type options for UI
// IMPORTANT: Steps should ALWAYS execute sequentially
// Parallel execution, conditionals, and loops should be implemented via control flow ACTIONS
export const STEP_TYPE_OPTIONS = [ export const STEP_TYPE_OPTIONS = [
{ {
value: "sequential" as const, value: "sequential" as const,
label: "Sequential", label: "Sequential",
description: "Actions run one after another", description: "Actions run one after another (enforced for all steps)",
},
{
value: "parallel" as const,
label: "Parallel",
description: "Actions run at the same time",
},
{
value: "conditional" as const,
label: "Conditional",
description: "Actions run if condition is met",
},
{
value: "loop" as const,
label: "Loop",
description: "Actions repeat multiple times",
}, },
]; ];

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

@@ -1,6 +1,6 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm"; import { and, asc, count, desc, eq, inArray, isNull, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
@@ -17,7 +17,10 @@ import {
studyMembers, studyMembers,
userSystemRoles, userSystemRoles,
} from "~/server/db/schema"; } from "~/server/db/schema";
import { convertStepsToDatabase } from "~/lib/experiment-designer/block-converter"; import {
convertStepsToDatabase,
convertDatabaseToSteps,
} from "~/lib/experiment-designer/block-converter";
import type { import type {
ExperimentStep, ExperimentStep,
ExperimentDesign, ExperimentDesign,
@@ -84,7 +87,10 @@ export const experimentsRouter = createTRPCRouter({
// Check study access // Check study access
await checkStudyAccess(ctx.db, userId, studyId); await checkStudyAccess(ctx.db, userId, studyId);
const conditions = [eq(experiments.studyId, studyId)]; const conditions = [
eq(experiments.studyId, studyId),
isNull(experiments.deletedAt),
];
if (status) { if (status) {
conditions.push(eq(experiments.status, status)); conditions.push(eq(experiments.status, status));
} }
@@ -221,7 +227,10 @@ export const experimentsRouter = createTRPCRouter({
} }
// Build where conditions // Build where conditions
const conditions = [inArray(experiments.studyId, studyIds)]; const conditions = [
inArray(experiments.studyId, studyIds),
isNull(experiments.deletedAt),
];
if (status) { if (status) {
conditions.push(eq(experiments.status, status)); conditions.push(eq(experiments.status, status));
@@ -382,6 +391,7 @@ export const experimentsRouter = createTRPCRouter({
return { return {
...experiment, ...experiment,
steps: convertDatabaseToSteps(experiment.steps),
integrityHash: experiment.integrityHash, integrityHash: experiment.integrityHash,
executionGraphSummary, executionGraphSummary,
pluginDependencies: experiment.pluginDependencies ?? [], pluginDependencies: experiment.pluginDependencies ?? [],
@@ -1539,7 +1549,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

@@ -7,6 +7,7 @@ import type { db } from "~/server/db";
import { import {
activityLogs, consentForms, participantConsents, participants, studyMembers, trials activityLogs, consentForms, participantConsents, participants, studyMembers, trials
} from "~/server/db/schema"; } from "~/server/db/schema";
import { getUploadUrl, validateFile } from "~/lib/storage/minio";
// Helper function to check study access // Helper function to check study access
async function checkStudyAccess( async function checkStudyAccess(
@@ -415,6 +416,42 @@ export const participantsRouter = createTRPCRouter({
return { success: true }; return { success: true };
}), }),
getConsentUploadUrl: protectedProcedure
.input(
z.object({
studyId: z.string().uuid(),
participantId: z.string().uuid(),
filename: z.string(),
contentType: z.string(),
size: z.number().max(10 * 1024 * 1024), // 10MB limit
})
)
.mutation(async ({ ctx, input }) => {
const { studyId, participantId, filename, contentType, size } = input;
const userId = ctx.session.user.id;
// Check study access with researcher permission
await checkStudyAccess(ctx.db, userId, studyId, ["owner", "researcher", "wizard"]);
// Validate file type
const allowedTypes = ["pdf", "png", "jpg", "jpeg"];
const validation = validateFile(filename, size, allowedTypes);
if (!validation.valid) {
throw new TRPCError({
code: "BAD_REQUEST",
message: validation.error,
});
}
// Generate key: studies/{studyId}/participants/{participantId}/consent/{timestamp}-{filename}
const key = `studies/${studyId}/participants/${participantId}/consent/${Date.now()}-${filename.replace(/[^a-zA-Z0-9.-]/g, "_")}`;
// Generate presigned URL
const url = await getUploadUrl(key, contentType);
return { url, key };
}),
recordConsent: protectedProcedure recordConsent: protectedProcedure
.input( .input(
z.object({ z.object({
@@ -422,10 +459,11 @@ export const participantsRouter = createTRPCRouter({
consentFormId: z.string().uuid(), consentFormId: z.string().uuid(),
signatureData: z.string().optional(), signatureData: z.string().optional(),
ipAddress: z.string().optional(), ipAddress: z.string().optional(),
storagePath: z.string().optional(),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { participantId, consentFormId, signatureData, ipAddress } = input; const { participantId, consentFormId, signatureData, ipAddress, storagePath } = input;
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
// Get participant to check study access // Get participant to check study access
@@ -489,6 +527,7 @@ export const participantsRouter = createTRPCRouter({
consentFormId, consentFormId,
signatureData, signatureData,
ipAddress, ipAddress,
storagePath,
}) })
.returning(); .returning();

View File

@@ -34,6 +34,7 @@ import { s3Client } from "~/server/storage";
import { GetObjectCommand } from "@aws-sdk/client-s3"; import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env } from "~/env"; import { env } from "~/env";
import { uploadFile } from "~/lib/storage/minio";
// Helper function to check if user has access to trial // Helper function to check if user has access to trial
async function checkTrialAccess( async function checkTrialAccess(
@@ -542,6 +543,14 @@ export const trialsRouter = createTRPCRouter({
}); });
} }
// Log trial start event
await db.insert(trialEvents).values({
trialId: input.id,
eventType: "trial_started",
timestamp: new Date(),
data: { userId },
});
return trial[0]; return trial[0];
}), }),
@@ -625,9 +634,136 @@ export const trialsRouter = createTRPCRouter({
}); });
} }
// Log trial abort event
await db.insert(trialEvents).values({
trialId: input.id,
eventType: "trial_aborted",
timestamp: new Date(),
data: { userId, reason: input.reason },
});
return trial[0]; return trial[0];
}), }),
pause: protectedProcedure
.input(
z.object({
id: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const { db } = ctx;
const userId = ctx.session.user.id;
await checkTrialAccess(db, userId, input.id, [
"owner",
"researcher",
"wizard",
]);
// Log trial paused event
await db.insert(trialEvents).values({
trialId: input.id,
eventType: "trial_paused",
timestamp: new Date(),
data: { userId },
});
return { success: true };
}),
archive: protectedProcedure
.input(
z.object({
id: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const { db } = ctx;
const userId = ctx.session.user.id;
const trial = await checkTrialAccess(db, userId, input.id, [
"owner",
"researcher",
"wizard",
]);
// 1. Fetch full trial data
const trialData = await db.query.trials.findFirst({
where: eq(trials.id, input.id),
with: {
experiment: true,
participant: true,
wizard: true,
},
});
if (!trialData) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Trial data not found",
});
}
// 2. Fetch all events
const events = await db
.select()
.from(trialEvents)
.where(eq(trialEvents.trialId, input.id))
.orderBy(asc(trialEvents.timestamp));
// 3. Fetch all interventions
const interventions = await db
.select()
.from(wizardInterventions)
.where(eq(wizardInterventions.trialId, input.id))
.orderBy(asc(wizardInterventions.timestamp));
// 4. Construct Archive Object
const archiveObject = {
trial: trialData,
events,
interventions,
archivedAt: new Date().toISOString(),
archivedBy: userId,
};
// 5. Upload to MinIO
const filename = `archive-${input.id}-${Date.now()}.json`;
const key = `trials/${input.id}/${filename}`;
try {
const uploadResult = await uploadFile({
key,
body: JSON.stringify(archiveObject, null, 2),
contentType: "application/json",
});
// 6. Update Trial Metadata with Archive URL/Key
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const currentMetadata = (trialData.metadata as any) || {};
await db
.update(trials)
.set({
metadata: {
...currentMetadata,
archiveKey: uploadResult.key,
archiveUrl: uploadResult.url,
archivedAt: new Date(),
},
})
.where(eq(trials.id, input.id));
return { success: true, url: uploadResult.url };
} catch (error) {
console.error("Failed to archive trial:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to upload archive to storage",
});
}
}),
logEvent: protectedProcedure logEvent: protectedProcedure
.input( .input(
z.object({ z.object({
@@ -1046,6 +1182,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

View File

@@ -69,42 +69,62 @@
--shadow-opacity: var(--shadow-opacity); --shadow-opacity: var(--shadow-opacity);
--color-shadow-color: var(--shadow-color); --color-shadow-color: var(--shadow-color);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
/* Validation Colors */
--color-validation-error-bg: var(--validation-error-bg);
--color-validation-error-text: var(--validation-error-text);
--color-validation-error-border: var(--validation-error-border);
--color-validation-warning-bg: var(--validation-warning-bg);
--color-validation-warning-text: var(--validation-warning-text);
--color-validation-warning-border: var(--validation-warning-border);
--color-validation-info-bg: var(--validation-info-bg);
--color-validation-info-text: var(--validation-info-text);
--color-validation-info-border: var(--validation-info-border);
} }
:root { :root {
--radius: 0rem; /* Light Mode (Inverted: White BG, gray Cards) */
--background: oklch(0.98 0.005 60); --radius: 0.5rem;
--foreground: oklch(0.15 0.005 240); --background: hsl(0 0% 100%);
--card: oklch(0.995 0.001 60); /* Pure White Background */
--card-foreground: oklch(0.15 0.005 240); --foreground: hsl(240 10% 3.9%);
--popover: oklch(0.99 0.002 60); --card: hsl(240 4.8% 95.9%);
--popover-foreground: oklch(0.15 0.005 240); /* Light Gray Card */
--primary: oklch(0.55 0.08 240); --card-foreground: hsl(240 10% 3.9%);
--primary-foreground: oklch(0.98 0.01 250); --popover: hsl(0 0% 100%);
--secondary: oklch(0.94 0.01 240); --popover-foreground: hsl(240 10% 3.9%);
--secondary-foreground: oklch(0.25 0.02 240); --primary: hsl(221.2 83.2% 53.3%);
--muted: oklch(0.95 0.008 240); /* Indigo-600 */
--muted-foreground: oklch(0.52 0.015 240); --primary-foreground: hsl(210 40% 98%);
--accent: oklch(0.92 0.015 240); --secondary: hsl(210 40% 96.1%);
--accent-foreground: oklch(0.2 0.02 240); --secondary-foreground: hsl(222.2 47.4% 11.2%);
--destructive: oklch(0.583 0.2387 28.4765); --muted: hsl(210 40% 96.1%);
--border: oklch(0.9 0.008 240); --muted-foreground: hsl(215.4 16.3% 46.9%);
--input: oklch(0.96 0.005 240); --accent: hsl(210 40% 96.1%);
--ring: oklch(0.55 0.08 240); --accent-foreground: hsl(222.2 47.4% 11.2%);
--chart-1: oklch(0.55 0.08 240); --destructive: hsl(0 84.2% 60.2%);
--chart-2: oklch(0.6 0.1 200); --destructive-foreground: hsl(210 40% 98%);
--chart-3: oklch(0.65 0.12 160); --border: hsl(214.3 31.8% 91.4%);
--chart-4: oklch(0.7 0.1 120); --input: hsl(214.3 31.8% 91.4%);
--chart-5: oklch(0.6 0.15 80); --ring: hsl(221.2 83.2% 53.3%);
--sidebar: oklch(0.97 0.015 250); --chart-1: hsl(221.2 83.2% 53.3%);
--sidebar-foreground: oklch(0.2 0.03 240); --chart-2: hsl(173 58% 39%);
--sidebar-primary: oklch(0.3 0.08 240); --chart-3: hsl(197 37% 24%);
--sidebar-primary-foreground: oklch(0.98 0.01 250); --chart-4: hsl(43 74% 66%);
--sidebar-accent: oklch(0.92 0.025 245); --chart-5: hsl(27 87% 67%);
--sidebar-accent-foreground: oklch(0.25 0.05 240); --sidebar: hsl(240 4.8% 95.9%);
--sidebar-border: oklch(0.85 0.03 245); /* Zinc-100: Distinct contrast against white BG */
--sidebar-ring: oklch(0.6 0.05 240); --sidebar-foreground: hsl(240 10% 3.9%);
--destructive-foreground: oklch(0.9702 0 0); /* Dark Text */
--sidebar-primary: hsl(221.2 83.2% 53.3%);
/* Indigo Accent */
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(240 5.9% 90%);
/* Zinc-200: Slightly darker for hover */
--sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: hsl(240 5.9% 90%);
/* Zinc-200 Border */
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--shadow-color: hsl(0 0% 0%); --shadow-color: hsl(0 0% 0%);
--shadow-opacity: 0; --shadow-opacity: 0;
@@ -131,82 +151,127 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--background: oklch(0.12 0.008 250); --background: hsl(240 10% 3.9%);
--foreground: oklch(0.95 0.005 250); --foreground: hsl(0 0% 98%);
--card: oklch(0.18 0.008 250); /* Distinct Card Background for better contrast */
--card-foreground: oklch(0.95 0.005 250); --card: hsl(240 5% 9%);
--popover: oklch(0.2 0.01 250); --card-foreground: hsl(0 0% 98%);
--popover-foreground: oklch(0.95 0.005 250); --popover: hsl(240 5% 9%);
--primary: oklch(0.65 0.1 240); --popover-foreground: hsl(0 0% 98%);
--primary-foreground: oklch(0.08 0.02 250); --primary: hsl(0 0% 98%);
--secondary: oklch(0.25 0.015 245); --primary-foreground: hsl(240 5.9% 10%);
--secondary-foreground: oklch(0.92 0.008 250); --secondary: hsl(240 3.7% 15.9%);
--muted: oklch(0.22 0.01 250); --secondary-foreground: hsl(0 0% 98%);
--muted-foreground: oklch(0.65 0.02 245); --muted: hsl(240 3.7% 15.9%);
--accent: oklch(0.35 0.025 245); --muted-foreground: hsl(240 5% 64.9%);
--accent-foreground: oklch(0.92 0.008 250); --accent: hsl(240 3.7% 15.9%);
--destructive: oklch(0.7022 0.1892 22.2279); --accent-foreground: hsl(0 0% 98%);
--border: oklch(0.3 0.015 250); --destructive: hsl(0 62.8% 30.6%);
--input: oklch(0.28 0.015 250); --destructive-foreground: hsl(0 0% 98%);
--ring: oklch(0.65 0.1 240); --border: hsl(240 3.7% 15.9%);
--chart-1: oklch(0.65 0.1 240); --input: hsl(240 3.7% 15.9%);
--chart-2: oklch(0.7 0.12 200); --ring: hsl(240 4.9% 83.9%);
--chart-3: oklch(0.75 0.15 160); --chart-1: hsl(220 70% 50%);
--chart-4: oklch(0.8 0.12 120); --chart-2: hsl(160 60% 45%);
--chart-5: oklch(0.7 0.18 80); --chart-3: hsl(30 80% 55%);
--sidebar: oklch(0.14 0.025 250); --chart-4: hsl(280 65% 60%);
--sidebar-foreground: oklch(0.88 0.02 250); --chart-5: hsl(340 75% 55%);
--sidebar-primary: oklch(0.8 0.06 240); --sidebar: hsl(240 5.9% 10%);
--sidebar-primary-foreground: oklch(0.12 0.025 250); --sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-accent: oklch(0.22 0.04 245); --sidebar-primary: hsl(224.3 76.3% 48%);
--sidebar-accent-foreground: oklch(0.88 0.02 250); --sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-border: oklch(0.32 0.035 250); --sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-ring: oklch(0.55 0.08 240); --sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--destructive-foreground: oklch(0.95 0.01 250); --sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
} }
} }
@layer base { @layer base {
.dark { .dark {
--background: oklch(0.12 0.008 250); --background: hsl(240 10% 3.9%);
--foreground: oklch(0.95 0.005 250); --foreground: hsl(0 0% 98%);
--card: oklch(0.18 0.008 250); --card: hsl(240 5% 9%);
--card-foreground: oklch(0.95 0.005 250); --card-foreground: hsl(0 0% 98%);
--popover: oklch(0.2 0.01 250); --popover: hsl(240 5% 9%);
--popover-foreground: oklch(0.95 0.005 250); --popover-foreground: hsl(0 0% 98%);
--primary: oklch(0.65 0.1 240); --primary: hsl(0 0% 98%);
--primary-foreground: oklch(0.08 0.02 250); --primary-foreground: hsl(240 5.9% 10%);
--secondary: oklch(0.25 0.015 245); --secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: oklch(0.92 0.008 250); --secondary-foreground: hsl(0 0% 98%);
--muted: oklch(0.22 0.01 250); --muted: hsl(240 3.7% 15.9%);
--muted-foreground: oklch(0.65 0.02 245); --muted-foreground: hsl(240 5% 64.9%);
--accent: oklch(0.35 0.025 245); --accent: hsl(240 3.7% 15.9%);
--accent-foreground: oklch(0.92 0.008 250); --accent-foreground: hsl(0 0% 98%);
--destructive: oklch(0.7022 0.1892 22.2279); --destructive: hsl(0 62.8% 30.6%);
--border: oklch(0.3 0.015 250); --destructive-foreground: hsl(0 0% 98%);
--input: oklch(0.28 0.015 250); --border: hsl(240 3.7% 15.9%);
--ring: oklch(0.65 0.1 240); --input: hsl(240 3.7% 15.9%);
--chart-1: oklch(0.65 0.1 240); --ring: hsl(240 4.9% 83.9%);
--chart-2: oklch(0.7 0.12 200); --chart-1: hsl(220 70% 50%);
--chart-3: oklch(0.75 0.15 160); --chart-2: hsl(160 60% 45%);
--chart-4: oklch(0.8 0.12 120); --chart-3: hsl(30 80% 55%);
--chart-5: oklch(0.7 0.18 80); --chart-4: hsl(280 65% 60%);
--sidebar: oklch(0.14 0.025 250); --chart-5: hsl(340 75% 55%);
--sidebar-foreground: oklch(0.88 0.02 250); --sidebar: hsl(240 5.9% 10%);
--sidebar-primary: oklch(0.8 0.06 240); --sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary-foreground: oklch(0.12 0.025 250); --sidebar-primary: hsl(224.3 76.3% 48%);
--sidebar-accent: oklch(0.22 0.04 245); --sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent-foreground: oklch(0.88 0.02 250); --sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-border: oklch(0.32 0.035 250); --sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-ring: oklch(0.55 0.08 240); --sidebar-border: hsl(240 3.7% 15.9%);
--destructive-foreground: oklch(0.95 0.01 250); --sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
/* Validation Dark Mode */
--validation-error-bg: hsl(0 75% 15%);
/* Red 950-ish */
--validation-error-text: hsl(0 100% 90%);
/* Red 100 */
--validation-error-border: hsl(0 50% 30%);
/* Red 900 */
--validation-warning-bg: hsl(30 90% 10%);
/* Amber 950-ish */
--validation-warning-text: hsl(30 100% 90%);
/* Amber 100 */
--validation-warning-border: hsl(30 60% 30%);
/* Amber 900 */
--validation-info-bg: hsl(210 50% 15%);
/* Blue 950-ish */
--validation-info-text: hsl(210 100% 90%);
/* Blue 100 */
--validation-info-border: hsl(210 40% 30%);
/* Blue 900 */
} }
} }
:root {
/* Validation Light Mode Defaults */
--validation-error-bg: hsl(0 85% 97%);
/* Red 50 */
--validation-error-text: hsl(0 72% 45%);
/* Red 700 */
--validation-error-border: hsl(0 80% 90%);
/* Red 200 */
--validation-warning-bg: hsl(40 85% 97%);
/* Amber 50 */
--validation-warning-text: hsl(35 90% 35%);
/* Amber 700 */
--validation-warning-border: hsl(40 80% 90%);
/* Amber 200 */
--validation-info-bg: hsl(210 85% 97%);
/* Blue 50 */
--validation-info-text: hsl(220 80% 45%);
/* Blue 700 */
--validation-info-border: hsl(210 80% 90%);
/* Blue 200 */
}
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
letter-spacing: var(--tracking-normal); letter-spacing: var(--tracking-normal);
@@ -225,3 +290,11 @@
@apply bg-background text-foreground shadow; @apply bg-background text-foreground shadow;
} }
} }
/* Viewport height constraint for proper flex layout */
html,
body,
#__next {
height: 100%;
min-height: 100vh;
}