mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
Compare commits
8 Commits
nao_ros2
...
568d408587
| Author | SHA1 | Date | |
|---|---|---|---|
| 568d408587 | |||
| 93de577939 | |||
| 85b951f742 | |||
| a8c868ad3f | |||
| 0f535f6887 | |||
| 388897c70e | |||
| 0ec63b3c97 | |||
| 89c44efcf7 |
Submodule robot-plugins updated: c6310d3144...d554891dab
65
scripts/check-db.ts
Normal file
65
scripts/check-db.ts
Normal 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);
|
||||
58
scripts/debug-experiment-structure.ts
Normal file
58
scripts/debug-experiment-structure.ts
Normal 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);
|
||||
});
|
||||
42
scripts/inspect-all-steps.ts
Normal file
42
scripts/inspect-all-steps.ts
Normal 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);
|
||||
});
|
||||
47
scripts/inspect-branch-action.ts
Normal file
47
scripts/inspect-branch-action.ts
Normal 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);
|
||||
});
|
||||
30
scripts/inspect-branch-steps.ts
Normal file
30
scripts/inspect-branch-steps.ts
Normal 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
24
scripts/inspect-db.ts
Normal 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
61
scripts/inspect-step.ts
Normal 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}`);
|
||||
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
33
scripts/inspect-visual-design.ts
Normal file
33
scripts/inspect-visual-design.ts
Normal 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);
|
||||
});
|
||||
69
scripts/patch-branch-action-params.ts
Normal file
69
scripts/patch-branch-action-params.ts
Normal 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);
|
||||
});
|
||||
92
scripts/patch-branch-steps.ts
Normal file
92
scripts/patch-branch-steps.ts
Normal 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);
|
||||
});
|
||||
@@ -35,6 +35,19 @@ async function loadNaoPluginDef() {
|
||||
|
||||
// Global variable to hold the loaded definition
|
||||
let NAO_PLUGIN_DEF: any;
|
||||
let CORE_PLUGIN_DEF: any;
|
||||
let WOZ_PLUGIN_DEF: any;
|
||||
|
||||
function loadSystemPlugin(filename: string) {
|
||||
const LOCAL_PATH = path.join(__dirname, `../src/plugins/definitions/${filename}`);
|
||||
try {
|
||||
const raw = fs.readFileSync(LOCAL_PATH, "utf-8");
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
console.error(`❌ Failed to load system plugin ${filename}:`, err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Starting realistic seed script...");
|
||||
@@ -43,6 +56,8 @@ async function main() {
|
||||
|
||||
try {
|
||||
NAO_PLUGIN_DEF = await loadNaoPluginDef();
|
||||
CORE_PLUGIN_DEF = loadSystemPlugin("hristudio-core.json");
|
||||
WOZ_PLUGIN_DEF = loadSystemPlugin("hristudio-woz.json");
|
||||
|
||||
// Ensure legacy 'actions' property maps to 'actionDefinitions' if needed, though schema supports both if we map it
|
||||
if (NAO_PLUGIN_DEF.actions && !NAO_PLUGIN_DEF.actionDefinitions) {
|
||||
@@ -61,6 +76,7 @@ async function main() {
|
||||
await db.delete(schema.studyPlugins).where(sql`1=1`);
|
||||
await db.delete(schema.studyMembers).where(sql`1=1`);
|
||||
await db.delete(schema.studies).where(sql`1=1`);
|
||||
await db.delete(schema.studies).where(sql`1=1`);
|
||||
await db.delete(schema.plugins).where(sql`1=1`);
|
||||
await db.delete(schema.pluginRepositories).where(sql`1=1`);
|
||||
await db.delete(schema.userSystemRoles).where(sql`1=1`);
|
||||
@@ -144,12 +160,51 @@ async function main() {
|
||||
{ studyId: study!.id, userId: researcherUser!.id, role: "researcher" }
|
||||
]);
|
||||
|
||||
await db.insert(schema.studyPlugins).values({
|
||||
// Insert System Plugins
|
||||
const [corePlugin] = await db.insert(schema.plugins).values({
|
||||
name: CORE_PLUGIN_DEF.name,
|
||||
version: CORE_PLUGIN_DEF.version,
|
||||
description: CORE_PLUGIN_DEF.description,
|
||||
author: CORE_PLUGIN_DEF.author,
|
||||
trustLevel: "official",
|
||||
actionDefinitions: CORE_PLUGIN_DEF.actionDefinitions,
|
||||
robotId: null, // System Plugin
|
||||
metadata: { ...CORE_PLUGIN_DEF, id: CORE_PLUGIN_DEF.id },
|
||||
status: "active"
|
||||
}).returning();
|
||||
|
||||
const [wozPlugin] = await db.insert(schema.plugins).values({
|
||||
name: WOZ_PLUGIN_DEF.name,
|
||||
version: WOZ_PLUGIN_DEF.version,
|
||||
description: WOZ_PLUGIN_DEF.description,
|
||||
author: WOZ_PLUGIN_DEF.author,
|
||||
trustLevel: "official",
|
||||
actionDefinitions: WOZ_PLUGIN_DEF.actionDefinitions,
|
||||
robotId: null, // System Plugin
|
||||
metadata: { ...WOZ_PLUGIN_DEF, id: WOZ_PLUGIN_DEF.id },
|
||||
status: "active"
|
||||
}).returning();
|
||||
|
||||
await db.insert(schema.studyPlugins).values([
|
||||
{
|
||||
studyId: study!.id,
|
||||
pluginId: naoPlugin!.id,
|
||||
configuration: { robotIp: "10.0.0.42" },
|
||||
installedBy: adminUser.id
|
||||
});
|
||||
},
|
||||
{
|
||||
studyId: study!.id,
|
||||
pluginId: corePlugin!.id,
|
||||
configuration: {},
|
||||
installedBy: adminUser.id
|
||||
},
|
||||
{
|
||||
studyId: study!.id,
|
||||
pluginId: wozPlugin!.id,
|
||||
configuration: {},
|
||||
installedBy: adminUser.id
|
||||
}
|
||||
]);
|
||||
|
||||
const [experiment] = await db.insert(schema.experiments).values({
|
||||
studyId: study!.id,
|
||||
@@ -159,6 +214,7 @@ async function main() {
|
||||
status: "ready",
|
||||
robotId: naoRobot!.id,
|
||||
createdBy: adminUser.id,
|
||||
// visualDesign will be auto-generated by designer from DB steps
|
||||
}).returning();
|
||||
|
||||
// 5. Create Steps & Actions (The Interactive Storyteller Protocol)
|
||||
@@ -168,144 +224,258 @@ async function main() {
|
||||
const [step1] = await db.insert(schema.steps).values({
|
||||
experimentId: experiment!.id,
|
||||
name: "The Hook",
|
||||
description: "Initial greeting and engagement",
|
||||
description: "Initial greeting and story introduction",
|
||||
type: "robot",
|
||||
orderIndex: 0,
|
||||
required: true,
|
||||
durationEstimate: 30
|
||||
durationEstimate: 25
|
||||
}).returning();
|
||||
|
||||
await db.insert(schema.actions).values([
|
||||
{
|
||||
stepId: step1!.id,
|
||||
name: "Greet Participant",
|
||||
type: "nao6-ros2.say_with_emotion",
|
||||
name: "Introduce Story",
|
||||
type: "nao6-ros2.say_text",
|
||||
orderIndex: 0,
|
||||
parameters: { text: "Hello there! I have a wonderful story to share with you today.", emotion: "happy", speed: 1.0 },
|
||||
pluginId: naoPlugin!.id,
|
||||
parameters: { text: "Hello. I have a story to tell you about a space traveler. Are you ready?" },
|
||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||
pluginVersion: "2.1.0",
|
||||
category: "interaction",
|
||||
retryable: true
|
||||
},
|
||||
{
|
||||
stepId: step1!.id,
|
||||
name: "Wave Greeting",
|
||||
name: "Welcome Gesture",
|
||||
type: "nao6-ros2.move_arm",
|
||||
orderIndex: 1,
|
||||
// Raising right arm to wave position
|
||||
// Open hand/welcome position
|
||||
parameters: {
|
||||
arm: "right",
|
||||
shoulder_pitch: -1.0,
|
||||
shoulder_roll: -0.3,
|
||||
elbow_yaw: 1.5,
|
||||
elbow_roll: 0.5,
|
||||
speed: 0.5
|
||||
shoulder_pitch: 1.0,
|
||||
shoulder_roll: -0.2,
|
||||
elbow_yaw: 0.5,
|
||||
elbow_roll: -0.4,
|
||||
speed: 0.4
|
||||
},
|
||||
pluginId: naoPlugin!.id,
|
||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||
pluginVersion: "2.1.0",
|
||||
category: "movement",
|
||||
retryable: true
|
||||
}
|
||||
]);
|
||||
|
||||
// --- Step 2: The Narrative (Part 1) ---
|
||||
// --- Step 2: The Narrative ---
|
||||
const [step2] = await db.insert(schema.steps).values({
|
||||
experimentId: experiment!.id,
|
||||
name: "The Narrative - Part 1",
|
||||
description: "Robot tells the first part of the story",
|
||||
name: "The Narrative",
|
||||
description: "Robot tells the space traveler story with gaze behavior",
|
||||
type: "robot",
|
||||
orderIndex: 1,
|
||||
required: true,
|
||||
durationEstimate: 60
|
||||
}).returning();
|
||||
|
||||
await db.insert(schema.actions).values([
|
||||
{
|
||||
stepId: step2!.id,
|
||||
name: "Tell Story Part 1",
|
||||
type: "nao6-ros2.say_text",
|
||||
orderIndex: 0,
|
||||
parameters: { text: "Once upon a time, in a land far away, there lived a curious robot named Alpha." },
|
||||
pluginId: naoPlugin!.id,
|
||||
category: "interaction"
|
||||
},
|
||||
{
|
||||
stepId: step2!.id,
|
||||
name: "Look at Audience",
|
||||
type: "nao6-ros2.move_head",
|
||||
orderIndex: 1,
|
||||
parameters: { yaw: 0.0, pitch: -0.2, speed: 0.5 },
|
||||
pluginId: naoPlugin!.id,
|
||||
category: "movement"
|
||||
}
|
||||
]);
|
||||
|
||||
// --- Step 3: Comprehension Check (Wizard Decision) ---
|
||||
// Note: In a real visual designer, this would be a Branch/Conditional.
|
||||
// Here we model it as a Wizard Step where they explicitly choose the next robot action.
|
||||
const [step3] = await db.insert(schema.steps).values({
|
||||
experimentId: experiment!.id,
|
||||
name: "Comprehension Check",
|
||||
description: "Wizard verifies participant understanding",
|
||||
type: "wizard",
|
||||
orderIndex: 2,
|
||||
required: true,
|
||||
durationEstimate: 45
|
||||
}).returning();
|
||||
|
||||
await db.insert(schema.actions).values([
|
||||
{
|
||||
stepId: step3!.id,
|
||||
name: "Ask Question",
|
||||
type: "nao6-ros2.say_with_emotion",
|
||||
stepId: step2!.id,
|
||||
name: "Tell Story",
|
||||
type: "nao6-ros2.say_text",
|
||||
orderIndex: 0,
|
||||
parameters: { text: "Did you understand the story so far?", emotion: "happy", speed: 1.0 },
|
||||
pluginId: naoPlugin!.id,
|
||||
category: "interaction"
|
||||
parameters: { text: "The traveler flew to Mars. He found a red rock that glowed in the dark. He put it in his pocket." },
|
||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||
pluginVersion: "2.1.0",
|
||||
category: "interaction",
|
||||
retryable: true
|
||||
},
|
||||
{
|
||||
stepId: step3!.id,
|
||||
name: "Wait for Wizard Input",
|
||||
type: "wizard_wait_for_response",
|
||||
stepId: step2!.id,
|
||||
name: "Look Away (Thinking)",
|
||||
type: "nao6-ros2.turn_head",
|
||||
orderIndex: 1,
|
||||
parameters: {
|
||||
prompt_text: "Did participant answer 'Alpha'?",
|
||||
response_type: "verbal",
|
||||
timeout: 60
|
||||
parameters: { yaw: 1.5, pitch: 0.0, speed: 0.3 },
|
||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||
pluginVersion: "2.1.0",
|
||||
category: "movement",
|
||||
retryable: true
|
||||
},
|
||||
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) ---
|
||||
// For linear seed, we just add the Positive feedback step
|
||||
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 3: Comprehension Check (Wizard Decision Point) ---
|
||||
// Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect)
|
||||
// --- Step 4a: Correct Response Branch ---
|
||||
const [step4a] = await db.insert(schema.steps).values({
|
||||
experimentId: experiment!.id,
|
||||
name: "Positive Feedback",
|
||||
description: "Correct answer response",
|
||||
name: "Branch A: Correct Response",
|
||||
description: "Response when participant says 'Red'",
|
||||
type: "robot",
|
||||
orderIndex: 3,
|
||||
required: false,
|
||||
durationEstimate: 20
|
||||
}).returning();
|
||||
|
||||
// --- Step 4b: Incorrect Response Branch ---
|
||||
const [step4b] = await db.insert(schema.steps).values({
|
||||
experimentId: experiment!.id,
|
||||
name: "Branch B: Incorrect Response",
|
||||
description: "Response when participant gives wrong answer",
|
||||
type: "robot",
|
||||
orderIndex: 4,
|
||||
required: false,
|
||||
durationEstimate: 20
|
||||
}).returning();
|
||||
|
||||
// --- Step 3: Comprehension Check (Wizard Decision Point) ---
|
||||
// Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect)
|
||||
const [step3] = await db.insert(schema.steps).values({
|
||||
experimentId: experiment!.id,
|
||||
name: "Comprehension Check",
|
||||
description: "Ask participant about rock color and wait for wizard input",
|
||||
type: "conditional",
|
||||
orderIndex: 2,
|
||||
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();
|
||||
|
||||
await db.insert(schema.actions).values([
|
||||
{
|
||||
stepId: step4!.id,
|
||||
name: "Express Agreement",
|
||||
type: "nao6-ros2.say_with_emotion",
|
||||
stepId: step3!.id,
|
||||
name: "Ask Question",
|
||||
type: "nao6-ros2.say_text",
|
||||
orderIndex: 0,
|
||||
parameters: { text: "Yes, exactly!", emotion: "happy", speed: 1.0 },
|
||||
pluginId: naoPlugin!.id,
|
||||
category: "interaction"
|
||||
parameters: { text: "What color was the rock the traveler found?" },
|
||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||
pluginVersion: "2.1.0",
|
||||
category: "interaction",
|
||||
retryable: true
|
||||
},
|
||||
{
|
||||
stepId: step4!.id,
|
||||
name: "Say Correct",
|
||||
type: "nao6-ros2.say_text",
|
||||
stepId: step3!.id,
|
||||
name: "Wait for Choice",
|
||||
type: "wizard_wait_for_response",
|
||||
orderIndex: 1,
|
||||
parameters: { text: "That is correct! Well done." },
|
||||
pluginId: naoPlugin!.id,
|
||||
category: "interaction"
|
||||
// Define the options that will be presented to the Wizard
|
||||
parameters: {
|
||||
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({
|
||||
experimentId: experiment!.id,
|
||||
name: "Conclusion",
|
||||
description: "Wrap up the story",
|
||||
description: "End the story and thank participant",
|
||||
type: "robot",
|
||||
orderIndex: 4,
|
||||
orderIndex: 5,
|
||||
required: true,
|
||||
durationEstimate: 30
|
||||
durationEstimate: 25
|
||||
}).returning();
|
||||
|
||||
await db.insert(schema.actions).values([
|
||||
{
|
||||
stepId: step5!.id,
|
||||
name: "Finish Story",
|
||||
name: "End Story",
|
||||
type: "nao6-ros2.say_text",
|
||||
orderIndex: 0,
|
||||
parameters: { text: "Alpha explored the world and learned many things. The end." },
|
||||
pluginId: naoPlugin!.id,
|
||||
category: "interaction"
|
||||
parameters: { text: "The End. Thank you for listening." },
|
||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||
pluginVersion: "2.1.0",
|
||||
category: "interaction",
|
||||
retryable: true
|
||||
},
|
||||
{
|
||||
stepId: step5!.id,
|
||||
name: "Say Goodbye",
|
||||
type: "nao6-ros2.say_with_emotion",
|
||||
name: "Bow Gesture",
|
||||
type: "nao6-ros2.move_arm",
|
||||
orderIndex: 1,
|
||||
parameters: { text: "Goodbye everyone!", emotion: "happy", speed: 1.0 },
|
||||
pluginId: naoPlugin!.id,
|
||||
category: "interaction"
|
||||
parameters: {
|
||||
arm: "right",
|
||||
shoulder_pitch: 1.8,
|
||||
shoulder_roll: 0.1,
|
||||
elbow_yaw: 0.0,
|
||||
elbow_roll: -0.3,
|
||||
speed: 0.3
|
||||
},
|
||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||
pluginVersion: "2.1.0",
|
||||
category: "movement",
|
||||
retryable: true
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -360,7 +541,13 @@ async function main() {
|
||||
console.log(`Summary:`);
|
||||
console.log(`- 1 Admin User (sean@soconnor.dev)`);
|
||||
console.log(`- Study: 'Comparative WoZ Study'`);
|
||||
console.log(`- Experiment: 'The Interactive Storyteller' (${5} steps created)`);
|
||||
console.log(`- Experiment: 'The Interactive Storyteller' (6 steps created)`);
|
||||
console.log(` - Step 1: The Hook (greeting + welcome gesture)`);
|
||||
console.log(` - Step 2: The Narrative (story + gaze sequence)`);
|
||||
console.log(` - Step 3: Comprehension Check (question + wizard wait)`);
|
||||
console.log(` - Step 4a: Branch A - Correct Response (affirmation + nod)`);
|
||||
console.log(` - Step 4b: Branch B - Incorrect Response (correction + head shake)`);
|
||||
console.log(` - Step 5: Conclusion (ending + bow)`);
|
||||
console.log(`- ${insertedParticipants.length} Participants`);
|
||||
|
||||
} catch (error) {
|
||||
|
||||
84
scripts/simulate-branch-logic.ts
Normal file
84
scripts/simulate-branch-logic.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
|
||||
// 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];
|
||||
|
||||
if (!currentStep) {
|
||||
console.log("No step found at index:", currentStepIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
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");
|
||||
44
scripts/verify-conversion.ts
Normal file
44
scripts/verify-conversion.ts
Normal 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);
|
||||
});
|
||||
84
scripts/verify-trpc-logic.ts
Normal file
84
scripts/verify-trpc-logic.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
|
||||
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];
|
||||
|
||||
if (branchAStep) {
|
||||
console.log("Step 4 (Branch A):", branchAStep.name);
|
||||
console.log(" Type:", branchAStep.type);
|
||||
console.log(" Trigger:", JSON.stringify(branchAStep.trigger, null, 2));
|
||||
} else {
|
||||
console.error("Step 4 (Branch A) not found in transformed steps!");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 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];
|
||||
if (branchBStep) {
|
||||
console.log("Step 5 (Branch B):", branchBStep.name);
|
||||
console.log(" Trigger:", JSON.stringify(branchBStep.trigger, null, 2));
|
||||
} else {
|
||||
console.warn("Step 5 (Branch B) not found in transformed steps.");
|
||||
}
|
||||
}
|
||||
|
||||
verifyTrpcLogic()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -43,7 +43,7 @@ function ProfileContent({ user }: { user: ProfileUser }) {
|
||||
{/* Profile Information */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Basic Information */}
|
||||
<Card>
|
||||
<Card className="hover:shadow-md transition-shadow duration-200">
|
||||
<CardHeader>
|
||||
<CardTitle>Basic Information</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -63,7 +63,7 @@ function ProfileContent({ user }: { user: ProfileUser }) {
|
||||
</Card>
|
||||
|
||||
{/* Password Change */}
|
||||
<Card>
|
||||
<Card className="hover:shadow-md transition-shadow duration-200">
|
||||
<CardHeader>
|
||||
<CardTitle>Password</CardTitle>
|
||||
<CardDescription>Change your account password</CardDescription>
|
||||
@@ -116,7 +116,7 @@ function ProfileContent({ user }: { user: ProfileUser }) {
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* User Summary */}
|
||||
<Card>
|
||||
<Card className="hover:shadow-md transition-shadow duration-200">
|
||||
<CardHeader>
|
||||
<CardTitle>Account Summary</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
@@ -1,190 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Search,
|
||||
Filter,
|
||||
PlayCircle,
|
||||
Calendar,
|
||||
Clock,
|
||||
ChevronRight,
|
||||
User,
|
||||
LayoutGrid
|
||||
} from "lucide-react";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { BarChart3 } from "lucide-react";
|
||||
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
|
||||
import { api } from "~/trpc/react";
|
||||
import { TrialAnalysisView } from "~/components/trials/views/TrialAnalysisView";
|
||||
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 --
|
||||
import { StudyAnalyticsDataTable } from "~/components/analytics/study-analytics-data-table";
|
||||
|
||||
export default function StudyAnalyticsPage() {
|
||||
const params = useParams();
|
||||
@@ -192,11 +17,8 @@ export default function StudyAnalyticsPage() {
|
||||
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
|
||||
const { study } = useSelectedStudyDetails();
|
||||
|
||||
// State lifted up
|
||||
const [selectedTrialId, setSelectedTrialId] = useState<string | null>(null);
|
||||
|
||||
// Fetch list of trials for the selector
|
||||
const { data: trialsList, isLoading: isLoadingList } = api.trials.list.useQuery(
|
||||
// Fetch list of trials
|
||||
const { data: trialsList, isLoading } = api.trials.list.useQuery(
|
||||
{ studyId, limit: 100 },
|
||||
{ enabled: !!studyId }
|
||||
);
|
||||
@@ -217,50 +39,30 @@ export default function StudyAnalyticsPage() {
|
||||
}, [studyId, selectedStudyId, setSelectedStudyId]);
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-64px)] flex flex-col p-6 gap-6">
|
||||
<div className="flex-none">
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Analytics"
|
||||
description="Analyze trial data and replay sessions"
|
||||
title="Analysis"
|
||||
description="View and analyze session data across all trials"
|
||||
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>}>
|
||||
<AnalyticsContent
|
||||
selectedTrialId={selectedTrialId}
|
||||
setSelectedTrialId={setSelectedTrialId}
|
||||
trialsList={trialsList ?? []}
|
||||
isLoadingList={isLoadingList}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,5 @@ export function DesignerPageClient({
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<DesignerRoot experimentId={experiment.id} initialDesign={initialDesign} />
|
||||
);
|
||||
return <DesignerRoot experimentId={experiment.id} initialDesign={initialDesign} />;
|
||||
}
|
||||
|
||||
@@ -222,7 +222,10 @@ export default async function ExperimentDesignerPage({
|
||||
: "sequential";
|
||||
})(),
|
||||
order: s.orderIndex ?? idx,
|
||||
trigger: { type: "trial_start", conditions: {} },
|
||||
trigger: {
|
||||
type: idx === 0 ? "trial_start" : "previous_step",
|
||||
conditions: (s.conditions as Record<string, unknown>) || {},
|
||||
},
|
||||
actions,
|
||||
expanded: true,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,8 @@ import { Button } from "~/components/ui/button";
|
||||
import { Edit } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { ParticipantConsentManager } from "~/components/participants/ParticipantConsentManager";
|
||||
|
||||
interface ParticipantDetailPageProps {
|
||||
params: Promise<{ id: string; participantId: string }>;
|
||||
}
|
||||
@@ -61,6 +63,13 @@ export default async function ParticipantDetailPage({
|
||||
|
||||
<TabsContent value="overview">
|
||||
<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">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
|
||||
<div>
|
||||
|
||||
@@ -95,25 +95,10 @@ function AnalysisPageContent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title="Trial Analysis"
|
||||
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>
|
||||
}
|
||||
<TrialAnalysisView
|
||||
trial={trialData}
|
||||
backHref={`/studies/${studyId}/trials/${trialId}`}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<TrialAnalysisView trial={trialData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -171,10 +171,27 @@ function WizardPageContent() {
|
||||
|
||||
const renderView = () => {
|
||||
const trialData = {
|
||||
...trial,
|
||||
id: trial.id,
|
||||
status: trial.status,
|
||||
scheduledAt: trial.scheduledAt,
|
||||
startedAt: trial.startedAt,
|
||||
completedAt: trial.completedAt,
|
||||
duration: trial.duration,
|
||||
sessionNumber: trial.sessionNumber,
|
||||
notes: trial.notes,
|
||||
metadata: trial.metadata as Record<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: {
|
||||
...trial.participant,
|
||||
id: trial.participant.id,
|
||||
participantCode: trial.participant.participantCode,
|
||||
demographics: trial.participant.demographics as Record<
|
||||
string,
|
||||
unknown
|
||||
@@ -184,7 +201,7 @@ function WizardPageContent() {
|
||||
|
||||
switch (currentRole) {
|
||||
case "wizard":
|
||||
return <WizardView trial={trialData} />;
|
||||
return <WizardView trial={trialData} userRole={currentRole} />;
|
||||
case "observer":
|
||||
return <ObserverView trial={trialData} />;
|
||||
case "participant":
|
||||
@@ -195,24 +212,8 @@ function WizardPageContent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
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>
|
||||
{renderView()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ export default function DashboardPage() {
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{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 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" />
|
||||
@@ -302,7 +302,7 @@ function StatsCard({
|
||||
trend?: string;
|
||||
}) {
|
||||
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">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
|
||||
321
src/components/analytics/study-analytics-data-table.tsx
Normal file
321
src/components/analytics/study-analytics-data-table.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
"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",
|
||||
id: "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" id="tour-analytics-table">
|
||||
<div className="flex items-center py-4">
|
||||
<Input
|
||||
placeholder="Filter participants..."
|
||||
value={(table.getColumn("participantCode")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) =>
|
||||
table.getColumn("participantCode")?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="max-w-sm"
|
||||
id="tour-analytics-filter"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { formatDistanceToNow } from "date-fns";
|
||||
@@ -243,8 +243,17 @@ export const columns: ColumnDef<Experiment>[] = [
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const experiment = row.original;
|
||||
cell: ({ row }) => <ExperimentActions experiment={row.original} />,
|
||||
},
|
||||
];
|
||||
|
||||
function ExperimentActions({ experiment }: { experiment: Experiment }) {
|
||||
const utils = api.useUtils();
|
||||
const deleteMutation = api.experiments.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.experiments.list.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -256,51 +265,34 @@ export const columns: ColumnDef<Experiment>[] = [
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<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>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
Edit Metadata
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
|
||||
<LayoutTemplate className="mr-2 h-4 w-4" />
|
||||
Designer
|
||||
Design
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/studies/${experiment.studyId}/trials/new?experimentId=${experiment.id}`}
|
||||
<DropdownMenuItem
|
||||
className="text-red-600 focus:text-red-700"
|
||||
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" />
|
||||
Start Trial
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
Archive
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function ExperimentsTable() {
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ActionDefinition } from "~/lib/experiment-designer/types";
|
||||
import type { ActionDefinition, ExperimentAction } from "~/lib/experiment-designer/types";
|
||||
import corePluginDef from "~/plugins/definitions/hristudio-core.json";
|
||||
import wozPluginDef from "~/plugins/definitions/hristudio-woz.json";
|
||||
|
||||
/**
|
||||
* ActionRegistry
|
||||
*
|
||||
* Central singleton for loading and serving action definitions from:
|
||||
* - Core system action JSON manifests (served from /hristudio-core/plugins/*.json)
|
||||
* - Core system action JSON manifests (hristudio-core, hristudio-woz)
|
||||
* - Study-installed plugin action definitions (ROS2 / REST / internal transports)
|
||||
*
|
||||
* Responsibilities:
|
||||
@@ -15,12 +17,6 @@ import type { ActionDefinition } from "~/lib/experiment-designer/types";
|
||||
* - Provenance retention (core vs plugin, plugin id/version, robot id)
|
||||
* - Parameter schema → UI parameter mapping (primitive only for now)
|
||||
* - Fallback action population if core load fails (ensures minimal functionality)
|
||||
*
|
||||
* Notes:
|
||||
* - The registry is client-side only (designer runtime); server performs its own
|
||||
* validation & compilation using persisted action instances (never trusts client).
|
||||
* - Action IDs for plugins are namespaced: `${plugin.id}.${action.id}`.
|
||||
* - Core actions retain their base IDs (e.g., wait, wizard_speak) for clarity.
|
||||
*/
|
||||
export class ActionRegistry {
|
||||
private static instance: ActionRegistry;
|
||||
@@ -31,6 +27,8 @@ export class ActionRegistry {
|
||||
private loadedStudyId: string | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
private readonly SYSTEM_PLUGIN_IDS = ["hristudio-core", "hristudio-woz"];
|
||||
|
||||
static getInstance(): ActionRegistry {
|
||||
if (!ActionRegistry.instance) {
|
||||
ActionRegistry.instance = new ActionRegistry();
|
||||
@@ -49,281 +47,27 @@ export class ActionRegistry {
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
/* ---------------- Core Actions ---------------- */
|
||||
/* ---------------- Core / System Actions ---------------- */
|
||||
|
||||
async loadCoreActions(): Promise<void> {
|
||||
if (this.coreActionsLoaded) return;
|
||||
|
||||
interface CoreBlockParam {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
placeholder?: string;
|
||||
options?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
value?: string | number | boolean;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
step?: number;
|
||||
}
|
||||
// Load System Plugins (Core & WoZ)
|
||||
this.registerPluginDefinition(corePluginDef);
|
||||
this.registerPluginDefinition(wozPluginDef);
|
||||
|
||||
interface CoreBlock {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
parameters?: CoreBlockParam[];
|
||||
timeoutMs?: number;
|
||||
retryable?: boolean;
|
||||
nestable?: boolean;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
console.log(`[ActionRegistry] Loaded system plugins: ${this.SYSTEM_PLUGIN_IDS.join(", ")}`);
|
||||
|
||||
this.coreActionsLoaded = true;
|
||||
this.notifyListeners();
|
||||
} catch (error) {
|
||||
console.error("Failed to load core actions:", error);
|
||||
this.loadFallbackActions();
|
||||
}
|
||||
}
|
||||
|
||||
private mapBlockCategoryToActionCategory(
|
||||
category: string,
|
||||
): ActionDefinition["category"] {
|
||||
switch (category) {
|
||||
case "wizard":
|
||||
return "wizard";
|
||||
case "event":
|
||||
return "wizard"; // Events are wizard-initiated triggers
|
||||
case "robot":
|
||||
return "robot";
|
||||
case "control":
|
||||
return "control";
|
||||
case "sensor":
|
||||
case "observation":
|
||||
return "observation";
|
||||
default:
|
||||
return "wizard";
|
||||
}
|
||||
}
|
||||
|
||||
private loadFallbackActions(): void {
|
||||
const fallbackActions: ActionDefinition[] = [
|
||||
{
|
||||
id: "wizard_say",
|
||||
type: "wizard_say",
|
||||
name: "Wizard Says",
|
||||
description: "Wizard speaks to participant",
|
||||
category: "wizard",
|
||||
icon: "MessageSquare",
|
||||
color: "#a855f7",
|
||||
parameters: [
|
||||
{
|
||||
id: "message",
|
||||
name: "Message",
|
||||
type: "text",
|
||||
placeholder: "Hello, participant!",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "tone",
|
||||
name: "Tone",
|
||||
type: "select",
|
||||
options: ["neutral", "friendly", "encouraging"],
|
||||
value: "neutral",
|
||||
},
|
||||
],
|
||||
source: { kind: "core", baseActionId: "wizard_say" },
|
||||
execution: { transport: "internal", timeoutMs: 30000 },
|
||||
parameterSchemaRaw: {},
|
||||
nestable: false,
|
||||
},
|
||||
{
|
||||
id: "wait",
|
||||
type: "wait",
|
||||
name: "Wait",
|
||||
description: "Wait for specified time",
|
||||
category: "control",
|
||||
icon: "Clock",
|
||||
color: "#f59e0b",
|
||||
parameters: [
|
||||
{
|
||||
id: "duration",
|
||||
name: "Duration (seconds)",
|
||||
type: "number",
|
||||
min: 0.1,
|
||||
max: 300,
|
||||
value: 2,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
source: { kind: "core", baseActionId: "wait" },
|
||||
execution: { transport: "internal", timeoutMs: 60000 },
|
||||
parameterSchemaRaw: {
|
||||
type: "object",
|
||||
properties: {
|
||||
duration: {
|
||||
type: "number",
|
||||
minimum: 0.1,
|
||||
maximum: 300,
|
||||
default: 2,
|
||||
},
|
||||
},
|
||||
required: ["duration"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "observe",
|
||||
type: "observe",
|
||||
name: "Observe",
|
||||
description: "Record participant behavior",
|
||||
category: "observation",
|
||||
icon: "Eye",
|
||||
color: "#8b5cf6",
|
||||
parameters: [
|
||||
{
|
||||
id: "behavior",
|
||||
name: "Behavior to observe",
|
||||
type: "select",
|
||||
options: ["facial_expression", "body_language", "verbal_response"],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
source: { kind: "core", baseActionId: "observe" },
|
||||
execution: { transport: "internal", timeoutMs: 120000 },
|
||||
parameterSchemaRaw: {
|
||||
type: "object",
|
||||
properties: {
|
||||
behavior: {
|
||||
type: "string",
|
||||
enum: ["facial_expression", "body_language", "verbal_response"],
|
||||
},
|
||||
},
|
||||
required: ["behavior"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
fallbackActions.forEach((action) => this.actions.set(action.id, action));
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
/* ---------------- Plugin Actions ---------------- */
|
||||
|
||||
loadPluginActions(
|
||||
studyId: string,
|
||||
studyPlugins: Array<{
|
||||
plugin: {
|
||||
id: string;
|
||||
robotId: string | null;
|
||||
version: string | null;
|
||||
actionDefinitions?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
icon?: string;
|
||||
timeout?: number;
|
||||
retryable?: boolean;
|
||||
aliases?: string[];
|
||||
parameterSchema?: unknown;
|
||||
ros2?: {
|
||||
topic?: string;
|
||||
messageType?: string;
|
||||
service?: string;
|
||||
action?: string;
|
||||
payloadMapping?: unknown;
|
||||
qos?: {
|
||||
reliability?: string;
|
||||
durability?: string;
|
||||
history?: string;
|
||||
depth?: number;
|
||||
};
|
||||
};
|
||||
rest?: {
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
}>;
|
||||
metadata?: Record<string, any>;
|
||||
};
|
||||
}>,
|
||||
studyPlugins: any[],
|
||||
): void {
|
||||
// console.log("ActionRegistry.loadPluginActions called with:", { studyId, pluginCount: studyPlugins?.length ?? 0 });
|
||||
|
||||
if (this.pluginActionsLoaded && this.loadedStudyId === studyId) return;
|
||||
|
||||
if (this.loadedStudyId !== studyId) {
|
||||
@@ -332,17 +76,30 @@ export class ActionRegistry {
|
||||
|
||||
let totalActionsLoaded = 0;
|
||||
|
||||
(studyPlugins ?? []).forEach((studyPlugin) => {
|
||||
const { plugin } = studyPlugin;
|
||||
(studyPlugins ?? []).forEach((plugin) => {
|
||||
this.registerPluginDefinition(plugin);
|
||||
totalActionsLoaded += (plugin.actionDefinitions?.length || 0);
|
||||
});
|
||||
|
||||
console.log(
|
||||
`ActionRegistry: Loaded ${totalActionsLoaded} plugin actions for study ${studyId}`,
|
||||
);
|
||||
|
||||
this.pluginActionsLoaded = true;
|
||||
this.loadedStudyId = studyId;
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
/* ---------------- Shared Registration Logic ---------------- */
|
||||
|
||||
private registerPluginDefinition(plugin: any) {
|
||||
const actionDefs = Array.isArray(plugin.actionDefinitions)
|
||||
? plugin.actionDefinitions
|
||||
: undefined;
|
||||
|
||||
// console.log(`Plugin ${plugin.id}:`, { actionCount: actionDefs?.length ?? 0 });
|
||||
|
||||
if (!actionDefs) return;
|
||||
|
||||
actionDefs.forEach((action) => {
|
||||
actionDefs.forEach((action: any) => {
|
||||
const rawCategory =
|
||||
typeof action.category === "string"
|
||||
? action.category.toLowerCase().trim()
|
||||
@@ -353,7 +110,14 @@ export class ActionRegistry {
|
||||
control: "control",
|
||||
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
|
||||
? {
|
||||
@@ -386,36 +150,50 @@ export class ActionRegistry {
|
||||
retryable: action.retryable,
|
||||
};
|
||||
|
||||
// Extract semantic ID from metadata if available, otherwise fall back to database IDs (which typically causes mismatch if seed uses semantic)
|
||||
// Ideally, plugin.metadata.robotId should populate this.
|
||||
const semanticRobotId = plugin.metadata?.robotId || plugin.robotId || plugin.id;
|
||||
// Extract semantic ID from metadata if available, otherwise fall back to database IDs
|
||||
// Priority: metadata.robotId > metadata.id (for system plugins) > robotId > id
|
||||
const semanticRobotId =
|
||||
plugin.metadata?.robotId ||
|
||||
plugin.metadata?.id ||
|
||||
plugin.robotId ||
|
||||
plugin.id;
|
||||
|
||||
// For system plugins, we want to keep the short IDs (wait, branch) to avoid breaking existing save data
|
||||
// For robot plugins, we namespace them (nao6-ros2.say_text)
|
||||
const isSystem = this.SYSTEM_PLUGIN_IDS.includes(semanticRobotId);
|
||||
const actionId = isSystem ? action.id : `${semanticRobotId}.${action.id}`;
|
||||
const actionType = actionId; // Type is usually same as ID
|
||||
|
||||
const actionDef: ActionDefinition = {
|
||||
id: `${semanticRobotId}.${action.id}`,
|
||||
type: `${semanticRobotId}.${action.id}`,
|
||||
id: actionId,
|
||||
type: actionType,
|
||||
name: action.name,
|
||||
description: action.description ?? "",
|
||||
category,
|
||||
icon: action.icon ?? "Bot",
|
||||
color: "#10b981",
|
||||
color: action.color || "#10b981",
|
||||
parameters: this.convertParameterSchemaToParameters(
|
||||
action.parameterSchema,
|
||||
),
|
||||
source: {
|
||||
kind: "plugin",
|
||||
pluginId: semanticRobotId, // Use semantic ID here too
|
||||
kind: isSystem ? "core" : "plugin", // Maintain 'core' distinction for UI grouping if needed
|
||||
pluginId: semanticRobotId,
|
||||
robotId: plugin.robotId,
|
||||
pluginVersion: plugin.version ?? undefined,
|
||||
baseActionId: action.id,
|
||||
},
|
||||
execution,
|
||||
parameterSchemaRaw: action.parameterSchema ?? undefined,
|
||||
nestable: action.nestable
|
||||
};
|
||||
this.actions.set(actionDef.id, actionDef);
|
||||
// Register aliases if provided by plugin metadata
|
||||
const aliases = Array.isArray(action.aliases)
|
||||
? action.aliases
|
||||
: undefined;
|
||||
|
||||
// Prevent overwriting if it already exists (first-come-first-served, usually core first)
|
||||
if (!this.actions.has(actionId)) {
|
||||
this.actions.set(actionId, actionDef);
|
||||
}
|
||||
|
||||
// Register aliases
|
||||
const aliases = Array.isArray(action.aliases) ? action.aliases : undefined;
|
||||
if (aliases) {
|
||||
for (const alias of aliases) {
|
||||
if (typeof alias === "string" && alias.trim()) {
|
||||
@@ -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(
|
||||
@@ -458,7 +224,7 @@ export class ActionRegistry {
|
||||
if (!schema?.properties) return [];
|
||||
|
||||
return Object.entries(schema.properties).map(([key, paramDef]) => {
|
||||
let type: "text" | "number" | "select" | "boolean" = "text";
|
||||
let type: "text" | "number" | "select" | "boolean" | "json" | "array" = "text";
|
||||
|
||||
if (paramDef.type === "number") {
|
||||
type = "number";
|
||||
@@ -466,6 +232,10 @@ export class ActionRegistry {
|
||||
type = "boolean";
|
||||
} else if (paramDef.enum && Array.isArray(paramDef.enum)) {
|
||||
type = "select";
|
||||
} else if (paramDef.type === "array") {
|
||||
type = "array";
|
||||
} else if (paramDef.type === "object") {
|
||||
type = "json";
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -485,29 +255,17 @@ export class ActionRegistry {
|
||||
private resetPluginActions(): void {
|
||||
this.pluginActionsLoaded = false;
|
||||
this.loadedStudyId = null;
|
||||
// Remove existing plugin actions (retain known core ids + fallback ids)
|
||||
const pluginActionIds = Array.from(this.actions.keys()).filter(
|
||||
(id) =>
|
||||
!id.startsWith("wizard_") &&
|
||||
!id.startsWith("when_") &&
|
||||
!id.startsWith("wait") &&
|
||||
!id.startsWith("observe") &&
|
||||
!id.startsWith("repeat") &&
|
||||
!id.startsWith("if_") &&
|
||||
!id.startsWith("parallel") &&
|
||||
!id.startsWith("sequence") &&
|
||||
!id.startsWith("random_") &&
|
||||
!id.startsWith("try_") &&
|
||||
!id.startsWith("break") &&
|
||||
!id.startsWith("measure_") &&
|
||||
!id.startsWith("count_") &&
|
||||
!id.startsWith("record_") &&
|
||||
!id.startsWith("capture_") &&
|
||||
!id.startsWith("log_") &&
|
||||
!id.startsWith("survey_") &&
|
||||
!id.startsWith("physiological_"),
|
||||
);
|
||||
pluginActionIds.forEach((id) => this.actions.delete(id));
|
||||
|
||||
// Robust Reset: Remove valid plugin actions, BUT protect system plugins.
|
||||
const idsToDelete: string[] = [];
|
||||
this.actions.forEach((action, id) => {
|
||||
if (action.source.kind === "plugin" && !this.SYSTEM_PLUGIN_IDS.includes(action.source.pluginId || "")) {
|
||||
idsToDelete.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
idsToDelete.forEach((id) => this.actions.delete(id));
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
/* ---------------- Query Helpers ---------------- */
|
||||
|
||||
@@ -8,7 +8,17 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
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 { PageHeader } from "~/components/ui/page-header";
|
||||
@@ -27,7 +37,7 @@ import {
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
KeyboardSensor,
|
||||
closestCorners,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
type DragOverEvent,
|
||||
@@ -35,7 +45,8 @@ import {
|
||||
import { BottomStatusBar } from "./layout/BottomStatusBar";
|
||||
import { ActionLibraryPanel } from "./panels/ActionLibraryPanel";
|
||||
import { InspectorPanel } from "./panels/InspectorPanel";
|
||||
import { FlowWorkspace } from "./flow/FlowWorkspace";
|
||||
import { FlowWorkspace, SortableActionChip, StepCardPreview } from "./flow/FlowWorkspace";
|
||||
import { GripVertical } from "lucide-react";
|
||||
|
||||
import {
|
||||
type ExperimentDesign,
|
||||
@@ -44,12 +55,13 @@ import {
|
||||
} from "~/lib/experiment-designer/types";
|
||||
|
||||
import { useDesignerStore } from "./state/store";
|
||||
import { actionRegistry } from "./ActionRegistry";
|
||||
import { actionRegistry, useActionRegistry } from "./ActionRegistry";
|
||||
import { computeDesignHash } from "./state/hashing";
|
||||
import {
|
||||
validateExperimentDesign,
|
||||
groupIssuesByEntity,
|
||||
} from "./state/validators";
|
||||
import { convertDatabaseToSteps } from "~/lib/experiment-designer/block-converter";
|
||||
|
||||
/**
|
||||
* DesignerRoot
|
||||
@@ -94,6 +106,7 @@ interface RawExperiment {
|
||||
integrityHash?: string | null;
|
||||
pluginDependencies?: string[] | null;
|
||||
visualDesign?: unknown;
|
||||
steps?: unknown[]; // DB steps from relation
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -101,6 +114,37 @@ interface RawExperiment {
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
|
||||
// 1. Prefer database steps (Source of Truth) if valid, to ensure we have the latest
|
||||
// plugin provenance data (which might be missing from stale visualDesign snapshots).
|
||||
// 1. Prefer database steps (Source of Truth) if valid.
|
||||
if (Array.isArray(exp.steps) && exp.steps.length > 0) {
|
||||
try {
|
||||
// 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 (
|
||||
!exp.visualDesign ||
|
||||
typeof exp.visualDesign !== "object" ||
|
||||
@@ -114,6 +158,7 @@ function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
|
||||
lastSaved?: string;
|
||||
};
|
||||
if (!Array.isArray(vd.steps)) return undefined;
|
||||
|
||||
return {
|
||||
id: exp.id,
|
||||
name: exp.name,
|
||||
@@ -152,6 +197,9 @@ export function DesignerRoot({
|
||||
autoCompile = true,
|
||||
onPersist,
|
||||
}: DesignerRootProps) {
|
||||
// Subscribe to registry updates to ensure re-renders when actions load
|
||||
useActionRegistry();
|
||||
|
||||
const { startTour } = useTour();
|
||||
|
||||
/* ----------------------------- Remote Experiment ------------------------- */
|
||||
@@ -159,7 +207,18 @@ export function DesignerRoot({
|
||||
data: experiment,
|
||||
isLoading: loadingExperiment,
|
||||
refetch: refetchExperiment,
|
||||
} = api.experiments.get.useQuery({ id: experimentId });
|
||||
} = api.experiments.get.useQuery(
|
||||
{ id: experimentId },
|
||||
{
|
||||
// Debug Mode: Disable all caching to ensure fresh data from DB
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 0,
|
||||
gcTime: 0, // Garbage collect immediately
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
|
||||
const updateExperiment = api.experiments.update.useMutation({
|
||||
onError: (err) => {
|
||||
@@ -199,6 +258,7 @@ export function DesignerRoot({
|
||||
const upsertAction = useDesignerStore((s) => s.upsertAction);
|
||||
const selectStep = useDesignerStore((s) => s.selectStep);
|
||||
const selectAction = useDesignerStore((s) => s.selectAction);
|
||||
const reorderStep = useDesignerStore((s) => s.reorderStep);
|
||||
const setValidationIssues = useDesignerStore((s) => s.setValidationIssues);
|
||||
const clearAllValidationIssues = useDesignerStore(
|
||||
(s) => s.clearAllValidationIssues,
|
||||
@@ -258,6 +318,23 @@ export function DesignerRoot({
|
||||
const [inspectorTab, setInspectorTab] = useState<
|
||||
"properties" | "issues" | "dependencies"
|
||||
>("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).
|
||||
* Captures a lightweight subset for visual feedback.
|
||||
@@ -269,6 +346,11 @@ export function DesignerRoot({
|
||||
description?: string;
|
||||
} | null>(null);
|
||||
|
||||
const [activeSortableItem, setActiveSortableItem] = useState<{
|
||||
type: 'step' | 'action';
|
||||
data: any;
|
||||
} | null>(null);
|
||||
|
||||
/* ----------------------------- Initialization ---------------------------- */
|
||||
useEffect(() => {
|
||||
if (initialized) return;
|
||||
@@ -327,13 +409,14 @@ export function DesignerRoot({
|
||||
.catch((err) => console.error("Core action load failed:", err));
|
||||
}, []);
|
||||
|
||||
// Load plugin actions when study plugins available
|
||||
// Load plugin actions only after we have the flattened, processed plugin list
|
||||
useEffect(() => {
|
||||
if (!experiment?.studyId) return;
|
||||
if (!studyPluginsRaw) return;
|
||||
// @ts-expect-error - studyPluginsRaw type from tRPC is compatible but TypeScript can't infer it
|
||||
actionRegistry.loadPluginActions(experiment.studyId, studyPluginsRaw);
|
||||
}, [experiment?.studyId, studyPluginsRaw]);
|
||||
if (!studyPlugins) return;
|
||||
|
||||
// Pass the flattened plugins which match the structure ActionRegistry expects
|
||||
actionRegistry.loadPluginActions(experiment.studyId, studyPlugins);
|
||||
}, [experiment?.studyId, studyPlugins]);
|
||||
|
||||
/* ------------------------- Ready State Management ------------------------ */
|
||||
// Mark as ready once initialized and plugins are loaded
|
||||
@@ -348,11 +431,10 @@ export function DesignerRoot({
|
||||
// Small delay to ensure all components have rendered
|
||||
const timer = setTimeout(() => {
|
||||
setIsReady(true);
|
||||
// console.log('[DesignerRoot] ✅ Designer ready (plugins loaded), fading in');
|
||||
}, 150);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [initialized, isReady, studyPluginsRaw]);
|
||||
}, [initialized, isReady, studyPlugins]);
|
||||
|
||||
/* ----------------------- Automatic Hash Recomputation -------------------- */
|
||||
// Automatically recompute hash when steps change (debounced to avoid excessive computation)
|
||||
@@ -415,6 +497,7 @@ export function DesignerRoot({
|
||||
const currentSteps = [...steps];
|
||||
// Ensure core actions are loaded before validating
|
||||
await actionRegistry.loadCoreActions();
|
||||
|
||||
const result = validateExperimentDesign(currentSteps, {
|
||||
steps: currentSteps,
|
||||
actionDefinitions: actionRegistry.getAllActions(),
|
||||
@@ -482,6 +565,15 @@ export function DesignerRoot({
|
||||
clearAllValidationIssues,
|
||||
]);
|
||||
|
||||
// Trigger initial validation when ready (plugins loaded) to ensure no stale errors
|
||||
// Trigger initial validation when ready (plugins loaded) to ensure no stale errors
|
||||
// DISABLED: User prefers manual validation to avoid noise on improved sequential architecture
|
||||
// useEffect(() => {
|
||||
// if (isReady) {
|
||||
// void validateDesign();
|
||||
// }
|
||||
// }, [isReady, validateDesign]);
|
||||
|
||||
/* --------------------------------- Save ---------------------------------- */
|
||||
const persist = useCallback(async () => {
|
||||
if (!initialized) return;
|
||||
@@ -535,6 +627,9 @@ export function DesignerRoot({
|
||||
setLastSavedAt(new Date());
|
||||
toast.success("Experiment saved");
|
||||
|
||||
// Auto-validate after save to clear "Modified" (drift) status
|
||||
void validateDesign();
|
||||
|
||||
console.log('[DesignerRoot] 💾 SAVE complete');
|
||||
|
||||
onPersist?.({
|
||||
@@ -664,15 +759,21 @@ export function DesignerRoot({
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
/* ----------------------------- Drag Handlers ----------------------------- */
|
||||
/* ----------------------------- Drag Handlers ----------------------------- */
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const { active } = event;
|
||||
const activeId = active.id.toString();
|
||||
const activeData = active.data.current;
|
||||
|
||||
console.log("[DesignerRoot] DragStart", { activeId, activeData });
|
||||
|
||||
if (
|
||||
active.id.toString().startsWith("action-") &&
|
||||
active.data.current?.action
|
||||
activeId.startsWith("action-") &&
|
||||
activeData?.action
|
||||
) {
|
||||
const a = active.data.current.action as {
|
||||
const a = activeData.action as {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
@@ -686,6 +787,18 @@ export function DesignerRoot({
|
||||
category: a.category,
|
||||
description: a.description,
|
||||
});
|
||||
} else if (activeId.startsWith("s-step-")) {
|
||||
console.log("[DesignerRoot] Setting active sortable STEP", activeData);
|
||||
setActiveSortableItem({
|
||||
type: 'step',
|
||||
data: activeData
|
||||
});
|
||||
} else if (activeId.startsWith("s-act-")) {
|
||||
console.log("[DesignerRoot] Setting active sortable ACTION", activeData);
|
||||
setActiveSortableItem({
|
||||
type: 'action',
|
||||
data: activeData
|
||||
});
|
||||
}
|
||||
},
|
||||
[toggleLibraryScrollLock],
|
||||
@@ -694,14 +807,7 @@ export function DesignerRoot({
|
||||
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
const store = useDesignerStore.getState();
|
||||
|
||||
// Only handle Library -> Flow projection
|
||||
if (!active.id.toString().startsWith("action-")) {
|
||||
if (store.insertionProjection) {
|
||||
store.setInsertionProjection(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const activeId = active.id.toString();
|
||||
|
||||
if (!over) {
|
||||
if (store.insertionProjection) {
|
||||
@@ -710,6 +816,16 @@ export function DesignerRoot({
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Library -> Flow Projection (Action)
|
||||
if (!activeId.startsWith("action-")) {
|
||||
if (store.insertionProjection) {
|
||||
store.setInsertionProjection(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
const overId = over.id.toString();
|
||||
const activeDef = active.data.current?.action;
|
||||
|
||||
@@ -804,6 +920,7 @@ export function DesignerRoot({
|
||||
// Clear overlay immediately
|
||||
toggleLibraryScrollLock(false);
|
||||
setDragOverlayAction(null);
|
||||
setActiveSortableItem(null);
|
||||
|
||||
// Capture and clear projection
|
||||
const store = useDesignerStore.getState();
|
||||
@@ -814,6 +931,32 @@ export function DesignerRoot({
|
||||
return;
|
||||
}
|
||||
|
||||
const activeId = active.id.toString();
|
||||
|
||||
// Handle Step Reordering (Active is a sortable step)
|
||||
if (activeId.startsWith("s-step-")) {
|
||||
const overId = over.id.toString();
|
||||
// Allow reordering over both sortable steps (s-step-) and drop zones (step-)
|
||||
if (!overId.startsWith("s-step-") && !overId.startsWith("step-")) return;
|
||||
|
||||
// Strip prefixes to get raw IDs
|
||||
const rawActiveId = activeId.replace(/^s-step-/, "");
|
||||
const rawOverId = overId.replace(/^s-step-/, "").replace(/^step-/, "");
|
||||
|
||||
console.log("[DesignerRoot] DragEnd - Step Sort", { activeId, overId, rawActiveId, rawOverId });
|
||||
|
||||
const oldIndex = steps.findIndex((s) => s.id === rawActiveId);
|
||||
const newIndex = steps.findIndex((s) => s.id === rawOverId);
|
||||
|
||||
console.log("[DesignerRoot] Indices", { oldIndex, newIndex });
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||
console.log("[DesignerRoot] Reordering...");
|
||||
reorderStep(oldIndex, newIndex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Determine Target (Step, Parent, Index)
|
||||
let stepId: string | null = null;
|
||||
let parentId: string | null = null;
|
||||
@@ -880,8 +1023,9 @@ export function DesignerRoot({
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const newId = `action-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const newAction: ExperimentAction = {
|
||||
id: crypto.randomUUID(),
|
||||
id: newId,
|
||||
type: actionDef.type, // this is the 'type' key
|
||||
name: actionDef.name,
|
||||
category: actionDef.category as any,
|
||||
@@ -906,7 +1050,7 @@ export function DesignerRoot({
|
||||
void recomputeHash();
|
||||
}
|
||||
},
|
||||
[steps, upsertAction, selectAction, recomputeHash, toggleLibraryScrollLock],
|
||||
[steps, upsertAction, selectAction, recomputeHash, toggleLibraryScrollLock, reorderStep],
|
||||
);
|
||||
// validation status badges removed (unused)
|
||||
/* ------------------------------- Panels ---------------------------------- */
|
||||
@@ -935,10 +1079,11 @@ export function DesignerRoot({
|
||||
activeTab={inspectorTab}
|
||||
onTabChange={setInspectorTab}
|
||||
studyPlugins={studyPlugins}
|
||||
onClearAll={clearAllValidationIssues}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[inspectorTab, studyPlugins],
|
||||
[inspectorTab, studyPlugins, clearAllValidationIssues],
|
||||
);
|
||||
|
||||
/* ------------------------------- Render ---------------------------------- */
|
||||
@@ -982,69 +1127,94 @@ export function DesignerRoot({
|
||||
);
|
||||
|
||||
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
|
||||
title={designMeta.name}
|
||||
description={designMeta.description || "No description"}
|
||||
icon={Play}
|
||||
actions={actions}
|
||||
className="pb-6"
|
||||
className="flex-none pb-4"
|
||||
/>
|
||||
|
||||
<div className="relative flex flex-1 flex-col overflow-hidden">
|
||||
{/* Loading Overlay */}
|
||||
{!isReady && (
|
||||
<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">
|
||||
{/* Main Grid Container - 2-4-2 Split */}
|
||||
{/* Main Grid Container - 2-4-2 Split */}
|
||||
<div className="flex-1 min-h-0 w-full px-2 overflow-hidden">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={() => toggleLibraryScrollLock(false)}
|
||||
>
|
||||
<PanelsContainer
|
||||
showDividers
|
||||
className="min-h-0 flex-1"
|
||||
left={leftPanel}
|
||||
center={centerPanel}
|
||||
right={rightPanel}
|
||||
/>
|
||||
<DragOverlay>
|
||||
{dragOverlayAction ? (
|
||||
<div className="bg-background flex items-center gap-2 rounded border px-3 py-2 text-xs font-medium shadow-lg select-none">
|
||||
<span
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 rounded-full",
|
||||
{
|
||||
wizard: "bg-blue-500",
|
||||
robot: "bg-emerald-600",
|
||||
control: "bg-amber-500",
|
||||
observation: "bg-purple-600",
|
||||
}[dragOverlayAction.category] || "bg-slate-400",
|
||||
)}
|
||||
/>
|
||||
{dragOverlayAction.name}
|
||||
<div className="grid grid-cols-8 gap-4 h-full w-full transition-all duration-300 ease-in-out">
|
||||
{/* Left Panel (Library) */}
|
||||
{!leftCollapsed && (
|
||||
<div className={cn(
|
||||
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
|
||||
rightCollapsed ? "col-span-3" : "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">Action Library</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setLeftCollapsed(true)}
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<div className="flex-shrink-0 border-t">
|
||||
<div className="flex-1 overflow-hidden min-h-0 bg-muted/10">
|
||||
{leftPanel}
|
||||
</div>
|
||||
</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 && (
|
||||
<div className="flex items-center">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => startTour('designer')}>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
{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>
|
||||
<div className="flex-1 overflow-hidden min-h-0 relative">
|
||||
{centerPanel}
|
||||
</div>
|
||||
<div className="border-t">
|
||||
<BottomStatusBar
|
||||
onSave={() => persist()}
|
||||
onValidate={() => validateDesign()}
|
||||
@@ -1057,7 +1227,67 @@ export function DesignerRoot({
|
||||
/>
|
||||
</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 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>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type ExperimentDesign,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
import { actionRegistry } from "./ActionRegistry";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Settings,
|
||||
Zap,
|
||||
@@ -39,6 +40,9 @@ import {
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
Plus,
|
||||
GitBranch,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
@@ -275,8 +279,138 @@ export function PropertiesPanelBase({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parameters */}
|
||||
{def?.parameters.length ? (
|
||||
{/* Branching Configuration (Special Case) */}
|
||||
{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="text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||
Parameters
|
||||
@@ -304,6 +438,7 @@ export function PropertiesPanelBase({
|
||||
<div className="text-muted-foreground text-xs">
|
||||
No parameters for this action.
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -388,17 +523,18 @@ export function PropertiesPanelBase({
|
||||
onValueChange={(val) => {
|
||||
onStepUpdate(selectedStep.id, { type: val as StepType });
|
||||
}}
|
||||
disabled={true}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-7 w-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sequential">Sequential</SelectItem>
|
||||
<SelectItem value="parallel">Parallel</SelectItem>
|
||||
<SelectItem value="conditional">Conditional</SelectItem>
|
||||
<SelectItem value="loop">Loop</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
Steps always execute sequentially. Use control flow actions for parallel/conditional logic.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Trigger</Label>
|
||||
|
||||
@@ -46,6 +46,10 @@ export interface ValidationPanelProps {
|
||||
* Called to clear all issues for an entity.
|
||||
*/
|
||||
onEntityClear?: (entityId: string) => void;
|
||||
/**
|
||||
* Called to clear all issues globally.
|
||||
*/
|
||||
onClearAll?: () => void;
|
||||
/**
|
||||
* Optional function to map entity IDs to human-friendly names (e.g., step/action names).
|
||||
*/
|
||||
@@ -60,25 +64,25 @@ export interface ValidationPanelProps {
|
||||
const severityConfig = {
|
||||
error: {
|
||||
icon: AlertCircle,
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
bgColor: "bg-red-100 dark:bg-red-950/60",
|
||||
borderColor: "border-red-300 dark:border-red-700",
|
||||
color: "text-validation-error-text",
|
||||
bgColor: "bg-validation-error-bg",
|
||||
borderColor: "border-validation-error-border",
|
||||
badgeVariant: "destructive" as const,
|
||||
label: "Error",
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
color: "text-amber-600 dark:text-amber-400",
|
||||
bgColor: "bg-amber-100 dark:bg-amber-950/60",
|
||||
borderColor: "border-amber-300 dark:border-amber-700",
|
||||
badgeVariant: "secondary" as const,
|
||||
color: "text-validation-warning-text",
|
||||
bgColor: "bg-validation-warning-bg",
|
||||
borderColor: "border-validation-warning-border",
|
||||
badgeVariant: "outline" as const,
|
||||
label: "Warning",
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-100 dark:bg-blue-950/60",
|
||||
borderColor: "border-blue-300 dark:border-blue-700",
|
||||
color: "text-validation-info-text",
|
||||
bgColor: "bg-validation-info-bg",
|
||||
borderColor: "border-validation-info-border",
|
||||
badgeVariant: "outline" as const,
|
||||
label: "Info",
|
||||
},
|
||||
@@ -141,7 +145,7 @@ function IssueItem({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[12px] leading-snug break-words whitespace-normal">
|
||||
<p className="text-[12px] leading-snug break-words whitespace-normal text-foreground">
|
||||
{issue.message}
|
||||
</p>
|
||||
|
||||
@@ -199,6 +203,7 @@ export function ValidationPanel({
|
||||
onIssueClick,
|
||||
onIssueClear,
|
||||
onEntityClear: _onEntityClear,
|
||||
onClearAll,
|
||||
entityLabelForId,
|
||||
className,
|
||||
}: ValidationPanelProps) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
useDndContext,
|
||||
useDroppable,
|
||||
useDndMonitor,
|
||||
type DragEndEvent,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
Trash2,
|
||||
GitBranch,
|
||||
Edit3,
|
||||
CornerDownRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
@@ -80,21 +82,28 @@ export interface VirtualItem {
|
||||
|
||||
interface StepRowProps {
|
||||
item: VirtualItem;
|
||||
step: ExperimentStep; // Explicit pass for freshness
|
||||
totalSteps: number;
|
||||
selectedStepId: string | null | undefined;
|
||||
selectedActionId: string | null | undefined;
|
||||
renamingStepId: string | null;
|
||||
onSelectStep: (id: string | undefined) => void;
|
||||
onSelectAction: (stepId: string, actionId: string | undefined) => void;
|
||||
onToggleExpanded: (step: ExperimentStep) => void;
|
||||
onRenameStep: (step: ExperimentStep, name: string) => void;
|
||||
onRenameStep: (step: ExperimentStep, newName: string) => void;
|
||||
onDeleteStep: (step: ExperimentStep) => void;
|
||||
onDeleteAction: (stepId: string, actionId: string) => void;
|
||||
setRenamingStepId: (id: string | null) => void;
|
||||
registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void;
|
||||
onReorderStep: (stepId: string, direction: 'up' | 'down') => void;
|
||||
onReorderAction?: (stepId: string, actionId: string, direction: 'up' | 'down') => void;
|
||||
isChild?: boolean;
|
||||
}
|
||||
|
||||
const StepRow = React.memo(function StepRow({
|
||||
function StepRow({
|
||||
item,
|
||||
step,
|
||||
totalSteps,
|
||||
selectedStepId,
|
||||
selectedActionId,
|
||||
renamingStepId,
|
||||
@@ -106,8 +115,12 @@ const StepRow = React.memo(function StepRow({
|
||||
onDeleteAction,
|
||||
setRenamingStepId,
|
||||
registerMeasureRef,
|
||||
onReorderStep,
|
||||
onReorderAction,
|
||||
isChild,
|
||||
}: 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 displayActions = useMemo(() => {
|
||||
@@ -125,47 +138,39 @@ const StepRow = React.memo(function StepRow({
|
||||
return step.actions;
|
||||
}, [step.actions, step.id, insertionProjection]);
|
||||
|
||||
const {
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
attributes,
|
||||
listeners,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: sortableStepId(step.id),
|
||||
data: {
|
||||
type: "step",
|
||||
step: step,
|
||||
},
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top: item.top,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: "100%",
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 25 : undefined,
|
||||
transition: "top 300ms cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
// transform: CSS.Transform.toString(transform), // Removed
|
||||
// zIndex: isDragging ? 25 : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} data-step-id={step.id}>
|
||||
<div style={style} data-step-id={step.id}>
|
||||
<div
|
||||
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}
|
||||
>
|
||||
{isChild && (
|
||||
<div className="absolute left-[-24px] top-8 text-muted-foreground/40">
|
||||
<CornerDownRight className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
<StepDroppableArea stepId={step.id} />
|
||||
<div
|
||||
className={cn(
|
||||
"mb-2 rounded border shadow-sm transition-colors",
|
||||
"mb-2 rounded-lg border shadow-sm transition-colors",
|
||||
selectedStepId === step.id
|
||||
? "border-border bg-accent/30"
|
||||
: "hover:bg-accent/30",
|
||||
isDragging && "opacity-80 ring-1 ring-blue-300",
|
||||
: "hover:bg-accent/30"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -258,17 +263,108 @@ const StepRow = React.memo(function StepRow({
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div
|
||||
className="text-muted-foreground cursor-grab p-1"
|
||||
aria-label="Drag step"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
<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, '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>
|
||||
|
||||
|
||||
|
||||
{/* Conditional Branching Visualization */}
|
||||
{/* Conditional Branching Visualization */}
|
||||
{step.type === "conditional" && (
|
||||
<div className="mx-3 my-3 rounded-md border text-xs" style={{
|
||||
backgroundColor: 'var(--validation-warning-bg)', // Semantic background
|
||||
borderColor: 'var(--validation-warning-border)', // Semantic border
|
||||
}}>
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2 font-medium" style={{
|
||||
borderColor: 'var(--validation-warning-border)',
|
||||
color: 'var(--validation-warning-text)'
|
||||
}}>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
<span>Branching Logic</span>
|
||||
</div>
|
||||
|
||||
<div className="p-2 space-y-2">
|
||||
{!(step.trigger.conditions as any)?.options?.length ? (
|
||||
<div className="text-muted-foreground/60 italic text-center py-2 text-[11px]">
|
||||
No branches configured. Add options in properties.
|
||||
</div>
|
||||
) : (
|
||||
(step.trigger.conditions as any).options.map((opt: any, idx: number) => {
|
||||
// Resolve ID to name for display
|
||||
let targetName = "Unlinked";
|
||||
let targetIndex = -1;
|
||||
|
||||
if (opt.nextStepId) {
|
||||
const target = allSteps.find(s => s.id === opt.nextStepId);
|
||||
if (target) {
|
||||
targetName = target.name;
|
||||
targetIndex = target.order;
|
||||
}
|
||||
} else if (typeof opt.nextStepIndex === 'number') {
|
||||
targetIndex = opt.nextStepIndex;
|
||||
targetName = `Step #${targetIndex + 1}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={idx} className="flex items-center justify-between rounded bg-background/50 shadow-sm border p-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Badge variant="outline" className={cn(
|
||||
"text-[10px] uppercase font-bold tracking-wider px-1.5 py-0.5 min-w-[70px] justify-center bg-background",
|
||||
opt.variant === "destructive"
|
||||
? "border-red-500/30 text-red-600 dark:text-red-400"
|
||||
: "border-slate-500/30 text-foreground"
|
||||
)}>
|
||||
{opt.label}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-[10px]">then go to</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-right min-w-0 max-w-[50%]">
|
||||
<span className="font-medium truncate text-[11px] block text-foreground" title={targetName}>
|
||||
{targetName}
|
||||
</span>
|
||||
{targetIndex !== -1 && (
|
||||
<Badge variant="secondary" className="px-1 py-0 h-4 text-[9px] min-w-[20px] justify-center tabular-nums">
|
||||
#{targetIndex + 1}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50 flex-shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action List (Collapsible/Virtual content) */}
|
||||
{step.expanded && (
|
||||
<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
|
||||
</div>
|
||||
) : (
|
||||
displayActions.map((action) => (
|
||||
displayActions.map((action, index) => (
|
||||
<SortableActionChip
|
||||
key={action.id}
|
||||
stepId={step.id}
|
||||
@@ -291,6 +387,9 @@ const StepRow = React.memo(function StepRow({
|
||||
selectedActionId={selectedActionId}
|
||||
onSelectAction={onSelectAction}
|
||||
onDeleteAction={onDeleteAction}
|
||||
onReorderAction={onReorderAction}
|
||||
isFirst={index === 0}
|
||||
isLast={index === displayActions.length - 1}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -302,7 +401,51 @@ const StepRow = React.memo(function StepRow({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Utility */
|
||||
@@ -331,9 +474,19 @@ function parseSortableAction(id: string): string | null {
|
||||
/* Droppable Overlay (for palette action drops) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
function StepDroppableArea({ stepId }: { stepId: string }) {
|
||||
const { isOver } = useDroppable({ id: `step-${stepId}` });
|
||||
const { active } = useDndContext();
|
||||
const isStepDragging = active?.id.toString().startsWith("s-step-");
|
||||
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: `step-${stepId}`,
|
||||
disabled: isStepDragging
|
||||
});
|
||||
|
||||
if (isStepDragging) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
data-step-drop
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 rounded-md transition-colors",
|
||||
@@ -348,26 +501,155 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
|
||||
/* Sortable Action Chip */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface ActionChipProps {
|
||||
export interface ActionChipProps {
|
||||
stepId: string;
|
||||
action: ExperimentAction;
|
||||
parentId: string | null;
|
||||
selectedActionId: string | null | undefined;
|
||||
onSelectAction: (stepId: string, actionId: string | undefined) => void;
|
||||
onDeleteAction: (stepId: string, actionId: string) => void;
|
||||
onReorderAction?: (stepId: string, actionId: string, direction: 'up' | 'down') => void;
|
||||
dragHandle?: boolean;
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
function SortableActionChip({
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Action Chip Visuals (Pure Component) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface ActionChipVisualsProps {
|
||||
action: ExperimentAction;
|
||||
isSelected?: boolean;
|
||||
isDragging?: boolean;
|
||||
isOverNested?: boolean;
|
||||
onSelect?: (e: React.MouseEvent) => void;
|
||||
onDelete?: (e: React.MouseEvent) => void;
|
||||
onReorder?: (direction: 'up' | 'down') => void;
|
||||
dragHandleProps?: React.HTMLAttributes<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,
|
||||
action,
|
||||
parentId,
|
||||
selectedActionId,
|
||||
onSelectAction,
|
||||
onDeleteAction,
|
||||
onReorderAction,
|
||||
dragHandle,
|
||||
isFirst,
|
||||
isLast,
|
||||
}: ActionChipProps) {
|
||||
const def = actionRegistry.getAction(action.type);
|
||||
const isSelected = selectedActionId === action.id;
|
||||
|
||||
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
|
||||
@@ -388,35 +670,44 @@ function SortableActionChip({
|
||||
/* ------------------------------------------------------------------------ */
|
||||
const isPlaceholder = action.id === "projection-placeholder";
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging: isSortableDragging,
|
||||
} = useSortable({
|
||||
id: sortableActionId(action.id),
|
||||
disabled: isPlaceholder, // Disable sortable for placeholder
|
||||
data: {
|
||||
type: "action",
|
||||
stepId,
|
||||
parentId,
|
||||
id: action.id,
|
||||
},
|
||||
});
|
||||
// Compute validation status
|
||||
const issues = useDesignerStore((s) => s.validationIssues[action.id]);
|
||||
const validationStatus = useMemo(() => {
|
||||
if (!issues?.length) return undefined;
|
||||
if (issues.some((i) => i.severity === "error")) return "error";
|
||||
if (issues.some((i) => i.severity === "warning")) return "warning";
|
||||
return "info";
|
||||
}, [issues]);
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Sortable (Local) DnD Monitoring */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
// useSortable disabled per user request to remove action drag-and-drop
|
||||
// const { ... } = useSortable(...)
|
||||
|
||||
// Use local dragging state or passed prop
|
||||
const isDragging = isSortableDragging || dragHandle;
|
||||
const isDragging = dragHandle || false;
|
||||
|
||||
const style = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
// transform: CSS.Translate.toString(transform),
|
||||
// transition,
|
||||
};
|
||||
|
||||
// We need a ref for droppable? Droppable is below.
|
||||
// For the chip itself, if not sortable, we don't need setNodeRef.
|
||||
// But we might need it for layout?
|
||||
// Let's keep a simple div ref usage if needed, but useSortable provided setNodeRef.
|
||||
// We can just use a normal ref or nothing if not measuring.
|
||||
const setNodeRef = undefined; // No-op
|
||||
const attributes = {};
|
||||
const listeners = {};
|
||||
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Nested Droppable (for control flow containers) */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
const def = actionRegistry.getAction(action.type);
|
||||
const nestedDroppableId = `container-${action.id}`;
|
||||
const {
|
||||
isOver: isOverNested,
|
||||
@@ -472,80 +763,26 @@ function SortableActionChip({
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"group relative flex w-full flex-col items-start gap-1 rounded border px-3 py-2 text-[11px]",
|
||||
"bg-muted/40 hover:bg-accent/40 cursor-pointer",
|
||||
isSelected && "border-border bg-accent/30",
|
||||
isDragging && "opacity-70 shadow-lg",
|
||||
// Visual feedback for nested drop
|
||||
isOverNested && !isDragging && "ring-2 ring-blue-400 ring-offset-1 bg-blue-50/50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
{...attributes}
|
||||
>
|
||||
<ActionChipVisuals
|
||||
action={action}
|
||||
isSelected={isSelected}
|
||||
isDragging={isDragging}
|
||||
isOverNested={isOverNested}
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectAction(stepId, action.id);
|
||||
}}
|
||||
{...attributes}
|
||||
role="button"
|
||||
aria-pressed={isSelected}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div
|
||||
{...listeners}
|
||||
className="text-muted-foreground/70 hover:text-foreground cursor-grab rounded p-0.5"
|
||||
aria-label="Drag action"
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 rounded-full",
|
||||
def
|
||||
? {
|
||||
wizard: "bg-blue-500",
|
||||
robot: "bg-emerald-500",
|
||||
control: "bg-amber-500",
|
||||
observation: "bg-purple-500",
|
||||
}[def.category]
|
||||
: "bg-slate-400",
|
||||
)}
|
||||
/>
|
||||
<span className="flex-1 leading-snug font-medium break-words">
|
||||
{action.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
onDelete={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteAction(stepId, action.id);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
aria-label="Delete action"
|
||||
onReorder={(direction) => onReorderAction?.(stepId, action.id, direction)}
|
||||
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 */}
|
||||
{shouldRenderChildren && (
|
||||
<div
|
||||
@@ -569,6 +806,7 @@ function SortableActionChip({
|
||||
selectedActionId={selectedActionId}
|
||||
onSelectAction={onSelectAction}
|
||||
onDeleteAction={onDeleteAction}
|
||||
onReorderAction={onReorderAction}
|
||||
/>
|
||||
))}
|
||||
{(!displayChildren?.length && !action.children?.length) && (
|
||||
@@ -579,7 +817,7 @@ function SortableActionChip({
|
||||
</SortableContext>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</ActionChipVisuals>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -633,6 +871,21 @@ export function FlowWorkspace({
|
||||
return map;
|
||||
}, [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 */
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
@@ -796,6 +1049,52 @@ export function FlowWorkspace({
|
||||
[removeAction, selectedActionId, selectAction, recomputeHash],
|
||||
);
|
||||
|
||||
const handleReorderStep = useCallback(
|
||||
(stepId: string, direction: 'up' | 'down') => {
|
||||
console.log('handleReorderStep', stepId, direction);
|
||||
const currentIndex = steps.findIndex((s) => s.id === stepId);
|
||||
console.log('currentIndex', currentIndex, 'total', steps.length);
|
||||
if (currentIndex === -1) return;
|
||||
const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||
console.log('newIndex', newIndex);
|
||||
if (newIndex < 0 || newIndex >= steps.length) return;
|
||||
reorderStep(currentIndex, newIndex);
|
||||
},
|
||||
[steps, reorderStep]
|
||||
);
|
||||
|
||||
const handleReorderAction = useCallback(
|
||||
(stepId: string, actionId: string, direction: 'up' | 'down') => {
|
||||
const step = steps.find(s => s.id === stepId);
|
||||
if (!step) return;
|
||||
|
||||
const findInTree = (list: ExperimentAction[], pId: string | null): { list: ExperimentAction[], parentId: string | null, index: number } | null => {
|
||||
const idx = list.findIndex(a => a.id === actionId);
|
||||
if (idx !== -1) return { list, parentId: pId, index: idx };
|
||||
|
||||
for (const a of list) {
|
||||
if (a.children) {
|
||||
const res = findInTree(a.children, a.id);
|
||||
if (res) return res;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const context = findInTree(step.actions, null);
|
||||
if (!context) return;
|
||||
|
||||
const { parentId, index, list } = context;
|
||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
|
||||
if (newIndex < 0 || newIndex >= list.length) return;
|
||||
|
||||
moveAction(stepId, actionId, parentId, newIndex);
|
||||
},
|
||||
[steps, moveAction]
|
||||
);
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Sortable (Local) DnD Monitoring */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
@@ -815,19 +1114,9 @@ export function FlowWorkspace({
|
||||
}
|
||||
const activeId = active.id.toString();
|
||||
const overId = over.id.toString();
|
||||
// Step reorder
|
||||
if (activeId.startsWith("s-step-") && overId.startsWith("s-step-")) {
|
||||
const fromStepId = parseSortableStep(activeId);
|
||||
const toStepId = parseSortableStep(overId);
|
||||
if (fromStepId && toStepId && fromStepId !== toStepId) {
|
||||
const fromIndex = steps.findIndex((s) => s.id === fromStepId);
|
||||
const toIndex = steps.findIndex((s) => s.id === toStepId);
|
||||
if (fromIndex >= 0 && toIndex >= 0) {
|
||||
reorderStep(fromIndex, toIndex);
|
||||
void recomputeHash();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step reorder is now handled globally in DesignerRoot
|
||||
|
||||
// Action reorder (supports nesting)
|
||||
if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) {
|
||||
const activeData = active.data.current;
|
||||
@@ -839,8 +1128,9 @@ export function FlowWorkspace({
|
||||
activeData.type === 'action' && overData.type === 'action'
|
||||
) {
|
||||
const stepId = activeData.stepId as string;
|
||||
const activeActionId = activeData.action.id;
|
||||
const overActionId = overData.action.id;
|
||||
// Fix: SortableActionChip puts 'id' directly on data, not inside 'action' property
|
||||
const activeActionId = activeData.id;
|
||||
const overActionId = overData.id;
|
||||
|
||||
if (activeActionId !== overActionId) {
|
||||
const newParentId = overData.parentId as string | null;
|
||||
@@ -877,8 +1167,10 @@ export function FlowWorkspace({
|
||||
activeData.type === 'action' &&
|
||||
overData.type === 'action'
|
||||
) {
|
||||
const activeActionId = activeData.action.id;
|
||||
const overActionId = overData.action.id;
|
||||
// Fix: Access 'id' directly from data payload
|
||||
const activeActionId = activeData.id;
|
||||
const overActionId = overData.id;
|
||||
|
||||
const activeStepId = activeData.stepId;
|
||||
const overStepId = overData.stepId;
|
||||
const activeParentId = activeData.parentId;
|
||||
@@ -956,7 +1248,8 @@ export function FlowWorkspace({
|
||||
<div
|
||||
ref={containerRef}
|
||||
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}
|
||||
>
|
||||
{steps.length === 0 ? (
|
||||
@@ -990,6 +1283,8 @@ export function FlowWorkspace({
|
||||
<StepRow
|
||||
key={vi.key}
|
||||
item={vi}
|
||||
step={vi.step}
|
||||
totalSteps={steps.length}
|
||||
selectedStepId={selectedStepId}
|
||||
selectedActionId={selectedActionId}
|
||||
renamingStepId={renamingStepId}
|
||||
@@ -1004,6 +1299,9 @@ export function FlowWorkspace({
|
||||
onDeleteAction={deleteAction}
|
||||
setRenamingStepId={setRenamingStepId}
|
||||
registerMeasureRef={registerMeasureRef}
|
||||
onReorderStep={handleReorderStep}
|
||||
onReorderAction={handleReorderAction}
|
||||
isChild={childStepIds.has(vi.step.id)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -5,14 +5,11 @@ import {
|
||||
Save,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Hash,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
UploadCloud,
|
||||
Wand2,
|
||||
Sparkles,
|
||||
Hash,
|
||||
GitBranch,
|
||||
Keyboard,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -20,21 +17,6 @@ import { Separator } from "~/components/ui/separator";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useDesignerStore } from "../state/store";
|
||||
|
||||
/**
|
||||
* BottomStatusBar
|
||||
*
|
||||
* Compact, persistent status + quick-action bar for the Experiment Designer.
|
||||
* Shows:
|
||||
* - Validation / drift / unsaved state
|
||||
* - Short design hash & version
|
||||
* - Aggregate counts (steps / actions)
|
||||
* - Last persisted hash (if available)
|
||||
* - Quick actions (Save, Validate, Export, Command Palette)
|
||||
*
|
||||
* The bar is intentionally UI-only: callback props are used so that higher-level
|
||||
* orchestration (e.g. DesignerRoot / Shell) controls actual side effects.
|
||||
*/
|
||||
|
||||
export interface BottomStatusBarProps {
|
||||
onSave?: () => void;
|
||||
onValidate?: () => void;
|
||||
@@ -45,9 +27,6 @@ export interface BottomStatusBarProps {
|
||||
saving?: boolean;
|
||||
validating?: boolean;
|
||||
exporting?: boolean;
|
||||
/**
|
||||
* Optional externally supplied last saved Date for relative display.
|
||||
*/
|
||||
lastSavedAt?: Date;
|
||||
}
|
||||
|
||||
@@ -55,24 +34,16 @@ export function BottomStatusBar({
|
||||
onSave,
|
||||
onValidate,
|
||||
onExport,
|
||||
onOpenCommandPalette,
|
||||
onRecalculateHash,
|
||||
className,
|
||||
saving,
|
||||
validating,
|
||||
exporting,
|
||||
lastSavedAt,
|
||||
}: BottomStatusBarProps) {
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Store Selectors */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
const steps = useDesignerStore((s) => s.steps);
|
||||
const lastPersistedHash = useDesignerStore((s) => s.lastPersistedHash);
|
||||
const currentDesignHash = useDesignerStore((s) => s.currentDesignHash);
|
||||
const lastValidatedHash = useDesignerStore((s) => s.lastValidatedHash);
|
||||
const pendingSave = useDesignerStore((s) => s.pendingSave);
|
||||
const versionStrategy = useDesignerStore((s) => s.versionStrategy);
|
||||
const autoSaveEnabled = useDesignerStore((s) => s.autoSaveEnabled);
|
||||
|
||||
const actionCount = useMemo(
|
||||
() => steps.reduce((sum, st) => sum + st.actions.length, 0),
|
||||
@@ -93,64 +64,28 @@ export function BottomStatusBar({
|
||||
return "valid";
|
||||
}, [currentDesignHash, lastValidatedHash]);
|
||||
|
||||
const shortHash = useMemo(
|
||||
() => (currentDesignHash ? currentDesignHash.slice(0, 8) : "—"),
|
||||
[currentDesignHash],
|
||||
);
|
||||
|
||||
const lastPersistedShort = useMemo(
|
||||
() => (lastPersistedHash ? lastPersistedHash.slice(0, 8) : null),
|
||||
[lastPersistedHash],
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Derived Display Helpers */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
function formatRelative(date?: Date): string {
|
||||
if (!date) return "—";
|
||||
const now = Date.now();
|
||||
const diffMs = now - date.getTime();
|
||||
if (diffMs < 30_000) return "just now";
|
||||
const mins = Math.floor(diffMs / 60_000);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
const relSaved = formatRelative(lastSavedAt);
|
||||
|
||||
const validationBadge = (() => {
|
||||
switch (validationStatus) {
|
||||
case "valid":
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-green-400 text-green-600 dark:text-green-400"
|
||||
title="Validated (hash stable)"
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
<span className="hidden sm:inline">Validated</span>
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1.5 text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Valid</span>
|
||||
</div>
|
||||
);
|
||||
case "drift":
|
||||
return (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="border-amber-400 bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400"
|
||||
title="Drift since last validation"
|
||||
>
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
<span className="hidden sm:inline">Drift</span>
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Modified</span>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge variant="outline" title="Not validated yet">
|
||||
<Hash className="mr-1 h-3 w-3" />
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Hash className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Unvalidated</span>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})();
|
||||
@@ -159,190 +94,63 @@ export function BottomStatusBar({
|
||||
hasUnsaved && !pendingSave ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-orange-300 text-orange-600 dark:text-orange-400"
|
||||
title="Unsaved changes"
|
||||
className="h-5 gap-1 border-orange-300 px-1.5 text-[10px] font-normal text-orange-600 dark:text-orange-400"
|
||||
>
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
<span className="hidden sm:inline">Unsaved</span>
|
||||
Unsaved
|
||||
</Badge>
|
||||
) : null;
|
||||
|
||||
const savingIndicator =
|
||||
pendingSave || saving ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="animate-pulse"
|
||||
title="Saving changes"
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3 w-3 animate-spin" />
|
||||
Saving…
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground animate-pulse">
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
<span>Saving...</span>
|
||||
</div>
|
||||
) : 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 (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 supports-[backdrop-filter]:bg-muted/30 backdrop-blur",
|
||||
"flex h-10 w-full flex-shrink-0 items-center gap-3 border-t px-3 text-xs",
|
||||
"font-medium",
|
||||
"flex h-9 w-full flex-shrink-0 items-center gap-4 border-t px-3 text-xs font-medium",
|
||||
className,
|
||||
)}
|
||||
aria-label="Designer status bar"
|
||||
>
|
||||
{/* Left Cluster: Validation & Hash */}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{/* Status Indicators */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{validationBadge}
|
||||
{unsavedBadge}
|
||||
{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>
|
||||
|
||||
{/* Middle Cluster: Aggregate Counts */}
|
||||
<div className="text-muted-foreground flex min-w-0 items-center gap-3 truncate">
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
title="Steps in current design"
|
||||
>
|
||||
<GitBranch className="h-3 w-3" />
|
||||
<Separator orientation="vertical" className="h-4 opacity-50" />
|
||||
|
||||
{/* Stats */}
|
||||
<div className="text-muted-foreground flex items-center gap-3 truncate">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<GitBranch className="h-3.5 w-3.5 opacity-70" />
|
||||
{steps.length}
|
||||
<span className="hidden sm:inline"> steps</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
title="Total actions across all steps"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 opacity-70" />
|
||||
{actionCount}
|
||||
<span className="hidden sm:inline"> actions</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>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Flexible Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Right Cluster: Quick Actions */}
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
disabled={!hasUnsaved && !pendingSave}
|
||||
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}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={onExport}
|
||||
disabled={exporting}
|
||||
aria-label="Export (e)"
|
||||
title="Export (e)"
|
||||
title="Export JSON"
|
||||
>
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
<span className="hidden sm:inline">Export</span>
|
||||
</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>
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import * as React from "react";
|
||||
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";
|
||||
|
||||
export interface PanelsContainerProps {
|
||||
@@ -36,6 +38,14 @@ export interface PanelsContainerProps {
|
||||
|
||||
/** Keyboard resize step (fractional) per arrow press; Shift increases by 2x */
|
||||
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:
|
||||
* - Drag-resizable left/right panels (no persistence)
|
||||
* - Collapsible side panels
|
||||
* - Strict overflow containment (no page-level x-scroll)
|
||||
* - Internal y-scroll for each panel
|
||||
* - Optional visual dividers on the center panel only (prevents double borders)
|
||||
@@ -64,7 +75,7 @@ const Panel: React.FC<React.PropsWithChildren<{
|
||||
children,
|
||||
}) => (
|
||||
<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
|
||||
className={cn(
|
||||
@@ -91,6 +102,10 @@ export function PanelsContainer({
|
||||
minRightPct = 0.12,
|
||||
maxRightPct = 0.33,
|
||||
keyboardStepPct = 0.02,
|
||||
leftCollapsed = false,
|
||||
rightCollapsed = false,
|
||||
onLeftCollapseChange,
|
||||
onRightCollapseChange,
|
||||
}: PanelsContainerProps) {
|
||||
const hasLeft = Boolean(left);
|
||||
const hasRight = Boolean(right);
|
||||
@@ -116,20 +131,39 @@ export function PanelsContainer({
|
||||
(lp: number, rp: number) => {
|
||||
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) {
|
||||
const l = clamp(lp, minLeftPct, maxLeftPct);
|
||||
const r = clamp(rp, minRightPct, maxRightPct);
|
||||
const c = Math.max(0.1, 1 - (l + r)); // always preserve some center space
|
||||
// Standard clamp (on the state values)
|
||||
const lState = clamp(lp, minLeftPct, maxLeftPct);
|
||||
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 };
|
||||
}
|
||||
if (hasLeft && !hasRight) {
|
||||
const l = clamp(lp, minLeftPct, maxLeftPct);
|
||||
const c = Math.max(0.2, 1 - l);
|
||||
const lState = clamp(lp, minLeftPct, maxLeftPct);
|
||||
const l = leftCollapsed ? 0 : lState;
|
||||
const c = 1 - l;
|
||||
return { l, c, r: 0 };
|
||||
}
|
||||
if (!hasLeft && hasRight) {
|
||||
const r = clamp(rp, minRightPct, maxRightPct);
|
||||
const c = Math.max(0.2, 1 - r);
|
||||
const rState = clamp(rp, minRightPct, maxRightPct);
|
||||
const r = rightCollapsed ? 0 : rState;
|
||||
const c = 1 - r;
|
||||
return { l: 0, c, r };
|
||||
}
|
||||
// Center only
|
||||
@@ -143,6 +177,8 @@ export function PanelsContainer({
|
||||
maxLeftPct,
|
||||
minRightPct,
|
||||
maxRightPct,
|
||||
leftCollapsed,
|
||||
rightCollapsed
|
||||
],
|
||||
);
|
||||
|
||||
@@ -157,10 +193,10 @@ export function PanelsContainer({
|
||||
const deltaPx = e.clientX - d.startX;
|
||||
const deltaPct = deltaPx / d.containerWidth;
|
||||
|
||||
if (d.edge === "left" && hasLeft) {
|
||||
if (d.edge === "left" && hasLeft && !leftCollapsed) {
|
||||
const nextLeft = clamp(d.startLeft + deltaPct, minLeftPct, maxLeftPct);
|
||||
setLeftPct(nextLeft);
|
||||
} else if (d.edge === "right" && hasRight) {
|
||||
} else if (d.edge === "right" && hasRight && !rightCollapsed) {
|
||||
// Dragging the right edge moves leftwards as delta increases
|
||||
const nextRight = clamp(
|
||||
d.startRight - deltaPct,
|
||||
@@ -170,7 +206,7 @@ export function PanelsContainer({
|
||||
setRightPct(nextRight);
|
||||
}
|
||||
},
|
||||
[hasLeft, hasRight, minLeftPct, maxLeftPct, minRightPct, maxRightPct],
|
||||
[hasLeft, hasRight, minLeftPct, maxLeftPct, minRightPct, maxRightPct, leftCollapsed, rightCollapsed],
|
||||
);
|
||||
|
||||
const endDrag = React.useCallback(() => {
|
||||
@@ -213,14 +249,14 @@ export function PanelsContainer({
|
||||
|
||||
const step = (e.shiftKey ? 2 : 1) * keyboardStepPct;
|
||||
|
||||
if (edge === "left" && hasLeft) {
|
||||
if (edge === "left" && hasLeft && !leftCollapsed) {
|
||||
const next = clamp(
|
||||
leftPct + (e.key === "ArrowRight" ? step : -step),
|
||||
minLeftPct,
|
||||
maxLeftPct,
|
||||
);
|
||||
setLeftPct(next);
|
||||
} else if (edge === "right" && hasRight) {
|
||||
} else if (edge === "right" && hasRight && !rightCollapsed) {
|
||||
const next = clamp(
|
||||
rightPct + (e.key === "ArrowLeft" ? step : -step),
|
||||
minRightPct,
|
||||
@@ -231,23 +267,33 @@ export function PanelsContainer({
|
||||
};
|
||||
|
||||
// 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
|
||||
? {
|
||||
"--col-left": `${(hasLeft ? l : 0) * 100}%`,
|
||||
"--col-center": `${c * 100}%`,
|
||||
"--col-right": `${(hasRight ? r : 0) * 100}%`,
|
||||
"--col-left": `${hasLeft ? l : 0}fr`,
|
||||
"--col-center": `${c}fr`,
|
||||
"--col-right": `${hasRight ? r : 0}fr`,
|
||||
}
|
||||
: {};
|
||||
|
||||
// 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 =
|
||||
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
|
||||
? "[grid-template-columns:minmax(0,var(--col-left))_minmax(0,var(--col-center))]"
|
||||
? "[grid-template-columns:var(--col-left)_var(--col-center)]"
|
||||
: !hasLeft && hasRight
|
||||
? "[grid-template-columns:minmax(0,var(--col-center))_minmax(0,var(--col-right))]"
|
||||
: "[grid-template-columns:minmax(0,1fr)]";
|
||||
? "[grid-template-columns:var(--col-center)_var(--col-right)]"
|
||||
: "[grid-template-columns:1fr]";
|
||||
|
||||
// Dividers on the center panel only (prevents double borders if children have their own borders)
|
||||
const centerDividers =
|
||||
@@ -261,17 +307,77 @@ export function PanelsContainer({
|
||||
|
||||
|
||||
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
|
||||
ref={rootRef}
|
||||
aria-label={ariaLabel}
|
||||
style={styleVars}
|
||||
className={cn(
|
||||
"relative grid h-full min-h-0 w-full overflow-hidden select-none",
|
||||
gridCols,
|
||||
"relative hidden md:grid h-full min-h-0 w-full max-w-full overflow-hidden select-none",
|
||||
// 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,
|
||||
)}
|
||||
>
|
||||
{hasLeft && (
|
||||
{hasLeft && !leftCollapsed && (
|
||||
<Panel
|
||||
panelClassName={panelClassName}
|
||||
contentClassName={contentClassName}
|
||||
@@ -290,7 +396,7 @@ export function PanelsContainer({
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{hasRight && (
|
||||
{hasRight && !rightCollapsed && (
|
||||
<Panel
|
||||
panelClassName={panelClassName}
|
||||
contentClassName={contentClassName}
|
||||
@@ -299,43 +405,29 @@ export function PanelsContainer({
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/* Resize handles (only render where applicable) */}
|
||||
{hasCenter && hasLeft && (
|
||||
{/* Resize Handles */}
|
||||
{hasLeft && !leftCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
role="separator"
|
||||
aria-label="Resize left panel"
|
||||
aria-orientation="vertical"
|
||||
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"
|
||||
style={{ left: "var(--col-left)" }}
|
||||
onPointerDown={startDrag("left")}
|
||||
onKeyDown={onKeyResize("left")}
|
||||
className={cn(
|
||||
"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}
|
||||
aria-label="Resize left panel"
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasCenter && hasRight && (
|
||||
{hasRight && !rightCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
role="separator"
|
||||
aria-label="Resize right panel"
|
||||
aria-orientation="vertical"
|
||||
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"
|
||||
style={{ right: "var(--col-right)" }}
|
||||
onPointerDown={startDrag("right")}
|
||||
onKeyDown={onKeyResize("right")}
|
||||
className={cn(
|
||||
"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}
|
||||
aria-label="Resize right panel"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Eye,
|
||||
X,
|
||||
Layers,
|
||||
PanelLeftClose,
|
||||
} from "lucide-react";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Button } from "~/components/ui/button";
|
||||
@@ -108,7 +109,7 @@ function DraggableAction({
|
||||
{...listeners}
|
||||
style={style}
|
||||
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]",
|
||||
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 [search, setSearch] = useState("");
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useMemo, useState, useCallback } from "react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useDesignerStore } from "../state/store";
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
AlertTriangle,
|
||||
GitBranch,
|
||||
PackageSearch,
|
||||
PanelRightClose,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
@@ -47,6 +49,11 @@ export interface InspectorPanelProps {
|
||||
* Called when user changes tab (only if activeTab not externally controlled).
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@@ -60,6 +67,10 @@ export interface InspectorPanelProps {
|
||||
name: string;
|
||||
version: string;
|
||||
}>;
|
||||
/**
|
||||
* Called to clear all validation issues.
|
||||
*/
|
||||
onClearAll?: () => void;
|
||||
}
|
||||
|
||||
export function InspectorPanel({
|
||||
@@ -68,6 +79,9 @@ export function InspectorPanel({
|
||||
onTabChange,
|
||||
autoFocusOnSelection = true,
|
||||
studyPlugins,
|
||||
collapsed,
|
||||
onCollapse,
|
||||
onClearAll,
|
||||
}: InspectorPanelProps) {
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Store Selectors */
|
||||
@@ -314,6 +328,7 @@ export function InspectorPanel({
|
||||
>
|
||||
<ValidationPanel
|
||||
issues={validationIssues}
|
||||
onClearAll={onClearAll}
|
||||
entityLabelForId={(entityId) => {
|
||||
if (entityId.startsWith("action-")) {
|
||||
for (const s of steps) {
|
||||
|
||||
@@ -203,8 +203,7 @@ function projectStepForDesign(
|
||||
order: step.order,
|
||||
trigger: {
|
||||
type: step.trigger.type,
|
||||
// Only the sorted keys of conditions (structural presence)
|
||||
conditionKeys: Object.keys(step.trigger.conditions).sort(),
|
||||
conditions: canonicalize(step.trigger.conditions),
|
||||
},
|
||||
actions: step.actions.map((a) => projectActionForDesign(a, options)),
|
||||
};
|
||||
@@ -267,11 +266,35 @@ export async function computeDesignHash(
|
||||
opts: DesignHashOptions = {},
|
||||
): Promise<string> {
|
||||
const options = { ...DEFAULT_OPTIONS, ...opts };
|
||||
const projected = steps
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((s) => projectStepForDesign(s, options));
|
||||
return hashObject({ steps: projected });
|
||||
|
||||
// 1. Sort steps first to ensure order independence of input array
|
||||
const sortedSteps = steps.slice().sort((a, b) => a.order - b.order);
|
||||
|
||||
// 2. Map hierarchically (Merkle style)
|
||||
const stepHashes = await Promise.all(sortedSteps.map(async (s) => {
|
||||
// Action hashes
|
||||
const actionHashes = await Promise.all(s.actions.map(a => hashObject(projectActionForDesign(a, options))));
|
||||
|
||||
// Step hash
|
||||
const pStep = {
|
||||
id: s.id,
|
||||
type: s.type,
|
||||
order: s.order,
|
||||
trigger: {
|
||||
type: s.trigger.type,
|
||||
conditions: canonicalize(s.trigger.conditions),
|
||||
},
|
||||
actions: actionHashes,
|
||||
...(options.includeStepNames ? { name: s.name } : {}),
|
||||
};
|
||||
return hashObject(pStep);
|
||||
}));
|
||||
|
||||
// 3. Aggregate design hash
|
||||
return hashObject({
|
||||
steps: stepHashes,
|
||||
count: steps.length
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -338,7 +361,7 @@ export async function computeIncrementalDesignHash(
|
||||
order: step.order,
|
||||
trigger: {
|
||||
type: step.trigger.type,
|
||||
conditionKeys: Object.keys(step.trigger.conditions).sort(),
|
||||
conditions: canonicalize(step.trigger.conditions),
|
||||
},
|
||||
actions: step.actions.map((a) => actionHashes.get(a.id) ?? ""),
|
||||
...(options.includeStepNames ? { name: step.name } : {}),
|
||||
|
||||
@@ -167,8 +167,6 @@ function cloneSteps(steps: ExperimentStep[]): ExperimentStep[] {
|
||||
|
||||
function reindexSteps(steps: ExperimentStep[]): ExperimentStep[] {
|
||||
return steps
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((s, idx) => ({ ...s, order: idx }));
|
||||
}
|
||||
|
||||
|
||||
@@ -49,11 +49,11 @@ export interface ValidationResult {
|
||||
/* Validation Rule Sets */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
// Steps should ALWAYS execute sequentially
|
||||
// Parallel/conditional/loop execution happens at the ACTION level, not step level
|
||||
const VALID_STEP_TYPES: StepType[] = [
|
||||
"sequential",
|
||||
"parallel",
|
||||
"conditional",
|
||||
"loop",
|
||||
];
|
||||
const VALID_TRIGGER_TYPES: TriggerType[] = [
|
||||
"trial_start",
|
||||
@@ -144,48 +144,8 @@ export function validateStructural(
|
||||
});
|
||||
}
|
||||
|
||||
// Conditional step must have conditions
|
||||
if (step.type === "conditional") {
|
||||
const conditionKeys = Object.keys(step.trigger.conditions || {});
|
||||
if (conditionKeys.length === 0) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Conditional step must define at least one condition",
|
||||
category: "structural",
|
||||
field: "trigger.conditions",
|
||||
stepId,
|
||||
suggestion: "Add conditions to define when this step should execute",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Loop step should have termination conditions
|
||||
if (step.type === "loop") {
|
||||
const conditionKeys = Object.keys(step.trigger.conditions || {});
|
||||
if (conditionKeys.length === 0) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message:
|
||||
"Loop step should define termination conditions to prevent infinite loops",
|
||||
category: "structural",
|
||||
field: "trigger.conditions",
|
||||
stepId,
|
||||
suggestion: "Add conditions to control when the loop should exit",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parallel step should have multiple actions
|
||||
if (step.type === "parallel" && step.actions.length < 2) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message:
|
||||
"Parallel step has fewer than 2 actions - consider using sequential type",
|
||||
category: "structural",
|
||||
stepId,
|
||||
suggestion: "Add more actions or change to sequential execution",
|
||||
});
|
||||
}
|
||||
// All steps must be sequential type (parallel/conditional/loop removed)
|
||||
// Control flow and parallelism should be implemented at the ACTION level
|
||||
|
||||
// Action-level structural validation
|
||||
step.actions.forEach((action) => {
|
||||
@@ -234,6 +194,7 @@ export function validateStructural(
|
||||
}
|
||||
|
||||
// Plugin actions need plugin metadata
|
||||
/* VALIDATION DISABLED BY USER REQUEST
|
||||
if (action.source?.kind === "plugin") {
|
||||
if (!action.source.pluginId) {
|
||||
issues.push({
|
||||
@@ -258,6 +219,7 @@ export function validateStructural(
|
||||
});
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// Execution descriptor validation
|
||||
if (!action.execution?.transport) {
|
||||
@@ -430,6 +392,34 @@ export function validateParameters(
|
||||
}
|
||||
break;
|
||||
|
||||
case "array":
|
||||
if (!Array.isArray(value)) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Parameter '${paramDef.name}' must be a list/array`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Enter a list of values",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "json":
|
||||
if (typeof value !== "object" || value === null) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Parameter '${paramDef.name}' must be a valid object`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Enter a valid JSON object",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown parameter type
|
||||
issues.push({
|
||||
@@ -532,10 +522,9 @@ export function validateSemantic(
|
||||
// Check for empty steps
|
||||
steps.forEach((step) => {
|
||||
if (step.actions.length === 0) {
|
||||
const severity = step.type === "parallel" ? "error" : "warning";
|
||||
issues.push({
|
||||
severity,
|
||||
message: `${step.type} step has no actions`,
|
||||
severity: "warning",
|
||||
message: "Step has no actions",
|
||||
category: "semantic",
|
||||
stepId: step.id,
|
||||
suggestion: "Add actions to this step or remove it",
|
||||
@@ -635,25 +624,9 @@ export function validateExecution(
|
||||
): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
// Check for unreachable steps (basic heuristic)
|
||||
if (steps.length > 1) {
|
||||
const trialStartSteps = steps.filter(
|
||||
(s) => s.trigger.type === "trial_start",
|
||||
);
|
||||
if (trialStartSteps.length > 1) {
|
||||
trialStartSteps.slice(1).forEach((step) => {
|
||||
issues.push({
|
||||
severity: "info",
|
||||
message:
|
||||
"This step will start immediately at trial start. For sequential flow, use 'Previous Step' trigger.",
|
||||
category: "execution",
|
||||
field: "trigger.type",
|
||||
stepId: step.id,
|
||||
suggestion: "Change trigger to 'Previous Step' if this step should follow the previous one",
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
// Note: Trigger validation removed - convertDatabaseToSteps() automatically assigns
|
||||
// correct triggers (trial_start for first step, previous_step for others) based on orderIndex.
|
||||
// Manual trigger configuration is intentional for advanced workflows.
|
||||
|
||||
// Check for missing robot dependencies
|
||||
const robotActions = steps.flatMap((step) =>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export type Experiment = {
|
||||
id: string;
|
||||
@@ -78,27 +79,23 @@ const statusConfig = {
|
||||
};
|
||||
|
||||
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 (
|
||||
window.confirm(`Are you sure you want to delete "${experiment.name}"?`)
|
||||
) {
|
||||
try {
|
||||
// TODO: Implement delete experiment mutation
|
||||
toast.success("Experiment deleted successfully");
|
||||
} catch {
|
||||
toast.error("Failed to delete experiment");
|
||||
deleteMutation.mutate({ id: experiment.id });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
@@ -111,45 +108,20 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Metadata
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
|
||||
<FlaskConical className="mr-2 h-4 w-4" />
|
||||
Open Designer
|
||||
Design
|
||||
</Link>
|
||||
</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 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -158,7 +130,7 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Experiment
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
@@ -315,20 +287,7 @@ export const experimentsColumns: ColumnDef<Experiment>[] = [
|
||||
},
|
||||
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",
|
||||
header: ({ column }) => (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useTheme } from "next-themes";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
type TourType = "dashboard" | "study_creation" | "designer" | "wizard" | "full_platform";
|
||||
type TourType = "dashboard" | "study_creation" | "participant_creation" | "designer" | "wizard" | "analytics" | "full_platform";
|
||||
|
||||
interface TourContextType {
|
||||
startTour: (tour: TourType) => void;
|
||||
@@ -46,6 +46,8 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
||||
runTourSegment("dashboard");
|
||||
} else if (pathname.includes("/studies/new")) {
|
||||
runTourSegment("study_creation");
|
||||
} else if (pathname.includes("/participants/new")) {
|
||||
runTourSegment("participant_creation");
|
||||
} else if (pathname.includes("/designer")) {
|
||||
runTourSegment("designer");
|
||||
} else if (pathname.includes("/wizard")) {
|
||||
@@ -56,7 +58,20 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
const runTourSegment = (segment: "dashboard" | "study_creation" | "designer" | "wizard") => {
|
||||
useEffect(() => {
|
||||
// Listen for custom tour triggers (from components without context access)
|
||||
const handleTourTrigger = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail as TourType;
|
||||
if (detail) {
|
||||
startTour(detail);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('hristudio-start-tour', handleTourTrigger);
|
||||
return () => document.removeEventListener('hristudio-start-tour', handleTourTrigger);
|
||||
}, []);
|
||||
|
||||
const runTourSegment = (segment: "dashboard" | "study_creation" | "participant_creation" | "designer" | "wizard" | "analytics") => {
|
||||
const isDark = theme === "dark";
|
||||
// We add a specific class to handle dark/light overrides reliably
|
||||
const themeClass = isDark ? "driverjs-theme-dark" : "driverjs-theme-light";
|
||||
@@ -134,6 +149,49 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}
|
||||
];
|
||||
} else if (segment === "participant_creation") {
|
||||
steps = [
|
||||
{
|
||||
element: "#tour-participant-code",
|
||||
popover: {
|
||||
title: "Participant ID",
|
||||
description: "Assign a unique code (e.g., P001) to identify this participant while maintaining anonymity.",
|
||||
side: "right",
|
||||
}
|
||||
},
|
||||
{
|
||||
element: "#tour-participant-name",
|
||||
popover: {
|
||||
title: "Name (Optional)",
|
||||
description: "You store their name for internal reference; analytics will use the ID.",
|
||||
side: "right",
|
||||
}
|
||||
},
|
||||
{
|
||||
element: "#tour-participant-study-container",
|
||||
popover: {
|
||||
title: "Study Association",
|
||||
description: "Link this participant to a specific research study to enable data collection.",
|
||||
side: "right",
|
||||
}
|
||||
},
|
||||
{
|
||||
element: "#tour-participant-consent",
|
||||
popover: {
|
||||
title: "Informed Consent",
|
||||
description: "Mandatory check to confirm you have obtained necessary ethical approvals and consent.",
|
||||
side: "top",
|
||||
}
|
||||
},
|
||||
{
|
||||
element: "#tour-participant-submit",
|
||||
popover: {
|
||||
title: "Register",
|
||||
description: "Create the participant record to begin scheduling trials.",
|
||||
side: "top",
|
||||
}
|
||||
}
|
||||
];
|
||||
} else if (segment === "designer") {
|
||||
steps = [
|
||||
{
|
||||
@@ -189,6 +247,50 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
||||
},
|
||||
];
|
||||
}
|
||||
else if (segment === "analytics") {
|
||||
steps = [
|
||||
{
|
||||
element: "#tour-analytics-table",
|
||||
popover: {
|
||||
title: "Study Analytics",
|
||||
description: "View aggregate data across all participant sessions. Sort and filter to identify trends or specific trials.",
|
||||
side: "bottom",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-analytics-filter",
|
||||
popover: {
|
||||
title: "Filter Data",
|
||||
description: "Quickly find participants by ID or name using this search bar.",
|
||||
side: "bottom",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-trial-metrics",
|
||||
popover: {
|
||||
title: "Trial Metrics",
|
||||
description: "High-level KPIs for the selected trial: Duration, Robot Actions, and Intervention counts.",
|
||||
side: "bottom",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-trial-timeline",
|
||||
popover: {
|
||||
title: "Video & Timeline",
|
||||
description: "Watch the trial recording synced with the event timeline. Click any event to jump to that moment in the video.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-trial-events",
|
||||
popover: {
|
||||
title: "Event Log",
|
||||
description: "A detailed, searchable log of every system event, robot action, and wizard interaction.",
|
||||
side: "left",
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
driverObj.current = driver({
|
||||
showProgress: true,
|
||||
@@ -217,8 +319,10 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
||||
// Trigger current page immediately
|
||||
if (pathname === "/dashboard") runTourSegment("dashboard");
|
||||
else if (pathname.includes("/studies/new")) runTourSegment("study_creation");
|
||||
else if (pathname.includes("/participants/new")) runTourSegment("participant_creation");
|
||||
else if (pathname.includes("/designer")) runTourSegment("designer");
|
||||
else if (pathname.includes("/wizard")) runTourSegment("wizard");
|
||||
else if (pathname.includes("/analysis")) runTourSegment("analytics");
|
||||
else runTourSegment("dashboard"); // Fallback
|
||||
} else {
|
||||
localStorage.setItem("hristudio_tour_mode", "manual");
|
||||
@@ -226,8 +330,10 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
if (tour === "dashboard") runTourSegment("dashboard");
|
||||
if (tour === "study_creation") runTourSegment("study_creation");
|
||||
if (tour === "participant_creation") runTourSegment("participant_creation");
|
||||
if (tour === "designer") runTourSegment("designer");
|
||||
if (tour === "wizard") runTourSegment("wizard");
|
||||
if (tour === "analytics") runTourSegment("analytics");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
194
src/components/participants/ConsentUploadForm.tsx
Normal file
194
src/components/participants/ConsentUploadForm.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
"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 "sonner";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
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.error("File too large", {
|
||||
description: "Maximum file size is 10MB",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Validate type
|
||||
const allowedTypes = ["application/pdf", "image/png", "image/jpeg", "image/jpg"];
|
||||
if (!allowedTypes.includes(selectedFile.type)) {
|
||||
toast.error("Invalid file type", {
|
||||
description: "Please upload a PDF, PNG, or JPG file",
|
||||
});
|
||||
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 using XMLHttpRequest for progress
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("PUT", url, true);
|
||||
xhr.setRequestHeader("Content-Type", file.type);
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const percentCompleted = Math.round(
|
||||
(event.loaded * 100) / event.total
|
||||
);
|
||||
setUploadProgress(percentCompleted);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new Error("Network error during upload"));
|
||||
xhr.send(file);
|
||||
});
|
||||
|
||||
// 3. Record Consent in DB
|
||||
await recordConsentMutation.mutateAsync({
|
||||
participantId,
|
||||
consentFormId,
|
||||
storagePath: key,
|
||||
});
|
||||
|
||||
toast.success("Consent Recorded", {
|
||||
description: "The consent form has been uploaded and recorded successfully.",
|
||||
});
|
||||
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error("Upload failed:", error);
|
||||
toast.error("Upload Failed", {
|
||||
description: error instanceof Error ? error.message : "An unexpected error occurred",
|
||||
});
|
||||
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>
|
||||
);
|
||||
}
|
||||
161
src/components/participants/ParticipantConsentManager.tsx
Normal file
161
src/components/participants/ParticipantConsentManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
} from "~/components/ui/select";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useTour } from "~/components/onboarding/TourProvider";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
type DemographicsData = {
|
||||
age?: number;
|
||||
@@ -80,6 +82,7 @@ export function ParticipantForm({
|
||||
studyId,
|
||||
}: ParticipantFormProps) {
|
||||
const router = useRouter();
|
||||
const { startTour } = useTour();
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
const contextStudyId = studyId ?? selectedStudyId;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -256,14 +259,15 @@ export function ParticipantForm({
|
||||
<>
|
||||
<FormSection
|
||||
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>
|
||||
<Label htmlFor="participantCode">Participant Code *</Label>
|
||||
<Input
|
||||
id="participantCode"
|
||||
id="tour-participant-code"
|
||||
{...form.register("participantCode")}
|
||||
placeholder="e.g., P001, SUBJ_01, etc."
|
||||
placeholder="e.g., P001"
|
||||
className={
|
||||
form.formState.errors.participantCode ? "border-red-500" : ""
|
||||
}
|
||||
@@ -273,17 +277,14 @@ export function ParticipantForm({
|
||||
{form.formState.errors.participantCode.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Unique identifier for this participant within the study
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
id="tour-participant-name"
|
||||
{...form.register("name")}
|
||||
placeholder="Optional: Participant's full name"
|
||||
placeholder="Optional name"
|
||||
className={form.formState.errors.name ? "border-red-500" : ""}
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
@@ -291,9 +292,6 @@ export function ParticipantForm({
|
||||
{form.formState.errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Optional: Real name for contact purposes
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
@@ -310,24 +308,33 @@ export function ParticipantForm({
|
||||
{form.formState.errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Optional: For scheduling and communication
|
||||
</p>
|
||||
</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>
|
||||
<Label htmlFor="studyId">Study *</Label>
|
||||
<Label htmlFor="studyId" id="tour-participant-study-label">Study *</Label>
|
||||
<div id="tour-participant-study-container">
|
||||
<Select
|
||||
value={form.watch("studyId")}
|
||||
onValueChange={(value) => form.setValue("studyId", value)}
|
||||
disabled={studiesLoading || mode === "edit"}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={form.formState.errors.studyId ? "border-red-500" : ""}
|
||||
className={
|
||||
form.formState.errors.studyId ? "border-red-500" : ""
|
||||
}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
studiesLoading ? "Loading studies..." : "Select a study"
|
||||
studiesLoading ? "Loading..." : "Select study"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
@@ -344,18 +351,9 @@ export function ParticipantForm({
|
||||
{form.formState.errors.studyId.message}
|
||||
</p>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Study cannot be changed after registration
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
title="Demographics"
|
||||
description="Optional demographic information for research purposes."
|
||||
>
|
||||
<FormField>
|
||||
<Label htmlFor="age">Age</Label>
|
||||
<Input
|
||||
@@ -372,9 +370,6 @@ export function ParticipantForm({
|
||||
{form.formState.errors.age.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Optional: Age in years (minimum 18)
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
@@ -394,7 +389,7 @@ export function ParticipantForm({
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select gender (optional)" />
|
||||
<SelectValue placeholder="Select gender" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="male">Male</SelectItem>
|
||||
@@ -406,10 +401,8 @@ export function ParticipantForm({
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Optional: Gender identity for demographic analysis
|
||||
</p>
|
||||
</FormField>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{mode === "create" && (
|
||||
@@ -420,7 +413,7 @@ export function ParticipantForm({
|
||||
<FormField>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="consentGiven"
|
||||
id="tour-participant-consent"
|
||||
checked={form.watch("consentGiven")}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue("consentGiven", !!checked)
|
||||
@@ -505,9 +498,19 @@ export function ParticipantForm({
|
||||
error={error}
|
||||
onDelete={mode === "edit" ? onDelete : undefined}
|
||||
isDeleting={isDeleting}
|
||||
|
||||
// sidebar={sidebar} // Removed for cleaner UI per user request
|
||||
sidebar={mode === "create" ? sidebar : undefined}
|
||||
submitText={mode === "create" ? "Register Participant" : "Save Changes"}
|
||||
submitButtonId="tour-participant-submit"
|
||||
extraActions={
|
||||
mode === "create" ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => startTour("participant_creation")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Help</span>
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-full border text-xs text-muted-foreground">?</div>
|
||||
</div>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{formFields}
|
||||
</EntityForm>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { formatDistanceToNow } from "date-fns";
|
||||
@@ -24,6 +24,12 @@ import {
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/ui/tooltip";
|
||||
|
||||
export type Participant = {
|
||||
id: string;
|
||||
@@ -101,16 +107,32 @@ export const columns: ColumnDef<Participant>[] = [
|
||||
const name = row.getValue("name");
|
||||
const email = row.original.email;
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div>
|
||||
<div className="truncate font-medium">
|
||||
{String(name) || "No name provided"}
|
||||
<div className="truncate font-medium max-w-[200px]">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>{String(name) || "No name provided"}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{String(name) || "No name provided"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{email && (
|
||||
<div className="text-muted-foreground truncate text-sm">
|
||||
{email}
|
||||
<div className="text-muted-foreground truncate text-sm max-w-[200px]">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>{email}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{email}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -120,11 +142,30 @@ export const columns: ColumnDef<Participant>[] = [
|
||||
cell: ({ row }) => {
|
||||
const consentGiven = row.getValue("consentGiven");
|
||||
|
||||
if (consentGiven) {
|
||||
return <Badge className="bg-green-100 text-green-800">Consented</Badge>;
|
||||
}
|
||||
|
||||
return <Badge className="bg-red-100 text-red-800">Pending</Badge>;
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
{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",
|
||||
enableHiding: false,
|
||||
@@ -195,23 +213,12 @@ export const columns: ColumnDef<Participant>[] = [
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(participant.id)}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${studyId}/participants/${participant.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit participant
|
||||
</Link >
|
||||
</DropdownMenuItem >
|
||||
<DropdownMenuItem disabled>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
Send consent
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import {
|
||||
EntityForm,
|
||||
@@ -165,10 +166,12 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
|
||||
|
||||
// Form fields
|
||||
const formFields = (
|
||||
<div className="space-y-6">
|
||||
<FormSection
|
||||
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>
|
||||
<Label htmlFor="tour-study-name">Study Name *</Label>
|
||||
<Input
|
||||
@@ -184,6 +187,36 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
|
||||
)}
|
||||
</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>
|
||||
<Label htmlFor="tour-study-description">Description *</Label>
|
||||
<Textarea
|
||||
@@ -191,7 +224,9 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
|
||||
{...form.register("description")}
|
||||
placeholder="Describe the research objectives, methodology, and expected outcomes..."
|
||||
rows={4}
|
||||
className={form.formState.errors.description ? "border-red-500" : ""}
|
||||
className={
|
||||
form.formState.errors.description ? "border-red-500" : ""
|
||||
}
|
||||
/>
|
||||
{form.formState.errors.description && (
|
||||
<p className="text-sm text-red-600">
|
||||
@@ -199,14 +234,26 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
<Label htmlFor="institution">Institution *</Label>
|
||||
<Input
|
||||
id="institution"
|
||||
{...form.register("institution")}
|
||||
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 && (
|
||||
<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
|
||||
</p>
|
||||
</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>
|
||||
</FormSection>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Sidebar content
|
||||
@@ -324,7 +346,7 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
|
||||
error={error}
|
||||
onDelete={mode === "edit" ? onDelete : undefined}
|
||||
isDeleting={isDeleting}
|
||||
sidebar={sidebar}
|
||||
sidebar={mode === "create" ? sidebar : undefined}
|
||||
submitButtonId="tour-study-submit"
|
||||
extraActions={
|
||||
<Button variant="ghost" size="sm" onClick={() => startTour("study_creation")}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { 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",
|
||||
enableHiding: false,
|
||||
@@ -393,19 +367,6 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<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" && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
||||
@@ -431,11 +392,6 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<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") && (
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
<Ban className="mr-2 h-4 w-4" />
|
||||
|
||||
107
src/components/trials/analysis/events-columns.tsx
Normal file
107
src/components/trials/analysis/events-columns.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
101
src/components/trials/analysis/events-data-table.tsx
Normal file
101
src/components/trials/analysis/events-data-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,17 +2,21 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
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 { PlaybackPlayer } from "../playback/PlaybackPlayer";
|
||||
import { EventTimeline } from "../playback/EventTimeline";
|
||||
import { api } from "~/trpc/react";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/ui/resizable";
|
||||
import { EventsDataTable } from "../analysis/events-data-table";
|
||||
|
||||
interface TrialAnalysisViewProps {
|
||||
trial: {
|
||||
@@ -27,9 +31,10 @@ interface TrialAnalysisViewProps {
|
||||
mediaCount?: number;
|
||||
media?: { url: string; contentType: string }[];
|
||||
};
|
||||
backHref: string;
|
||||
}
|
||||
|
||||
export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) {
|
||||
export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
||||
// Fetch events for timeline
|
||||
const { data: events = [] } = api.trials.getEvents.useQuery({
|
||||
trialId: trial.id,
|
||||
@@ -39,139 +44,164 @@ export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) {
|
||||
const videoMedia = trial.media?.find(m => m.contentType.startsWith("video/"));
|
||||
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 (
|
||||
<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 */}
|
||||
<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">
|
||||
<Button variant="ghost" size="icon" asChild className="-ml-2">
|
||||
<Link href={backHref}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 ml-1" onClick={() => {
|
||||
// Dispatch custom event since useTour isn't directly available in this specific context yet
|
||||
// or better yet, assume we can import useTour if valid context, but here let's try direct button if applicable.
|
||||
// Actually, TrialAnalysisView is a child of page, we need useTour context.
|
||||
// Checking imports... TrialAnalysisView doesn't have useTour.
|
||||
// We should probably just dispatch an event or rely on the parent.
|
||||
// Let's assume we can add useTour hook support here.
|
||||
document.dispatchEvent(new CustomEvent('hristudio-start-tour', { detail: 'analytics' }));
|
||||
}}>
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
<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}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{trial.participant.participantCode} • Session {trial.id.slice(0, 4)}...
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-muted-foreground mt-1">
|
||||
<span className="font-mono">{trial.participant.participantCode}</span>
|
||||
<span>•</span>
|
||||
<span>Session {trial.id.slice(0, 4)}</span>
|
||||
</div>
|
||||
<div className="h-8 w-px bg-border" />
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
</div>
|
||||
</div>
|
||||
<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" />
|
||||
<span>{trial.startedAt?.toLocaleDateString()} {trial.startedAt?.toLocaleTimeString()}</span>
|
||||
<span className="text-xs font-mono">
|
||||
{trial.startedAt?.toLocaleDateString()} {trial.startedAt?.toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
{trial.duration && (
|
||||
<Badge variant="secondary" className="text-[10px] font-mono">
|
||||
{Math.floor(trial.duration / 60)}m {trial.duration % 60}s
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Header */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4" id="tour-trial-metrics">
|
||||
<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>
|
||||
<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 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>
|
||||
|
||||
{/* Main Resizable Workspace */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
|
||||
{/* LEFT: Video & Timeline */}
|
||||
<ResizablePanel defaultSize={65} minSize={30} className="flex flex-col min-h-0">
|
||||
{/* Main Workspace: Vertical Layout */}
|
||||
<div className="flex-1 min-h-0 rounded-xl border shadow-sm overflow-hidden bg-background">
|
||||
<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" id="tour-trial-timeline">
|
||||
<div className="relative flex-1 min-h-0 flex items-center justify-center">
|
||||
{videoUrl ? (
|
||||
<div className="absolute inset-0">
|
||||
<PlaybackPlayer src={videoUrl} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center text-slate-500">
|
||||
<VideoOff className="h-12 w-12 mb-3 opacity-20" />
|
||||
<p className="text-sm">No recording available.</p>
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground p-8 text-center">
|
||||
<div className="bg-muted rounded-full p-4 mb-4">
|
||||
<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>
|
||||
)}
|
||||
</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 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 />
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
<ResizableHandle withHandle className="bg-border/50" />
|
||||
|
||||
{/* RIGHT: Logs & Metrics */}
|
||||
<ResizablePanel defaultSize={35} minSize={20} className="flex flex-col min-h-0 border-l bg-muted/5">
|
||||
{/* Metrics Strip */}
|
||||
<div className="grid grid-cols-2 gap-2 p-3 border-b bg-background flex-none">
|
||||
<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">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" />
|
||||
{/* BOTTOM: Events Table */}
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="flex flex-col min-h-0 bg-background" id="tour-trial-events">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-primary" />
|
||||
<h3 className="font-semibold text-sm">Event Log</h3>
|
||||
</div>
|
||||
</CardContent>
|
||||
</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'}`} />
|
||||
<Badge variant="secondary" className="text-xs">{events.length} Events</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Log Title */}
|
||||
<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>
|
||||
)}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4">
|
||||
<EventsDataTable
|
||||
data={events.map(e => ({ ...e, timestamp: new Date(e.timestamp) }))}
|
||||
startTime={trial.startedAt ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
@@ -187,3 +217,4 @@ function formatTime(ms: number) {
|
||||
const s = Math.floor(totalSeconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,12 +29,13 @@ interface WizardViewProps {
|
||||
demographics: Record<string, unknown> | null;
|
||||
};
|
||||
};
|
||||
userRole: string;
|
||||
}
|
||||
|
||||
export function WizardView({ trial }: WizardViewProps) {
|
||||
export function WizardView({ trial, userRole }: WizardViewProps) {
|
||||
return (
|
||||
<div className="h-full">
|
||||
<WizardInterface trial={trial} userRole="wizard" />
|
||||
<div className="h-full max-h-full w-full overflow-hidden">
|
||||
<WizardInterface trial={trial} userRole={userRole} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ export function RobotActionsPanel({
|
||||
disconnect: disconnectRos,
|
||||
executeRobotAction: executeRosAction,
|
||||
} = useWizardRos({
|
||||
autoConnect: true,
|
||||
autoConnect: false, // Let WizardInterface handle connection
|
||||
onActionCompleted: (execution) => {
|
||||
toast.success(`Completed: ${execution.actionId}`, {
|
||||
description: `Action executed in ${execution.endTime ? execution.endTime.getTime() - execution.startTime.getTime() : 0}ms`,
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Play, CheckCircle, X, Clock, AlertCircle, HelpCircle } from "lucide-react";
|
||||
import {
|
||||
Play,
|
||||
CheckCircle,
|
||||
X,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Pause,
|
||||
SkipForward
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import Link from "next/link";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { PanelsContainer } from "~/components/experiments/designer/layout/PanelsContainer";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { WizardControlPanel } from "./panels/WizardControlPanel";
|
||||
import { WizardExecutionPanel } from "./panels/WizardExecutionPanel";
|
||||
import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel";
|
||||
import { WizardObservationPane } from "./panels/WizardObservationPane";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/ui/resizable";
|
||||
import { TrialStatusBar } from "./panels/TrialStatusBar";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useWizardRos } from "~/hooks/useWizardRos";
|
||||
import { toast } from "sonner";
|
||||
@@ -68,8 +82,18 @@ interface StepData {
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
| "conditional";
|
||||
parameters: Record<string, unknown>;
|
||||
conditions?: {
|
||||
nextStepId?: string;
|
||||
options?: {
|
||||
label: string;
|
||||
value: string;
|
||||
nextStepId?: string;
|
||||
nextStepIndex?: number;
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
||||
}[];
|
||||
};
|
||||
order: number;
|
||||
actions: ActionData[];
|
||||
}
|
||||
@@ -87,24 +111,31 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const router = useRouter();
|
||||
|
||||
// Persistent tab states to prevent resets from parent re-renders
|
||||
const [controlPanelTab, setControlPanelTab] = useState<
|
||||
"control" | "step" | "actions" | "robot"
|
||||
>("control");
|
||||
const [executionPanelTab, setExecutionPanelTab] = useState<
|
||||
"current" | "timeline" | "events"
|
||||
>(trial.status === "in_progress" ? "current" : "timeline");
|
||||
// UI State
|
||||
const [executionPanelTab, setExecutionPanelTab] = useState<"current" | "timeline" | "events">("timeline");
|
||||
const [obsTab, setObsTab] = useState<"notes" | "timeline">("notes");
|
||||
const [isExecutingAction, setIsExecutingAction] = useState(false);
|
||||
const [monitoringPanelTab, setMonitoringPanelTab] = useState<
|
||||
"status" | "robot" | "events"
|
||||
>("status");
|
||||
const [completedActionsCount, setCompletedActionsCount] = useState(0);
|
||||
|
||||
// Collapse state for panels
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||
const [obsCollapsed, setObsCollapsed] = useState(false);
|
||||
|
||||
// Center tabs (Timeline | Actions)
|
||||
const [centerTab, setCenterTab] = useState<"timeline" | "actions">("timeline");
|
||||
|
||||
// Reset completed actions when step changes
|
||||
useEffect(() => {
|
||||
setCompletedActionsCount(0);
|
||||
}, [currentStepIndex]);
|
||||
|
||||
// Track the last response value from wizard_wait_for_response for branching
|
||||
const [lastResponse, setLastResponse] = useState<string | null>(null);
|
||||
|
||||
// Get experiment steps from API
|
||||
const { data: experimentSteps } = api.experiments.getSteps.useQuery(
|
||||
{ experimentId: trial.experimentId },
|
||||
@@ -145,7 +176,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
case "parallel":
|
||||
return "parallel_steps" as const;
|
||||
case "conditional":
|
||||
return "conditional_branch" as const;
|
||||
return "conditional" as const;
|
||||
default:
|
||||
return "wizard_action" as const;
|
||||
}
|
||||
@@ -276,9 +307,11 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
name: step.name ?? `Step ${index + 1}`,
|
||||
description: step.description,
|
||||
type: mapStepType(step.type),
|
||||
// Fix: Conditions are at root level from API
|
||||
conditions: (step as any).conditions ?? (step as any).trigger?.conditions ?? undefined,
|
||||
parameters: step.parameters ?? {},
|
||||
order: step.order ?? index,
|
||||
actions: step.actions?.map((action) => ({
|
||||
actions: step.actions?.filter(a => a.type !== 'branch').map((action) => ({
|
||||
id: action.id,
|
||||
name: action.name,
|
||||
description: action.description,
|
||||
@@ -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
|
||||
const handleStartTrial = async () => {
|
||||
console.log(
|
||||
@@ -410,21 +469,88 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
};
|
||||
|
||||
const handlePauseTrial = async () => {
|
||||
// TODO: Implement pause functionality
|
||||
console.log("Pause trial");
|
||||
try {
|
||||
await pauseTrialMutation.mutateAsync({ id: trial.id });
|
||||
} catch (error) {
|
||||
console.error("Failed to pause trial:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (currentStepIndex < steps.length - 1) {
|
||||
setCompletedActionsCount(0); // Reset immediately to prevent flickering/double-click issues
|
||||
setCurrentStepIndex(currentStepIndex + 1);
|
||||
// Note: Step transitions can be enhanced later with database logging
|
||||
const handleNextStep = (targetIndex?: number) => {
|
||||
// If explicit target provided (from branching choice), use it
|
||||
if (typeof targetIndex === 'number') {
|
||||
// Find step by index to ensure safety
|
||||
if (targetIndex >= 0 && targetIndex < steps.length) {
|
||||
console.log(`[WizardInterface] Manual jump to step ${targetIndex}`);
|
||||
setCompletedActionsCount(0);
|
||||
setCurrentStepIndex(targetIndex);
|
||||
setLastResponse(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic Branching Logic
|
||||
const currentStep = steps[currentStepIndex];
|
||||
|
||||
// Check if we have a stored response that dictates the next step
|
||||
if (currentStep?.type === 'conditional' && currentStep.conditions?.options && lastResponse) {
|
||||
const matchedOption = currentStep.conditions.options.find(opt => opt.value === lastResponse);
|
||||
if (matchedOption && matchedOption.nextStepId) {
|
||||
// Find index of the target step
|
||||
const targetIndex = steps.findIndex(s => s.id === matchedOption.nextStepId);
|
||||
if (targetIndex !== -1) {
|
||||
console.log(`[WizardInterface] Branching to step ${targetIndex} (${matchedOption.label})`);
|
||||
setCurrentStepIndex(targetIndex);
|
||||
setLastResponse(null); // Reset after consuming
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for explicit nextStepId in conditions (e.g. for end of branch)
|
||||
console.log("[WizardInterface] Checking for nextStepId condition:", currentStep?.conditions);
|
||||
if (currentStep?.conditions?.nextStepId) {
|
||||
const nextId = String(currentStep.conditions.nextStepId);
|
||||
const targetIndex = steps.findIndex(s => s.id === nextId);
|
||||
if (targetIndex !== -1) {
|
||||
console.log(`[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`);
|
||||
setCurrentStepIndex(targetIndex);
|
||||
setCompletedActionsCount(0);
|
||||
return;
|
||||
} else {
|
||||
console.warn(`[WizardInterface] Targeted nextStepId ${nextId} not found in steps list.`);
|
||||
}
|
||||
} else {
|
||||
console.log("[WizardInterface] No nextStepId found in conditions, proceeding linearly.");
|
||||
}
|
||||
|
||||
// Default: Linear progression
|
||||
const nextIndex = currentStepIndex + 1;
|
||||
if (nextIndex < steps.length) {
|
||||
// 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 () => {
|
||||
try {
|
||||
await completeTrialMutation.mutateAsync({ id: trial.id });
|
||||
// Trigger archive in background
|
||||
archiveTrialMutation.mutate({ id: trial.id });
|
||||
} catch (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
|
||||
const addInterventionMutation = api.trials.addIntervention.useMutation({
|
||||
@@ -476,8 +599,25 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
parameters?: Record<string, unknown>,
|
||||
) => {
|
||||
try {
|
||||
// Log action execution
|
||||
console.log("Executing action:", actionId, parameters);
|
||||
|
||||
// Handle branching logic (wizard_wait_for_response)
|
||||
if (parameters?.value && parameters?.label) {
|
||||
setLastResponse(String(parameters.value));
|
||||
|
||||
// If nextStepId is provided, jump immediately
|
||||
if (parameters.nextStepId) {
|
||||
const nextId = String(parameters.nextStepId);
|
||||
const targetIndex = steps.findIndex(s => s.id === nextId);
|
||||
if (targetIndex !== -1) {
|
||||
console.log(`[WizardInterface] Choice-based jump to step ${targetIndex} (${nextId})`);
|
||||
handleNextStep(targetIndex);
|
||||
return; // Exit after jump
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (actionId === "acknowledge") {
|
||||
await logEventMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
@@ -614,65 +754,102 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
[logRobotActionMutation, trial.id],
|
||||
);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Compact Status Bar */}
|
||||
<div className="bg-background border-b px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge
|
||||
variant={statusConfig.variant}
|
||||
className="flex items-center gap-1"
|
||||
<div className="flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden bg-background">
|
||||
<PageHeader
|
||||
title="Trial Execution"
|
||||
description={`Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
|
||||
icon={Play}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{trial.status === "scheduled" && (
|
||||
<Button
|
||||
onClick={handleStartTrial}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{trial.status.replace("_", " ")}
|
||||
</Badge>
|
||||
<Play className="h-4 w-4" />
|
||||
Start Trial
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{trial.status === "in_progress" && (
|
||||
<div className="flex items-center gap-1 font-mono text-sm">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatElapsedTime(elapsedTime)}
|
||||
</div>
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
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 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Step {currentStepIndex + 1} of {totalSteps}
|
||||
</span>
|
||||
<div className="w-16">
|
||||
<Progress value={progressPercentage} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
{_userRole !== "participant" && (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/studies/${trial.experiment.studyId}/trials`}>
|
||||
Exit
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
className="flex-none px-2 pb-2"
|
||||
/>
|
||||
|
||||
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||
<div>{trial.experiment.name}</div>
|
||||
<div>{trial.participant.participantCode}</div>
|
||||
<Badge
|
||||
variant={rosConnected ? "default" : "outline"}
|
||||
className="text-xs"
|
||||
{/* Main Grid - 2 rows */}
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2">
|
||||
{/* Top Row - 3 Column Layout */}
|
||||
<div className="flex-1 min-h-0 flex gap-2">
|
||||
{/* Left Sidebar - Control Panel (Collapsible) */}
|
||||
{!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"}
|
||||
</Badge>
|
||||
<button
|
||||
onClick={() => startTour("wizard")}
|
||||
className="hover:bg-muted p-1 rounded-full transition-colors"
|
||||
title="Start Tour"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</button>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</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 className="flex-1 overflow-auto min-h-0 bg-muted/10">
|
||||
<div id="tour-wizard-controls" className="h-full">
|
||||
<WizardControlPanel
|
||||
trial={trial}
|
||||
@@ -688,15 +865,59 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
onExecuteRobotAction={handleExecuteRobotAction}
|
||||
studyId={trial.experiment.studyId}
|
||||
_isConnected={rosConnected}
|
||||
activeTab={controlPanelTab}
|
||||
onTabChange={setControlPanelTab}
|
||||
isStarting={startTrialMutation.isPending}
|
||||
onSetAutonomousLife={setAutonomousLife}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
center={
|
||||
</div>
|
||||
</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">
|
||||
<WizardExecutionPanel
|
||||
trial={trial}
|
||||
@@ -718,8 +939,24 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
</div>
|
||||
</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">
|
||||
<WizardMonitoringPanel
|
||||
rosConnected={rosConnected}
|
||||
@@ -732,25 +969,54 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
showDividers={true}
|
||||
className="h-full"
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ResizableHandle />
|
||||
|
||||
<ResizablePanel defaultSize={25} minSize={10}>
|
||||
{/* Bottom Row - Observations (Full Width, Collapsible) */}
|
||||
{!obsCollapsed && (
|
||||
<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
|
||||
onAddAnnotation={handleAddAnnotation}
|
||||
isSubmitting={addAnnotationMutation.isPending}
|
||||
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'}
|
||||
activeTab={obsTab}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</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 >
|
||||
);
|
||||
|
||||
133
src/components/trials/wizard/panels/TrialStatusBar.tsx
Normal file
133
src/components/trials/wizard/panels/TrialStatusBar.tsx
Normal 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;
|
||||
@@ -207,9 +207,9 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
|
||||
)}
|
||||
</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 ? (
|
||||
<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}>
|
||||
<Webcam
|
||||
ref={webcamRef}
|
||||
@@ -249,11 +249,13 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-slate-500">
|
||||
<CameraOff className="mx-auto mb-2 h-12 w-12 opacity-20" />
|
||||
<p className="text-sm">Camera is disabled</p>
|
||||
<div className="text-center text-muted-foreground/50">
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<CameraOff className="h-6 w-6 opacity-50" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">Camera is disabled</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={handleEnableCamera}
|
||||
|
||||
@@ -22,7 +22,6 @@ import { Separator } from "~/components/ui/separator";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { RobotActionsPanel } from "../RobotActionsPanel";
|
||||
|
||||
@@ -34,8 +33,17 @@ interface StepData {
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
| "conditional"; // Updated to match DB enum
|
||||
parameters: Record<string, unknown>;
|
||||
conditions?: {
|
||||
options?: {
|
||||
label: string;
|
||||
value: string;
|
||||
nextStepId?: string;
|
||||
nextStepIndex?: number;
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
||||
}[];
|
||||
};
|
||||
order: number;
|
||||
actions?: {
|
||||
id: string;
|
||||
@@ -80,7 +88,7 @@ interface WizardControlPanelProps {
|
||||
currentStepIndex: number;
|
||||
onStartTrial: () => void;
|
||||
onPauseTrial: () => void;
|
||||
onNextStep: () => void;
|
||||
onNextStep: (targetIndex?: number) => void;
|
||||
onCompleteTrial: () => void;
|
||||
onAbortTrial: () => void;
|
||||
onExecuteAction: (
|
||||
@@ -94,14 +102,13 @@ interface WizardControlPanelProps {
|
||||
) => Promise<void>;
|
||||
studyId?: string;
|
||||
_isConnected: boolean;
|
||||
activeTab: "control" | "step" | "actions" | "robot";
|
||||
onTabChange: (tab: "control" | "step" | "actions" | "robot") => void;
|
||||
|
||||
isStarting?: boolean;
|
||||
onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function WizardControlPanel({
|
||||
export const WizardControlPanel = React.memo(function WizardControlPanel({
|
||||
trial,
|
||||
currentStep,
|
||||
steps,
|
||||
@@ -115,8 +122,6 @@ export function WizardControlPanel({
|
||||
onExecuteRobotAction,
|
||||
studyId,
|
||||
_isConnected,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
isStarting = false,
|
||||
onSetAutonomousLife,
|
||||
readOnly = false,
|
||||
@@ -140,238 +145,22 @@ export function WizardControlPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<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="flex h-full flex-col" id="tour-wizard-controls">
|
||||
|
||||
|
||||
<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">
|
||||
<div className="space-y-3 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-4 p-3">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
{currentStep.name}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{currentStep.type.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Decision Point UI removed as per user request (handled in Execution Panel) */}
|
||||
|
||||
{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" ? (
|
||||
<>
|
||||
<div className="mb-2 text-xs font-medium">
|
||||
Quick Actions
|
||||
</div>
|
||||
|
||||
<div className="space-y-2" id="tour-wizard-action-list">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
console.log("[WizardControlPanel] Acknowledge clicked");
|
||||
onExecuteAction("acknowledge");
|
||||
}}
|
||||
onClick={() => onExecuteAction("acknowledge")}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-3 w-3" />
|
||||
@@ -381,36 +170,26 @@ export function WizardControlPanel({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
console.log("[WizardControlPanel] Intervene clicked");
|
||||
onExecuteAction("intervene");
|
||||
}}
|
||||
className="w-full justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800"
|
||||
onClick={() => onExecuteAction("intervene")}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<AlertCircle className="mr-2 h-3 w-3" />
|
||||
Intervene
|
||||
Flag Intervention
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
console.log("[WizardControlPanel] Add Note clicked");
|
||||
onExecuteAction("note", { content: "Wizard note" });
|
||||
}}
|
||||
onClick={() => onExecuteAction("note", { content: "Wizard note" })}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<User className="mr-2 h-3 w-3" />
|
||||
Add Note
|
||||
</Button>
|
||||
|
||||
<Separator />
|
||||
|
||||
{currentStep?.type === "wizard_action" && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium">Step Actions</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -419,31 +198,44 @@ export function WizardControlPanel({
|
||||
disabled={readOnly}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-3 w-3" />
|
||||
Mark Complete
|
||||
Mark Step Complete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<div className="text-muted-foreground text-center text-xs">
|
||||
{trial.status === "scheduled"
|
||||
? "Start trial to access actions"
|
||||
: "Actions unavailable - trial not active"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground p-2 text-center border border-dashed rounded-md bg-muted/20">
|
||||
Controls available during trial
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* Robot Actions Tab */}
|
||||
<TabsContent
|
||||
value="robot"
|
||||
className="m-0 h-full data-[state=active]:flex data-[state=active]:flex-col"
|
||||
>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-3">
|
||||
<Separator />
|
||||
|
||||
{/* Robot Controls (Merged from System & Robot Tab) */}
|
||||
<div className="space-y-3">
|
||||
<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-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="tour-wizard-autonomous"
|
||||
checked={!!autonomousLife}
|
||||
onCheckedChange={handleAutonomousLifeChange}
|
||||
disabled={!_isConnected || readOnly}
|
||||
className="scale-75"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Robot Actions Panel Integration */}
|
||||
{studyId && onExecuteRobotAction ? (
|
||||
<div className={readOnly ? "pointer-events-none opacity-50" : ""}>
|
||||
<RobotActionsPanel
|
||||
@@ -453,19 +245,12 @@ export function WizardControlPanel({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Robot actions are not available. Study ID or action
|
||||
handler is missing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="text-xs text-muted-foreground text-center py-2">Robot actions unavailable</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</ScrollArea>
|
||||
</div >
|
||||
</div >
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,16 +3,15 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Play,
|
||||
Clock,
|
||||
SkipForward,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Bot,
|
||||
User,
|
||||
Activity,
|
||||
Zap,
|
||||
ArrowRight,
|
||||
AlertTriangle,
|
||||
Zap,
|
||||
Loader2,
|
||||
Clock,
|
||||
RotateCcw,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -26,8 +25,17 @@ interface StepData {
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
| "conditional";
|
||||
parameters: Record<string, unknown>;
|
||||
conditions?: {
|
||||
options?: {
|
||||
label: string;
|
||||
value: string;
|
||||
nextStepId?: string;
|
||||
nextStepIndex?: number;
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
||||
}[];
|
||||
};
|
||||
order: number;
|
||||
actions?: {
|
||||
id: string;
|
||||
@@ -129,30 +137,31 @@ export function WizardExecutionPanel({
|
||||
|
||||
const activeActionIndex = completedActionsCount;
|
||||
|
||||
// Auto-scroll to active action
|
||||
const activeActionRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeActionRef.current) {
|
||||
activeActionRef.current.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
}, [activeActionIndex, currentStepIndex]);
|
||||
|
||||
// Pre-trial state
|
||||
if (trial.status === "scheduled") {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b p-3">
|
||||
<h3 className="text-sm font-medium">Trial Ready</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{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 className="flex-1 flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-md space-y-4 text-center">
|
||||
<Clock className="text-muted-foreground mx-auto h-12 w-12 opacity-20" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">Ready to Begin</h4>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Use the control panel to start this trial
|
||||
<h4 className="text-lg font-medium">Ready to Begin</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{steps.length} steps prepared. Use controls to start.
|
||||
</p>
|
||||
</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>
|
||||
@@ -197,88 +206,111 @@ export function WizardExecutionPanel({
|
||||
|
||||
// Active trial state
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="border-b p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<ScrollArea className="h-full w-full">
|
||||
<div className="pr-4">
|
||||
{currentStep ? (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
{/* Header Info (Simplified) */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex flex-col gap-4 p-4 max-w-2xl mx-auto">
|
||||
{/* Header Info */}
|
||||
<div className="space-y-1 pb-4 border-b">
|
||||
<h2 className="text-xl font-bold tracking-tight">{currentStep.name}</h2>
|
||||
{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>
|
||||
|
||||
{/* Action Sequence */}
|
||||
{currentStep.actions && currentStep.actions.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
<div className="relative ml-3 space-y-0 pt-2">
|
||||
{currentStep.actions.map((action, idx) => {
|
||||
const isCompleted = idx < activeActionIndex;
|
||||
const isActive = idx === activeActionIndex;
|
||||
const isActive: boolean = idx === activeActionIndex;
|
||||
const isLast = idx === currentStep.actions!.length - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
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" :
|
||||
isCompleted ? "bg-muted/30 border-transparent opacity-70" :
|
||||
"bg-card border-border opacity-50"
|
||||
className="relative pl-8 pb-10 last:pb-0"
|
||||
ref={isActive ? activeActionRef : undefined}
|
||||
>
|
||||
{/* 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" :
|
||||
isActive ? "bg-transparent text-primary border-primary font-bold shadow-sm" :
|
||||
"bg-transparent text-muted-foreground border-transparent"
|
||||
}`}>
|
||||
{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>
|
||||
{isCompleted ? (
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<span className="text-[10px] font-bold">{idx + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{action.pluginId && isActive && (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Content Card */}
|
||||
<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 === true ? (
|
||||
<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
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-9 px-3 text-muted-foreground hover:text-foreground"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log("Skip clicked");
|
||||
// Fire and forget
|
||||
onSkipAction(
|
||||
action.pluginId!,
|
||||
action.type.includes(".")
|
||||
@@ -293,34 +325,8 @@ export function WizardExecutionPanel({
|
||||
>
|
||||
Skip
|
||||
</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
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
@@ -331,26 +337,74 @@ export function WizardExecutionPanel({
|
||||
>
|
||||
Mark Done
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completed State Indicator */}
|
||||
{isCompleted && (
|
||||
<div className="flex items-center gap-2 px-3">
|
||||
<div className="text-xs font-medium text-green-600">
|
||||
Done
|
||||
</div>
|
||||
{action.pluginId && (
|
||||
<>
|
||||
) : null}
|
||||
|
||||
{/* Wizard Wait For Response / Branching UI */}
|
||||
{isActive === true &&
|
||||
action.type === "wizard_wait_for_response" &&
|
||||
action.parameters?.options &&
|
||||
Array.isArray(action.parameters.options) ? (
|
||||
<div className="pt-3 grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{(action.parameters.options as any[]).map(
|
||||
(opt, optIdx) => {
|
||||
// Handle both string options and object options
|
||||
const label =
|
||||
typeof opt === "string"
|
||||
? opt
|
||||
: opt.label;
|
||||
const value =
|
||||
typeof opt === "string"
|
||||
? opt
|
||||
: opt.value;
|
||||
const nextStepId =
|
||||
typeof opt === "object"
|
||||
? opt.nextStepId
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
title="Retry Action"
|
||||
key={optIdx}
|
||||
variant="outline"
|
||||
className="justify-start h-auto py-3 px-4 text-left border-primary/20 hover:border-primary hover:bg-primary/5"
|
||||
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>
|
||||
) : null}
|
||||
|
||||
{/* 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) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Execute again without advancing count
|
||||
onExecuteRobotAction(
|
||||
action.pluginId!,
|
||||
action.type.includes(".") ? action.type.split(".").pop()! : action.type,
|
||||
@@ -360,41 +414,27 @@ export function WizardExecutionPanel({
|
||||
}}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
<RotateCcw className="mr-1.5 h-3 w-3" />
|
||||
Retry
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Manual Advance Button */}
|
||||
{activeActionIndex >= (currentStep.actions?.length || 0) && (
|
||||
<div className="mt-6 flex justify-end">
|
||||
<div className="mt-6 flex justify-center pb-8">
|
||||
<Button
|
||||
size="lg"
|
||||
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-green-600 hover:bg-green-700"
|
||||
}`}
|
||||
@@ -406,34 +446,14 @@ export function WizardExecutionPanel({
|
||||
</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">
|
||||
No active step
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground space-y-3">
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -49,7 +49,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 p-2">
|
||||
{/* 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} />
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
{rosConnected ? (
|
||||
<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>
|
||||
|
||||
@@ -31,6 +31,7 @@ interface WizardObservationPaneProps {
|
||||
) => Promise<void>;
|
||||
isSubmitting?: boolean;
|
||||
readOnly?: boolean;
|
||||
activeTab?: "notes" | "timeline";
|
||||
}
|
||||
|
||||
export function WizardObservationPane({
|
||||
@@ -38,6 +39,7 @@ export function WizardObservationPane({
|
||||
isSubmitting = false,
|
||||
trialEvents = [],
|
||||
readOnly = false,
|
||||
activeTab = "notes",
|
||||
}: WizardObservationPaneProps & { trialEvents?: TrialEvent[] }) {
|
||||
const [note, setNote] = useState("");
|
||||
const [category, setCategory] = useState("observation");
|
||||
@@ -68,20 +70,8 @@ export function WizardObservationPane({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col border-t bg-background">
|
||||
<Tabs defaultValue="notes" className="flex h-full flex-col">
|
||||
<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 h-full flex-col bg-background">
|
||||
<div className={`flex-1 flex flex-col p-4 m-0 ${activeTab !== "notes" ? "hidden" : ""}`}>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Textarea
|
||||
placeholder={readOnly ? "Session is read-only" : "Type your observation here..."}
|
||||
@@ -151,12 +141,11 @@ export function WizardObservationPane({
|
||||
</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} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ export function DataTable<TData, TValue>({
|
||||
</DropdownMenu>
|
||||
</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">
|
||||
<Table className="min-w-[600px]">
|
||||
<TableHeader>
|
||||
|
||||
@@ -127,12 +127,14 @@ export function EntityForm<T extends FieldValues = FieldValues>({
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-8 w-full",
|
||||
layout === "default" && "grid-cols-1 lg:grid-cols-3", // Keep the column split but remove max-width
|
||||
layout === "full-width" && "grid-cols-1",
|
||||
// If sidebar exists, use 2-column layout. If not, use full width (max-w-7xl centered).
|
||||
sidebar && layout === "default"
|
||||
? "grid-cols-1 lg:grid-cols-3"
|
||||
: "grid-cols-1 max-w-7xl mx-auto",
|
||||
)}
|
||||
>
|
||||
{/* 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>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function Logo({
|
||||
}: LogoProps) {
|
||||
return (
|
||||
<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]} />
|
||||
</div>
|
||||
{showText && (
|
||||
|
||||
@@ -309,7 +309,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -18,7 +18,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
className={cn("[&_tr]:border-b bg-secondary/30", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -158,3 +158,88 @@ export function convertActionToDatabase(
|
||||
category: action.category,
|
||||
};
|
||||
}
|
||||
|
||||
// Reconstruct designer steps from database records
|
||||
export function convertDatabaseToSteps(
|
||||
dbSteps: any[] // Typing as any[] because Drizzle types are complex to import here without circular deps
|
||||
): ExperimentStep[] {
|
||||
// Paranoid Sort: Ensure steps are strictly ordered by index before assigning Triggers.
|
||||
// This safeguards against API returning unsorted data.
|
||||
const sortedSteps = [...dbSteps].sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0));
|
||||
|
||||
return sortedSteps.map((dbStep, idx) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export type TriggerType =
|
||||
export interface ActionParameter {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "text" | "number" | "select" | "boolean";
|
||||
type: "text" | "number" | "select" | "boolean" | "json" | "array";
|
||||
placeholder?: string;
|
||||
options?: string[];
|
||||
min?: number;
|
||||
@@ -119,26 +119,13 @@ export const TRIGGER_OPTIONS = [
|
||||
];
|
||||
|
||||
// Step type options for UI
|
||||
// IMPORTANT: Steps should ALWAYS execute sequentially
|
||||
// Parallel execution, conditionals, and loops should be implemented via control flow ACTIONS
|
||||
export const STEP_TYPE_OPTIONS = [
|
||||
{
|
||||
value: "sequential" as const,
|
||||
label: "Sequential",
|
||||
description: "Actions run one after another",
|
||||
},
|
||||
{
|
||||
value: "parallel" as const,
|
||||
label: "Parallel",
|
||||
description: "Actions run at the same time",
|
||||
},
|
||||
{
|
||||
value: "conditional" as const,
|
||||
label: "Conditional",
|
||||
description: "Actions run if condition is met",
|
||||
},
|
||||
{
|
||||
value: "loop" as const,
|
||||
label: "Loop",
|
||||
description: "Actions repeat multiple times",
|
||||
description: "Actions run one after another (enforced for all steps)",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -148,7 +148,11 @@ export class WizardRosService extends EventEmitter {
|
||||
console.error("[WizardROS] WebSocket error:", error);
|
||||
clearTimeout(connectionTimeout);
|
||||
this.isConnecting = false;
|
||||
|
||||
// Prevent unhandled error event if no listeners
|
||||
if (this.listenerCount("error") > 0) {
|
||||
this.emit("error", error);
|
||||
}
|
||||
reject(error);
|
||||
};
|
||||
});
|
||||
|
||||
51
src/plugins/definitions/hristudio-core.json
Normal file
51
src/plugins/definitions/hristudio-core.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
107
src/plugins/definitions/hristudio-woz.json
Normal file
107
src/plugins/definitions/hristudio-woz.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
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 { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
studyMembers,
|
||||
userSystemRoles,
|
||||
} from "~/server/db/schema";
|
||||
import { convertStepsToDatabase } from "~/lib/experiment-designer/block-converter";
|
||||
import {
|
||||
convertStepsToDatabase,
|
||||
convertDatabaseToSteps,
|
||||
} from "~/lib/experiment-designer/block-converter";
|
||||
import type {
|
||||
ExperimentStep,
|
||||
ExperimentDesign,
|
||||
@@ -84,7 +87,10 @@ export const experimentsRouter = createTRPCRouter({
|
||||
// Check study access
|
||||
await checkStudyAccess(ctx.db, userId, studyId);
|
||||
|
||||
const conditions = [eq(experiments.studyId, studyId)];
|
||||
const conditions = [
|
||||
eq(experiments.studyId, studyId),
|
||||
isNull(experiments.deletedAt),
|
||||
];
|
||||
if (status) {
|
||||
conditions.push(eq(experiments.status, status));
|
||||
}
|
||||
@@ -221,7 +227,10 @@ export const experimentsRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
// Build where conditions
|
||||
const conditions = [inArray(experiments.studyId, studyIds)];
|
||||
const conditions = [
|
||||
inArray(experiments.studyId, studyIds),
|
||||
isNull(experiments.deletedAt),
|
||||
];
|
||||
|
||||
if (status) {
|
||||
conditions.push(eq(experiments.status, status));
|
||||
@@ -382,6 +391,7 @@ export const experimentsRouter = createTRPCRouter({
|
||||
|
||||
return {
|
||||
...experiment,
|
||||
steps: convertDatabaseToSteps(experiment.steps),
|
||||
integrityHash: experiment.integrityHash,
|
||||
executionGraphSummary,
|
||||
pluginDependencies: experiment.pluginDependencies ?? [],
|
||||
@@ -1539,7 +1549,8 @@ export const experimentsRouter = createTRPCRouter({
|
||||
description: step.description,
|
||||
order: step.orderIndex,
|
||||
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
|
||||
children: [], // TODO: implement hierarchical steps if needed
|
||||
actions: step.actions.map((action) => ({
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { db } from "~/server/db";
|
||||
import {
|
||||
activityLogs, consentForms, participantConsents, participants, studyMembers, trials
|
||||
} from "~/server/db/schema";
|
||||
import { getUploadUrl, validateFile } from "~/lib/storage/minio";
|
||||
|
||||
// Helper function to check study access
|
||||
async function checkStudyAccess(
|
||||
@@ -415,6 +416,42 @@ export const participantsRouter = createTRPCRouter({
|
||||
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
|
||||
.input(
|
||||
z.object({
|
||||
@@ -422,10 +459,11 @@ export const participantsRouter = createTRPCRouter({
|
||||
consentFormId: z.string().uuid(),
|
||||
signatureData: z.string().optional(),
|
||||
ipAddress: z.string().optional(),
|
||||
storagePath: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { participantId, consentFormId, signatureData, ipAddress } = input;
|
||||
const { participantId, consentFormId, signatureData, ipAddress, storagePath } = input;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get participant to check study access
|
||||
@@ -489,6 +527,7 @@ export const participantsRouter = createTRPCRouter({
|
||||
consentFormId,
|
||||
signatureData,
|
||||
ipAddress,
|
||||
storagePath,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import { s3Client } from "~/server/storage";
|
||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { env } from "~/env";
|
||||
import { uploadFile } from "~/lib/storage/minio";
|
||||
|
||||
// Helper function to check if user has access to trial
|
||||
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];
|
||||
}),
|
||||
|
||||
@@ -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];
|
||||
}),
|
||||
|
||||
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
|
||||
.input(
|
||||
z.object({
|
||||
@@ -1046,6 +1182,19 @@ export const trialsRouter = createTRPCRouter({
|
||||
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 };
|
||||
}),
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface StepDefinition {
|
||||
type: string;
|
||||
orderIndex: number;
|
||||
condition?: string;
|
||||
conditions?: Record<string, any>;
|
||||
actions: ActionDefinition[];
|
||||
}
|
||||
|
||||
@@ -173,7 +174,8 @@ export class TrialExecutionEngine {
|
||||
description: step.description || undefined,
|
||||
type: step.type,
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -399,20 +401,37 @@ export class TrialExecutionEngine {
|
||||
|
||||
switch (action.type) {
|
||||
case "wait":
|
||||
case "hristudio-core.wait":
|
||||
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 "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);
|
||||
|
||||
case "wizard_gesture":
|
||||
return await this.executeWizardAction(trialId, action);
|
||||
|
||||
case "observe_behavior":
|
||||
case "hristudio-woz.observe":
|
||||
return await this.executeObservationAction(trialId, action);
|
||||
|
||||
default:
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -424,6 +443,7 @@ export class TrialExecutionEngine {
|
||||
data: {
|
||||
message: `Action type '${action.type}' not implemented yet`,
|
||||
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
|
||||
*/
|
||||
@@ -827,12 +857,54 @@ export class TrialExecutionEngine {
|
||||
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;
|
||||
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", {
|
||||
fromStepIndex: previousStepIndex,
|
||||
toStepIndex: context.currentStepIndex,
|
||||
reason: nextStepIndex !== previousStepIndex + 1 ? "branch" : "sequence"
|
||||
});
|
||||
|
||||
// Check if we've completed all steps
|
||||
|
||||
@@ -69,42 +69,62 @@
|
||||
--shadow-opacity: var(--shadow-opacity);
|
||||
--color-shadow-color: var(--shadow-color);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
/* Validation Colors */
|
||||
--color-validation-error-bg: var(--validation-error-bg);
|
||||
--color-validation-error-text: var(--validation-error-text);
|
||||
--color-validation-error-border: var(--validation-error-border);
|
||||
--color-validation-warning-bg: var(--validation-warning-bg);
|
||||
--color-validation-warning-text: var(--validation-warning-text);
|
||||
--color-validation-warning-border: var(--validation-warning-border);
|
||||
--color-validation-info-bg: var(--validation-info-bg);
|
||||
--color-validation-info-text: var(--validation-info-text);
|
||||
--color-validation-info-border: var(--validation-info-border);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0rem;
|
||||
--background: oklch(0.98 0.005 60);
|
||||
--foreground: oklch(0.15 0.005 240);
|
||||
--card: oklch(0.995 0.001 60);
|
||||
--card-foreground: oklch(0.15 0.005 240);
|
||||
--popover: oklch(0.99 0.002 60);
|
||||
--popover-foreground: oklch(0.15 0.005 240);
|
||||
--primary: oklch(0.55 0.08 240);
|
||||
--primary-foreground: oklch(0.98 0.01 250);
|
||||
--secondary: oklch(0.94 0.01 240);
|
||||
--secondary-foreground: oklch(0.25 0.02 240);
|
||||
--muted: oklch(0.95 0.008 240);
|
||||
--muted-foreground: oklch(0.52 0.015 240);
|
||||
--accent: oklch(0.92 0.015 240);
|
||||
--accent-foreground: oklch(0.2 0.02 240);
|
||||
--destructive: oklch(0.583 0.2387 28.4765);
|
||||
--border: oklch(0.9 0.008 240);
|
||||
--input: oklch(0.96 0.005 240);
|
||||
--ring: oklch(0.55 0.08 240);
|
||||
--chart-1: oklch(0.55 0.08 240);
|
||||
--chart-2: oklch(0.6 0.1 200);
|
||||
--chart-3: oklch(0.65 0.12 160);
|
||||
--chart-4: oklch(0.7 0.1 120);
|
||||
--chart-5: oklch(0.6 0.15 80);
|
||||
--sidebar: oklch(0.97 0.015 250);
|
||||
--sidebar-foreground: oklch(0.2 0.03 240);
|
||||
--sidebar-primary: oklch(0.3 0.08 240);
|
||||
--sidebar-primary-foreground: oklch(0.98 0.01 250);
|
||||
--sidebar-accent: oklch(0.92 0.025 245);
|
||||
--sidebar-accent-foreground: oklch(0.25 0.05 240);
|
||||
--sidebar-border: oklch(0.85 0.03 245);
|
||||
--sidebar-ring: oklch(0.6 0.05 240);
|
||||
--destructive-foreground: oklch(0.9702 0 0);
|
||||
/* Light Mode (Inverted: White BG, gray Cards) */
|
||||
--radius: 0.5rem;
|
||||
--background: hsl(0 0% 100%);
|
||||
/* Pure White Background */
|
||||
--foreground: hsl(240 10% 3.9%);
|
||||
--card: hsl(240 4.8% 95.9%);
|
||||
/* Light Gray Card */
|
||||
--card-foreground: hsl(240 10% 3.9%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(240 10% 3.9%);
|
||||
--primary: hsl(221.2 83.2% 53.3%);
|
||||
/* Indigo-600 */
|
||||
--primary-foreground: hsl(210 40% 98%);
|
||||
--secondary: hsl(210 40% 96.1%);
|
||||
--secondary-foreground: hsl(222.2 47.4% 11.2%);
|
||||
--muted: hsl(210 40% 96.1%);
|
||||
--muted-foreground: hsl(215.4 16.3% 46.9%);
|
||||
--accent: hsl(210 40% 96.1%);
|
||||
--accent-foreground: hsl(222.2 47.4% 11.2%);
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive-foreground: hsl(210 40% 98%);
|
||||
--border: hsl(214.3 31.8% 91.4%);
|
||||
--input: hsl(214.3 31.8% 91.4%);
|
||||
--ring: hsl(221.2 83.2% 53.3%);
|
||||
--chart-1: hsl(221.2 83.2% 53.3%);
|
||||
--chart-2: hsl(173 58% 39%);
|
||||
--chart-3: hsl(197 37% 24%);
|
||||
--chart-4: hsl(43 74% 66%);
|
||||
--chart-5: hsl(27 87% 67%);
|
||||
--sidebar: hsl(240 4.8% 95.9%);
|
||||
/* Zinc-100: Distinct contrast against white BG */
|
||||
--sidebar-foreground: hsl(240 10% 3.9%);
|
||||
/* 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-opacity: 0;
|
||||
@@ -131,82 +151,127 @@
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: oklch(0.12 0.008 250);
|
||||
--foreground: oklch(0.95 0.005 250);
|
||||
--card: oklch(0.18 0.008 250);
|
||||
--card-foreground: oklch(0.95 0.005 250);
|
||||
--popover: oklch(0.2 0.01 250);
|
||||
--popover-foreground: oklch(0.95 0.005 250);
|
||||
--primary: oklch(0.65 0.1 240);
|
||||
--primary-foreground: oklch(0.08 0.02 250);
|
||||
--secondary: oklch(0.25 0.015 245);
|
||||
--secondary-foreground: oklch(0.92 0.008 250);
|
||||
--muted: oklch(0.22 0.01 250);
|
||||
--muted-foreground: oklch(0.65 0.02 245);
|
||||
--accent: oklch(0.35 0.025 245);
|
||||
--accent-foreground: oklch(0.92 0.008 250);
|
||||
--destructive: oklch(0.7022 0.1892 22.2279);
|
||||
--border: oklch(0.3 0.015 250);
|
||||
--input: oklch(0.28 0.015 250);
|
||||
--ring: oklch(0.65 0.1 240);
|
||||
--chart-1: oklch(0.65 0.1 240);
|
||||
--chart-2: oklch(0.7 0.12 200);
|
||||
--chart-3: oklch(0.75 0.15 160);
|
||||
--chart-4: oklch(0.8 0.12 120);
|
||||
--chart-5: oklch(0.7 0.18 80);
|
||||
--sidebar: oklch(0.14 0.025 250);
|
||||
--sidebar-foreground: oklch(0.88 0.02 250);
|
||||
--sidebar-primary: oklch(0.8 0.06 240);
|
||||
--sidebar-primary-foreground: oklch(0.12 0.025 250);
|
||||
--sidebar-accent: oklch(0.22 0.04 245);
|
||||
--sidebar-accent-foreground: oklch(0.88 0.02 250);
|
||||
--sidebar-border: oklch(0.32 0.035 250);
|
||||
--sidebar-ring: oklch(0.55 0.08 240);
|
||||
--destructive-foreground: oklch(0.95 0.01 250);
|
||||
--background: hsl(240 10% 3.9%);
|
||||
--foreground: hsl(0 0% 98%);
|
||||
/* Distinct Card Background for better contrast */
|
||||
--card: hsl(240 5% 9%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--popover: hsl(240 5% 9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--primary: hsl(0 0% 98%);
|
||||
--primary-foreground: hsl(240 5.9% 10%);
|
||||
--secondary: hsl(240 3.7% 15.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(240 3.7% 15.9%);
|
||||
--muted-foreground: hsl(240 5% 64.9%);
|
||||
--accent: hsl(240 3.7% 15.9%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 3.7% 15.9%);
|
||||
--input: hsl(240 3.7% 15.9%);
|
||||
--ring: hsl(240 4.9% 83.9%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar: hsl(240 5.9% 10%);
|
||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-primary: hsl(224.3 76.3% 48%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(240 3.7% 15.9%);
|
||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-border: hsl(240 3.7% 15.9%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
.dark {
|
||||
--background: oklch(0.12 0.008 250);
|
||||
--foreground: oklch(0.95 0.005 250);
|
||||
--card: oklch(0.18 0.008 250);
|
||||
--card-foreground: oklch(0.95 0.005 250);
|
||||
--popover: oklch(0.2 0.01 250);
|
||||
--popover-foreground: oklch(0.95 0.005 250);
|
||||
--primary: oklch(0.65 0.1 240);
|
||||
--primary-foreground: oklch(0.08 0.02 250);
|
||||
--secondary: oklch(0.25 0.015 245);
|
||||
--secondary-foreground: oklch(0.92 0.008 250);
|
||||
--muted: oklch(0.22 0.01 250);
|
||||
--muted-foreground: oklch(0.65 0.02 245);
|
||||
--accent: oklch(0.35 0.025 245);
|
||||
--accent-foreground: oklch(0.92 0.008 250);
|
||||
--destructive: oklch(0.7022 0.1892 22.2279);
|
||||
--border: oklch(0.3 0.015 250);
|
||||
--input: oklch(0.28 0.015 250);
|
||||
--ring: oklch(0.65 0.1 240);
|
||||
--chart-1: oklch(0.65 0.1 240);
|
||||
--chart-2: oklch(0.7 0.12 200);
|
||||
--chart-3: oklch(0.75 0.15 160);
|
||||
--chart-4: oklch(0.8 0.12 120);
|
||||
--chart-5: oklch(0.7 0.18 80);
|
||||
--sidebar: oklch(0.14 0.025 250);
|
||||
--sidebar-foreground: oklch(0.88 0.02 250);
|
||||
--sidebar-primary: oklch(0.8 0.06 240);
|
||||
--sidebar-primary-foreground: oklch(0.12 0.025 250);
|
||||
--sidebar-accent: oklch(0.22 0.04 245);
|
||||
--sidebar-accent-foreground: oklch(0.88 0.02 250);
|
||||
--sidebar-border: oklch(0.32 0.035 250);
|
||||
--sidebar-ring: oklch(0.55 0.08 240);
|
||||
--destructive-foreground: oklch(0.95 0.01 250);
|
||||
--background: hsl(240 10% 3.9%);
|
||||
--foreground: hsl(0 0% 98%);
|
||||
--card: hsl(240 5% 9%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--popover: hsl(240 5% 9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--primary: hsl(0 0% 98%);
|
||||
--primary-foreground: hsl(240 5.9% 10%);
|
||||
--secondary: hsl(240 3.7% 15.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(240 3.7% 15.9%);
|
||||
--muted-foreground: hsl(240 5% 64.9%);
|
||||
--accent: hsl(240 3.7% 15.9%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 3.7% 15.9%);
|
||||
--input: hsl(240 3.7% 15.9%);
|
||||
--ring: hsl(240 4.9% 83.9%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar: hsl(240 5.9% 10%);
|
||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-primary: hsl(224.3 76.3% 48%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(240 3.7% 15.9%);
|
||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-border: hsl(240 3.7% 15.9%);
|
||||
--sidebar-border: hsl(240 3.7% 15.9%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
|
||||
/* Validation Dark Mode */
|
||||
--validation-error-bg: hsl(0 75% 15%);
|
||||
/* Red 950-ish */
|
||||
--validation-error-text: hsl(0 100% 90%);
|
||||
/* Red 100 */
|
||||
--validation-error-border: hsl(0 50% 30%);
|
||||
/* Red 900 */
|
||||
--validation-warning-bg: hsl(30 90% 10%);
|
||||
/* Amber 950-ish */
|
||||
--validation-warning-text: hsl(30 100% 90%);
|
||||
/* Amber 100 */
|
||||
--validation-warning-border: hsl(30 60% 30%);
|
||||
/* Amber 900 */
|
||||
--validation-info-bg: hsl(210 50% 15%);
|
||||
/* Blue 950-ish */
|
||||
--validation-info-text: hsl(210 100% 90%);
|
||||
/* Blue 100 */
|
||||
--validation-info-border: hsl(210 40% 30%);
|
||||
/* Blue 900 */
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Validation Light Mode Defaults */
|
||||
--validation-error-bg: hsl(0 85% 97%);
|
||||
/* Red 50 */
|
||||
--validation-error-text: hsl(0 72% 45%);
|
||||
/* Red 700 */
|
||||
--validation-error-border: hsl(0 80% 90%);
|
||||
/* Red 200 */
|
||||
--validation-warning-bg: hsl(40 85% 97%);
|
||||
/* Amber 50 */
|
||||
--validation-warning-text: hsl(35 90% 35%);
|
||||
/* Amber 700 */
|
||||
--validation-warning-border: hsl(40 80% 90%);
|
||||
/* Amber 200 */
|
||||
--validation-info-bg: hsl(210 85% 97%);
|
||||
/* Blue 50 */
|
||||
--validation-info-text: hsl(220 80% 45%);
|
||||
/* Blue 700 */
|
||||
--validation-info-border: hsl(210 80% 90%);
|
||||
/* Blue 200 */
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
letter-spacing: var(--tracking-normal);
|
||||
@@ -225,3 +290,11 @@
|
||||
@apply bg-background text-foreground shadow;
|
||||
}
|
||||
}
|
||||
|
||||
/* Viewport height constraint for proper flex layout */
|
||||
html,
|
||||
body,
|
||||
#__next {
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
Reference in New Issue
Block a user