mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-23 19:27:51 -04:00
feat(analytics): refine timeline visualization and add print support
This commit is contained in:
@@ -19,12 +19,13 @@ HRIStudio addresses critical challenges in HRI research by providing a comprehen
|
|||||||
- **Hierarchical Structure**: Study → Experiment → Trial → Step → Action
|
- **Hierarchical Structure**: Study → Experiment → Trial → Step → Action
|
||||||
- **Visual Experiment Designer**: Drag-and-drop protocol creation with 26+ core blocks
|
- **Visual Experiment Designer**: Drag-and-drop protocol creation with 26+ core blocks
|
||||||
- **Plugin System**: Extensible robot platform integration (RESTful, ROS2, custom)
|
- **Plugin System**: Extensible robot platform integration (RESTful, ROS2, custom)
|
||||||
|
- **Consolidated Wizard Interface**: 3-panel design with trial controls, horizontal timeline, and unified robot controls
|
||||||
- **Real-time Trial Execution**: Live wizard control with comprehensive data capture
|
- **Real-time Trial Execution**: Live wizard control with comprehensive data capture
|
||||||
- **Role-Based Access**: Administrator, Researcher, Wizard, Observer (4 distinct roles)
|
- **Role-Based Access**: Administrator, Researcher, Wizard, Observer (4 distinct roles)
|
||||||
- **Unified Form Experiences**: 73% code reduction through standardized patterns
|
- **Unified Form Experiences**: 73% code reduction through standardized patterns
|
||||||
- **Enterprise DataTables**: Advanced filtering, pagination, export capabilities
|
- **Enterprise DataTables**: Advanced filtering, pagination, export capabilities
|
||||||
- **Real-time Trial Execution**: Professional wizard interface with live monitoring
|
|
||||||
- **Mock Robot Integration**: Complete simulation system for development and testing
|
- **Mock Robot Integration**: Complete simulation system for development and testing
|
||||||
|
- **Intelligent Control Flow**: Loops with implicit approval, branching logic, parallel execution
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -96,6 +97,9 @@ bun dev
|
|||||||
- Plugin Store with trust levels (Official, Verified, Community)
|
- Plugin Store with trust levels (Official, Verified, Community)
|
||||||
|
|
||||||
#### 3. Adaptive Wizard Interface
|
#### 3. Adaptive Wizard Interface
|
||||||
|
- **3-Panel Design**: Trial controls (left), horizontal timeline (center), robot control & status (right)
|
||||||
|
- **Horizontal Step Progress**: Non-scrolling step navigation with visual progress indicators
|
||||||
|
- **Consolidated Robot Controls**: Single location for connection, autonomous life, actions, and monitoring
|
||||||
- Real-time experiment execution dashboard
|
- Real-time experiment execution dashboard
|
||||||
- Step-by-step guidance for consistent execution
|
- Step-by-step guidance for consistent execution
|
||||||
- Quick actions for unscripted interventions
|
- Quick actions for unscripted interventions
|
||||||
|
|||||||
@@ -29,6 +29,18 @@ services:
|
|||||||
- minio_data:/data
|
- minio_data:/data
|
||||||
command: server --console-address ":9001" /data
|
command: server --console-address ":9001" /data
|
||||||
|
|
||||||
|
createbuckets:
|
||||||
|
image: minio/mc
|
||||||
|
depends_on:
|
||||||
|
- minio
|
||||||
|
entrypoint: >
|
||||||
|
/bin/sh -c "
|
||||||
|
/usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin;
|
||||||
|
/usr/bin/mc mb myminio/hristudio;
|
||||||
|
/usr/bin/mc anonymous set public myminio/hristudio;
|
||||||
|
exit 0;
|
||||||
|
"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
minio_data:
|
minio_data:
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ bun dev
|
|||||||
### **Development Experience**
|
### **Development Experience**
|
||||||
- **Unified Components**: Significant reduction in code duplication
|
- **Unified Components**: Significant reduction in code duplication
|
||||||
- **Panel Architecture**: 90% code sharing between experiment designer and wizard interface
|
- **Panel Architecture**: 90% code sharing between experiment designer and wizard interface
|
||||||
|
- **Consolidated Wizard**: 3-panel design with trial controls, horizontal timeline, and unified robot controls
|
||||||
- **Enterprise DataTables**: Advanced filtering, export, pagination
|
- **Enterprise DataTables**: Advanced filtering, export, pagination
|
||||||
- **Comprehensive Testing**: Realistic seed data with complete scenarios
|
- **Comprehensive Testing**: Realistic seed data with complete scenarios
|
||||||
- **Developer Friendly**: Clear patterns and extensive documentation
|
- **Developer Friendly**: Clear patterns and extensive documentation
|
||||||
@@ -253,11 +254,12 @@ bun dev
|
|||||||
- ✅ **Database Schema** - 31 tables with comprehensive relationships
|
- ✅ **Database Schema** - 31 tables with comprehensive relationships
|
||||||
- ✅ **Authentication** - Role-based access control system
|
- ✅ **Authentication** - Role-based access control system
|
||||||
- ✅ **Visual Designer** - Repository-based plugin architecture
|
- ✅ **Visual Designer** - Repository-based plugin architecture
|
||||||
- ✅ **Panel-Based Wizard Interface** - Consistent with experiment designer architecture
|
- ✅ **Consolidated Wizard Interface** - 3-panel design with horizontal timeline and unified robot controls
|
||||||
- ✅ **Core Blocks System** - 26 blocks across events, wizard, control, observation
|
- ✅ **Core Blocks System** - 26 blocks across events, wizard, control, observation
|
||||||
- ✅ **Plugin Architecture** - Unified system for core blocks and robot actions
|
- ✅ **Plugin Architecture** - Unified system for core blocks and robot actions
|
||||||
- ✅ **Development Environment** - Realistic test data and scenarios
|
- ✅ **Development Environment** - Realistic test data and scenarios
|
||||||
- ✅ **NAO6 Robot Integration** - Full ROS2 integration with comprehensive control and monitoring
|
- ✅ **NAO6 Robot Integration** - Full ROS2 integration with comprehensive control and monitoring
|
||||||
|
- ✅ **Intelligent Control Flow** - Loops with implicit approval, branching, parallel execution
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ HRIStudio is a web-based platform designed to standardize and improve the reprod
|
|||||||
- Context-sensitive help and best practice guidance
|
- Context-sensitive help and best practice guidance
|
||||||
- Automatic generation of robot-specific action components
|
- Automatic generation of robot-specific action components
|
||||||
- Parameter configuration with validation
|
- Parameter configuration with validation
|
||||||
|
- **System Plugins**:
|
||||||
|
- **Core (`hristudio-core`)**: Control flow (loops, branches) and observation blocks
|
||||||
|
- **Wizard (`hristudio-woz`)**: Wizard interactions (speech, text input)
|
||||||
|
- **External Robot Plugins**:
|
||||||
|
- Located in `robot-plugins/` repository (e.g., `nao6-ros2`)
|
||||||
|
- Loaded dynamically per study
|
||||||
|
- Map abstract actions (Say, Walk) to ROS2 topics
|
||||||
- **Core Block Categories**:
|
- **Core Block Categories**:
|
||||||
- Events (4): Trial triggers, speech detection, timers, key presses
|
- Events (4): Trial triggers, speech detection, timers, key presses
|
||||||
- Wizard Actions (6): Speech, gestures, object handling, rating, notes
|
- Wizard Actions (6): Speech, gestures, object handling, rating, notes
|
||||||
|
|||||||
@@ -2,278 +2,366 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The Wizard Interface is a real-time control panel for conducting Human-Robot Interaction (HRI) trials. It provides wizards with comprehensive tools to execute experiment protocols, monitor participant interactions, and control robot behaviors in real-time.
|
The HRIStudio wizard interface provides a comprehensive, real-time trial execution environment with a consolidated 3-panel design optimized for efficient experiment control and monitoring.
|
||||||
|
|
||||||
## Key Features
|
## Interface Layout
|
||||||
|
|
||||||
- **Real-time Trial Execution**: Live step-by-step protocol execution with WebSocket connectivity
|
```
|
||||||
- **Robot Status Monitoring**: Battery levels, connection status, sensor readings, and position tracking
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
- **Participant Information**: Demographics, consent status, and session details
|
│ Trial Execution Header │
|
||||||
- **Live Event Logging**: Real-time capture of all trial events and wizard interventions
|
│ [Trial Name] - [Participant] - [Status] │
|
||||||
- **Action Controls**: Quick access to common wizard actions and robot commands
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
┌──────────────┬──────────────────────────────────────┬──────────────────────┐
|
||||||
## WebSocket System
|
│ │ │ │
|
||||||
|
│ Trial │ Execution Timeline │ Robot Control │
|
||||||
### Connection Setup
|
│ Control │ │ & Status │
|
||||||
|
│ │ │ │
|
||||||
The wizard interface automatically connects to a WebSocket server for real-time communication:
|
│ ┌──────────┐ │ ┌──┬──┬──┬──┬──┐ Step Progress │ 📷 Camera View │
|
||||||
|
│ │ Start │ │ │✓ │✓ │● │ │ │ │ │
|
||||||
```typescript
|
│ │ Pause │ │ └──┴──┴──┴──┴──┘ │ Connection: ✓ │
|
||||||
// WebSocket URL format
|
│ │ Next Step│ │ │ │
|
||||||
wss://your-domain.com/api/websocket?trialId={TRIAL_ID}&token={AUTH_TOKEN}
|
│ │ Complete │ │ Current Step: "Greeting" │ Autonomous Life: ON │
|
||||||
|
│ │ Abort │ │ ┌────────────────────────────────┐ │ │
|
||||||
|
│ └──────────┘ │ │ Actions: │ │ Robot Actions: │
|
||||||
|
│ │ │ • Say "Hello" [Run] │ │ ┌──────────────────┐ │
|
||||||
|
│ Progress: │ │ • Wave Hand [Run] │ │ │ Quick Commands │ │
|
||||||
|
│ Step 3/5 │ │ • Wait 2s [Run] │ │ └──────────────────┘ │
|
||||||
|
│ │ └────────────────────────────────┘ │ │
|
||||||
|
│ │ │ Movement Controls │
|
||||||
|
│ │ │ Quick Actions │
|
||||||
|
│ │ │ Status Monitoring │
|
||||||
|
└──────────────┴──────────────────────────────────────┴──────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### Message Types
|
## Panel Descriptions
|
||||||
|
|
||||||
#### Incoming Messages (from server):
|
### Left Panel: Trial Control
|
||||||
- `connection_established` - Connection acknowledgment
|
|
||||||
- `trial_status` - Current trial state and step information
|
|
||||||
- `trial_action_executed` - Confirmation of action execution
|
|
||||||
- `step_changed` - Step transition notifications
|
|
||||||
- `intervention_logged` - Wizard intervention confirmations
|
|
||||||
|
|
||||||
#### Outgoing Messages (to server):
|
**Purpose**: Manage overall trial flow and progression
|
||||||
- `heartbeat` - Keep connection alive
|
|
||||||
- `trial_action` - Execute trial actions (start, complete, abort)
|
|
||||||
- `wizard_intervention` - Log wizard interventions
|
|
||||||
- `step_transition` - Advance to next step
|
|
||||||
|
|
||||||
### Example Usage
|
**Features:**
|
||||||
|
- **Start Trial**: Begin experiment execution
|
||||||
|
- **Pause/Resume**: Temporarily halt trial without aborting
|
||||||
|
- **Next Step**: Manually advance to next step (when all actions complete)
|
||||||
|
- **Complete Trial**: Mark trial as successfully completed
|
||||||
|
- **Abort Trial**: Emergency stop with reason logging
|
||||||
|
|
||||||
```typescript
|
**Progress Indicators:**
|
||||||
// Start a trial
|
- Current step number (e.g., "Step 3 of 5")
|
||||||
webSocket.sendMessage({
|
- Overall trial status
|
||||||
type: "trial_action",
|
- Time elapsed
|
||||||
data: {
|
|
||||||
actionType: "start_trial",
|
|
||||||
step_index: 0,
|
|
||||||
data: { notes: "Trial started by wizard" }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log wizard intervention
|
**Best Practices:**
|
||||||
webSocket.sendMessage({
|
- Use Pause for participant breaks
|
||||||
type: "wizard_intervention",
|
- Use Abort only for unrecoverable issues
|
||||||
data: {
|
- Document abort reasons thoroughly
|
||||||
action_type: "manual_correction",
|
|
||||||
step_index: currentStepIndex,
|
|
||||||
action_data: { message: "Clarified instruction" }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Trial Execution Workflow
|
|
||||||
|
|
||||||
### 1. Pre-Trial Setup
|
|
||||||
- Verify participant consent and demographics
|
|
||||||
- Check robot connection and status
|
|
||||||
- Review experiment protocol steps
|
|
||||||
- Confirm WebSocket connectivity
|
|
||||||
|
|
||||||
### 2. Starting a Trial
|
|
||||||
1. Click "Start Trial" button
|
|
||||||
2. System automatically:
|
|
||||||
- Updates trial status to "in_progress"
|
|
||||||
- Records start timestamp
|
|
||||||
- Loads first protocol step
|
|
||||||
- Broadcasts status to all connected clients
|
|
||||||
|
|
||||||
### 3. Step-by-Step Execution
|
|
||||||
- **Current Step Display**: Shows active step details and actions
|
|
||||||
- **Execute Step**: Trigger step-specific actions (robot commands, wizard prompts)
|
|
||||||
- **Next Step**: Advance to subsequent protocol step
|
|
||||||
- **Quick Actions**: Access common wizard interventions
|
|
||||||
|
|
||||||
### 4. Real-time Monitoring
|
|
||||||
- **Robot Status**: Live updates on battery, signal, position, sensors
|
|
||||||
- **Event Log**: Chronological list of all trial events
|
|
||||||
- **Progress Tracking**: Visual progress bar and step completion status
|
|
||||||
|
|
||||||
### 5. Trial Completion
|
|
||||||
- Click "Complete" for successful trials
|
|
||||||
- Click "Abort" for early termination
|
|
||||||
- System records end timestamp and final status
|
|
||||||
- Automatic redirect to analysis page
|
|
||||||
|
|
||||||
## Experiment Data Integration
|
|
||||||
|
|
||||||
### Loading Real Experiment Steps
|
|
||||||
|
|
||||||
The wizard interface automatically loads experiment steps from the database:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Steps are fetched from the experiments API
|
|
||||||
const { data: experimentSteps } = api.experiments.getSteps.useQuery({
|
|
||||||
experimentId: trial.experimentId
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step Types and Actions
|
|
||||||
|
|
||||||
Supported step types from the experiment designer:
|
|
||||||
- **Wizard Steps**: Manual wizard actions and prompts
|
|
||||||
- **Robot Steps**: Automated robot behaviors and movements
|
|
||||||
- **Parallel Steps**: Concurrent actions executed simultaneously
|
|
||||||
- **Conditional Steps**: Branching logic based on participant responses
|
|
||||||
|
|
||||||
## Seed Data and Testing
|
|
||||||
|
|
||||||
### Available Test Data
|
|
||||||
|
|
||||||
The development database includes realistic test scenarios:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Seed the database with test data
|
|
||||||
bun db:seed
|
|
||||||
|
|
||||||
# Default login credentials
|
|
||||||
Email: sean@soconnor.dev
|
|
||||||
Password: password123
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Experiments
|
|
||||||
|
|
||||||
1. **"Basic Interaction Protocol 1"** (Study: Real-time HRI Coordination)
|
|
||||||
- 3 steps: Introduction, Wait for Response, Robot Feedback
|
|
||||||
- Includes wizard actions and NAO robot integration
|
|
||||||
- Estimated duration: 25 minutes
|
|
||||||
|
|
||||||
2. **"Dialogue Timing Pilot"** (Study: Wizard-of-Oz Dialogue Study)
|
|
||||||
- Multi-step protocol with parallel and conditional actions
|
|
||||||
- Timer-based transitions and conditional follow-ups
|
|
||||||
- Estimated duration: 35 minutes
|
|
||||||
|
|
||||||
### Test Participants
|
|
||||||
|
|
||||||
Pre-loaded participants with complete demographics:
|
|
||||||
- Various age groups (18-65)
|
|
||||||
- Different educational backgrounds
|
|
||||||
- Robot experience levels
|
|
||||||
- Consent already verified
|
|
||||||
|
|
||||||
## Robot Integration
|
|
||||||
|
|
||||||
### Supported Robots
|
|
||||||
|
|
||||||
- **TurtleBot3 Burger**: Navigation and sensing capabilities
|
|
||||||
- **NAO Humanoid Robot**: Speech, gestures, and animations
|
|
||||||
- **Plugin System**: Extensible support for additional platforms
|
|
||||||
|
|
||||||
### Robot Actions
|
|
||||||
|
|
||||||
Common robot actions available during trials:
|
|
||||||
- **Speech**: Text-to-speech with configurable speed/volume
|
|
||||||
- **Movement**: Navigation commands and position control
|
|
||||||
- **Gestures**: Pre-defined animation sequences
|
|
||||||
- **LED Control**: Visual feedback through color changes
|
|
||||||
- **Sensor Readings**: Real-time environmental data
|
|
||||||
|
|
||||||
## Error Handling and Troubleshooting
|
|
||||||
|
|
||||||
### WebSocket Connection Issues
|
|
||||||
|
|
||||||
- **Connection Failed**: Check network connectivity and server status
|
|
||||||
- **Frequent Disconnections**: Verify firewall settings and WebSocket support
|
|
||||||
- **Authentication Errors**: Ensure valid session and proper token generation
|
|
||||||
|
|
||||||
### Trial Execution Problems
|
|
||||||
|
|
||||||
- **Steps Not Loading**: Verify experiment has published steps in database
|
|
||||||
- **Robot Commands Failing**: Check robot connection and plugin configuration
|
|
||||||
- **Progress Not Updating**: Confirm WebSocket messages are being sent/received
|
|
||||||
|
|
||||||
### Recovery Procedures
|
|
||||||
|
|
||||||
1. **Connection Loss**: Interface automatically attempts reconnection with exponential backoff
|
|
||||||
2. **Trial State Mismatch**: Use "Refresh" button to sync with server state
|
|
||||||
3. **Robot Disconnect**: Monitor robot status panel for connection recovery
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Wizard Guidelines
|
|
||||||
|
|
||||||
1. **Pre-Trial Preparation**
|
|
||||||
- Review complete experiment protocol
|
|
||||||
- Test robot functionality before participant arrival
|
|
||||||
- Verify audio/video recording systems
|
|
||||||
|
|
||||||
2. **During Trial Execution**
|
|
||||||
- Follow protocol steps in sequence
|
|
||||||
- Use intervention logging for any deviations
|
|
||||||
- Monitor participant comfort and engagement
|
|
||||||
- Watch robot status for any issues
|
|
||||||
|
|
||||||
3. **Post-Trial Procedures**
|
|
||||||
- Complete trial properly (don't just abort)
|
|
||||||
- Add summary notes about participant behavior
|
|
||||||
- Review event log for any anomalies
|
|
||||||
|
|
||||||
### Technical Considerations
|
|
||||||
|
|
||||||
- **Browser Compatibility**: Use modern browsers with WebSocket support
|
|
||||||
- **Network Requirements**: Stable internet connection for real-time features
|
|
||||||
- **Performance**: Close unnecessary browser tabs during trials
|
|
||||||
- **Backup Plans**: Have manual procedures ready if technology fails
|
|
||||||
|
|
||||||
## Development and Customization
|
|
||||||
|
|
||||||
### Adding Custom Actions
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Register new wizard action
|
|
||||||
const handleCustomAction = async (actionData: Record<string, unknown>) => {
|
|
||||||
await logEventMutation.mutateAsync({
|
|
||||||
trialId: trial.id,
|
|
||||||
type: "wizard_action",
|
|
||||||
data: {
|
|
||||||
action_type: "custom_intervention",
|
|
||||||
...actionData
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Extending Robot Support
|
|
||||||
|
|
||||||
1. Create new robot plugin following plugin system guidelines
|
|
||||||
2. Define action schemas in plugin configuration
|
|
||||||
3. Implement communication protocol (REST/ROS2/WebSocket)
|
|
||||||
4. Test integration with wizard interface
|
|
||||||
|
|
||||||
### Custom Step Types
|
|
||||||
|
|
||||||
To add new step types:
|
|
||||||
1. Update database schema (`stepTypeEnum`)
|
|
||||||
2. Add type mapping in `WizardInterface.tsx`
|
|
||||||
3. Create step-specific UI components
|
|
||||||
4. Update execution engine logic
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
- **Authentication**: All WebSocket connections require valid session tokens
|
|
||||||
- **Authorization**: Role-based access control for trial operations
|
|
||||||
- **Data Protection**: All trial data encrypted in transit and at rest
|
|
||||||
- **Session Management**: Automatic cleanup of expired connections
|
|
||||||
|
|
||||||
## Performance Optimization
|
|
||||||
|
|
||||||
- **Connection Pooling**: Efficient WebSocket connection management
|
|
||||||
- **Event Batching**: Group related events to reduce message overhead
|
|
||||||
- **Selective Updates**: Only broadcast relevant changes to connected clients
|
|
||||||
- **Caching**: Local state management for responsive UI updates
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start Checklist
|
### Center Panel: Execution Timeline
|
||||||
|
|
||||||
- [ ] Database seeded with test data (`bun db:seed`)
|
**Purpose**: Visualize experiment flow and execute current step actions
|
||||||
- [ ] Development server running (`bun dev`)
|
|
||||||
- [ ] Logged in as administrator (sean@soconnor.dev)
|
|
||||||
- [ ] Navigate to Trials section
|
|
||||||
- [ ] Select a trial and click "Wizard Control"
|
|
||||||
- [ ] Verify WebSocket connection (green "Real-time" badge)
|
|
||||||
- [ ] Start trial and execute steps
|
|
||||||
- [ ] Monitor robot status and event log
|
|
||||||
- [ ] Complete trial and review analysis page
|
|
||||||
|
|
||||||
For additional support, refer to the complete HRIStudio documentation in the `docs/` folder.
|
#### Horizontal Step Progress Bar
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- **Visual Overview**: See all steps at a glance
|
||||||
|
- **Step States**:
|
||||||
|
- ✓ **Completed** (green checkmark, primary border)
|
||||||
|
- ● **Current** (highlighted, ring effect)
|
||||||
|
- ○ **Upcoming** (muted appearance)
|
||||||
|
- **Click Navigation**: Jump to any step (unless read-only)
|
||||||
|
- **Horizontal Scroll**: For experiments with many steps
|
||||||
|
|
||||||
|
**Step Card Elements:**
|
||||||
|
- Step number or checkmark icon
|
||||||
|
- Truncated step name (hover for full name)
|
||||||
|
- Visual state indicators
|
||||||
|
|
||||||
|
#### Current Step View
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- **Step Header**: Name and description
|
||||||
|
- **Action List**: Vertical timeline of actions
|
||||||
|
- **Action States**:
|
||||||
|
- Completed actions (checkmark)
|
||||||
|
- Active action (highlighted, pulsing)
|
||||||
|
- Pending actions (numbered)
|
||||||
|
- **Action Controls**: Run, Skip, Mark Complete buttons
|
||||||
|
- **Progress Tracking**: Auto-scrolls to active action
|
||||||
|
|
||||||
|
**Action Types:**
|
||||||
|
- **Wizard Actions**: Manual tasks for the wizard
|
||||||
|
- **Robot Actions**: Commands sent to the robot
|
||||||
|
- **Control Flow**: Loops, branches, parallel execution
|
||||||
|
- **Observations**: Data collection and recording
|
||||||
|
|
||||||
|
**Best Practices:**
|
||||||
|
- Review step description before starting
|
||||||
|
- Execute actions in order unless branching
|
||||||
|
- Use Skip sparingly and document reasons
|
||||||
|
- Verify robot action completion before proceeding
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Right Panel: Robot Control & Status
|
||||||
|
|
||||||
|
**Purpose**: Unified location for all robot-related controls and monitoring
|
||||||
|
|
||||||
|
#### Camera View
|
||||||
|
- Live video feed from robot or environment
|
||||||
|
- Multiple camera support (switchable)
|
||||||
|
- Full-screen mode available
|
||||||
|
|
||||||
|
#### Connection Status
|
||||||
|
- **ROS Bridge**: WebSocket connection state
|
||||||
|
- **Robot Status**: Online/offline indicator
|
||||||
|
- **Reconnect**: Manual reconnection button
|
||||||
|
- **Auto-reconnect**: Automatic retry on disconnect
|
||||||
|
|
||||||
|
#### Autonomous Life Toggle
|
||||||
|
- **Purpose**: Enable/disable robot's autonomous behaviors
|
||||||
|
- **States**:
|
||||||
|
- ON: Robot exhibits idle animations, breathing, awareness
|
||||||
|
- OFF: Robot remains still, fully manual control
|
||||||
|
- **Best Practice**: Turn OFF during precise interactions
|
||||||
|
|
||||||
|
#### Robot Actions Panel
|
||||||
|
- **Quick Commands**: Pre-configured robot actions
|
||||||
|
- **Parameter Controls**: Adjust action parameters
|
||||||
|
- **Execution Status**: Real-time feedback
|
||||||
|
- **Action History**: Recent commands log
|
||||||
|
|
||||||
|
#### Movement Controls
|
||||||
|
- **Directional Pad**: Manual robot navigation
|
||||||
|
- **Speed Control**: Adjust movement speed
|
||||||
|
- **Safety Limits**: Collision detection and boundaries
|
||||||
|
- **Emergency Stop**: Immediate halt
|
||||||
|
|
||||||
|
#### Quick Actions
|
||||||
|
- **Text-to-Speech**: Send custom speech commands
|
||||||
|
- **Preset Gestures**: Common robot gestures
|
||||||
|
- **LED Control**: Change robot LED colors
|
||||||
|
- **Posture Control**: Sit, stand, crouch commands
|
||||||
|
|
||||||
|
#### Status Monitoring
|
||||||
|
- **Battery Level**: Remaining charge percentage
|
||||||
|
- **Joint Status**: Motor temperatures and positions
|
||||||
|
- **Sensor Data**: Ultrasonic, tactile, IMU readings
|
||||||
|
- **Warnings**: Overheating, low battery, errors
|
||||||
|
|
||||||
|
**Best Practices:**
|
||||||
|
- Monitor battery level throughout trial
|
||||||
|
- Check connection status before robot actions
|
||||||
|
- Use Emergency Stop for safety concerns
|
||||||
|
- Document any robot malfunctions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow Guide
|
||||||
|
|
||||||
|
### Pre-Trial Setup
|
||||||
|
|
||||||
|
1. **Verify Robot Connection**
|
||||||
|
- Check ROS Bridge status (green indicator)
|
||||||
|
- Test robot responsiveness with quick action
|
||||||
|
- Confirm camera feed is visible
|
||||||
|
|
||||||
|
2. **Review Experiment Protocol**
|
||||||
|
- Scan horizontal step progress bar
|
||||||
|
- Review first step's actions
|
||||||
|
- Prepare any physical materials
|
||||||
|
|
||||||
|
3. **Configure Robot Settings** (Researchers/Admins only)
|
||||||
|
- Click Settings icon in robot panel
|
||||||
|
- Adjust speech, movement, connection parameters
|
||||||
|
- Save configuration for this study
|
||||||
|
|
||||||
|
### During Trial Execution
|
||||||
|
|
||||||
|
1. **Start Trial**
|
||||||
|
- Click "Start" in left panel
|
||||||
|
- First step becomes active
|
||||||
|
- First action highlights in timeline
|
||||||
|
|
||||||
|
2. **Execute Actions**
|
||||||
|
- Follow action sequence in center panel
|
||||||
|
- Use action controls (Run/Skip/Complete)
|
||||||
|
- Monitor robot status in right panel
|
||||||
|
- Document any deviations
|
||||||
|
|
||||||
|
3. **Navigate Steps**
|
||||||
|
- Wait for "Complete Step" button after all actions
|
||||||
|
- Click to advance to next step
|
||||||
|
- Or click step in progress bar to jump
|
||||||
|
|
||||||
|
4. **Handle Issues**
|
||||||
|
- **Participant Question**: Use Pause
|
||||||
|
- **Robot Malfunction**: Check status panel, use Emergency Stop if needed
|
||||||
|
- **Protocol Deviation**: Document in notes, continue or abort as appropriate
|
||||||
|
|
||||||
|
### Post-Trial Completion
|
||||||
|
|
||||||
|
1. **Complete Trial**
|
||||||
|
- Click "Complete Trial" after final step
|
||||||
|
- Confirm completion dialog
|
||||||
|
- Trial marked as completed
|
||||||
|
|
||||||
|
2. **Review Data**
|
||||||
|
- All actions logged with timestamps
|
||||||
|
- Robot commands recorded
|
||||||
|
- Sensor data captured
|
||||||
|
- Video recordings saved
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Control Flow Features
|
||||||
|
|
||||||
|
### Loops
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Loops execute their child actions repeatedly
|
||||||
|
- **Implicit Approval**: Wizard automatically approves each iteration
|
||||||
|
- **Manual Override**: Wizard can skip or abort loop
|
||||||
|
- **Progress Tracking**: Shows current iteration (e.g., "2 of 5")
|
||||||
|
|
||||||
|
**Best Practices:**
|
||||||
|
- Monitor participant engagement during loops
|
||||||
|
- Use abort if participant shows distress
|
||||||
|
- Document any skipped iterations
|
||||||
|
|
||||||
|
### Branches
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Conditional execution based on criteria
|
||||||
|
- Wizard selects branch path
|
||||||
|
- Only selected branch actions execute
|
||||||
|
- Other branches are skipped
|
||||||
|
|
||||||
|
**Best Practices:**
|
||||||
|
- Review branch conditions before choosing
|
||||||
|
- Document branch selection rationale
|
||||||
|
- Ensure participant meets branch criteria
|
||||||
|
|
||||||
|
### Parallel Execution
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Multiple actions execute simultaneously
|
||||||
|
- All must complete before proceeding
|
||||||
|
- Independent progress tracking
|
||||||
|
|
||||||
|
**Best Practices:**
|
||||||
|
- Monitor all parallel actions
|
||||||
|
- Be prepared for simultaneous robot and wizard tasks
|
||||||
|
- Coordinate timing carefully
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
|----------|--------|
|
||||||
|
| `Space` | Start/Pause Trial |
|
||||||
|
| `→` | Next Step |
|
||||||
|
| `Esc` | Abort Trial (with confirmation) |
|
||||||
|
| `R` | Run Current Action |
|
||||||
|
| `S` | Skip Current Action |
|
||||||
|
| `C` | Complete Current Action |
|
||||||
|
| `E` | Emergency Stop Robot |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Robot Not Responding
|
||||||
|
|
||||||
|
1. Check ROS Bridge connection (right panel)
|
||||||
|
2. Click Reconnect button
|
||||||
|
3. Verify robot is powered on
|
||||||
|
4. Check network connectivity
|
||||||
|
5. Restart ROS Bridge if needed
|
||||||
|
|
||||||
|
### Camera Feed Not Showing
|
||||||
|
|
||||||
|
1. Verify camera is enabled in robot settings
|
||||||
|
2. Check camera topic in ROS
|
||||||
|
3. Refresh browser page
|
||||||
|
4. Check camera hardware connection
|
||||||
|
|
||||||
|
### Actions Not Progressing
|
||||||
|
|
||||||
|
1. Verify action has completed
|
||||||
|
2. Check for error messages
|
||||||
|
3. Manually mark complete if stuck
|
||||||
|
4. Document issue in trial notes
|
||||||
|
|
||||||
|
### Timeline Not Updating
|
||||||
|
|
||||||
|
1. Refresh browser page
|
||||||
|
2. Check WebSocket connection
|
||||||
|
3. Verify trial status is "in_progress"
|
||||||
|
4. Contact administrator if persists
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Role-Specific Features
|
||||||
|
|
||||||
|
### Wizards
|
||||||
|
- Full trial execution control
|
||||||
|
- Action execution and skipping
|
||||||
|
- Robot control (if permitted)
|
||||||
|
- Real-time decision making
|
||||||
|
|
||||||
|
### Researchers
|
||||||
|
- All wizard features
|
||||||
|
- Robot settings configuration
|
||||||
|
- Trial monitoring and oversight
|
||||||
|
- Protocol deviation approval
|
||||||
|
|
||||||
|
### Observers
|
||||||
|
- **Read-only access**
|
||||||
|
- View trial progress
|
||||||
|
- Monitor robot status
|
||||||
|
- Add annotations (no control)
|
||||||
|
|
||||||
|
### Administrators
|
||||||
|
- All features enabled
|
||||||
|
- System configuration
|
||||||
|
- Plugin management
|
||||||
|
- Emergency overrides
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices Summary
|
||||||
|
|
||||||
|
✅ **Before Trial**
|
||||||
|
- Verify all connections
|
||||||
|
- Test robot responsiveness
|
||||||
|
- Review protocol thoroughly
|
||||||
|
|
||||||
|
✅ **During Trial**
|
||||||
|
- Follow action sequence
|
||||||
|
- Monitor robot status continuously
|
||||||
|
- Document deviations immediately
|
||||||
|
- Use Pause for breaks, not Abort
|
||||||
|
|
||||||
|
✅ **After Trial**
|
||||||
|
- Complete trial properly
|
||||||
|
- Review captured data
|
||||||
|
- Document any issues
|
||||||
|
- Debrief with participant
|
||||||
|
|
||||||
|
❌ **Avoid**
|
||||||
|
- Skipping actions without documentation
|
||||||
|
- Ignoring robot warnings
|
||||||
|
- Aborting trials unnecessarily
|
||||||
|
- Deviating from protocol without approval
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- **[Quick Reference](./quick-reference.md)** - Essential commands and shortcuts
|
||||||
|
- **[Implementation Details](./implementation-details.md)** - Technical architecture
|
||||||
|
- **[NAO6 Quick Reference](./nao6-quick-reference.md)** - Robot-specific commands
|
||||||
|
- **[Troubleshooting Guide](./nao6-integration-complete-guide.md)** - Detailed problem resolution
|
||||||
107
package.json
107
package.json
@@ -23,41 +23,42 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/drizzle-adapter": "^1.10.0",
|
"@auth/drizzle-adapter": "^1.11.1",
|
||||||
"@aws-sdk/client-s3": "^3.859.0",
|
"@aws-sdk/client-s3": "^3.989.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.859.0",
|
"@aws-sdk/s3-request-presigner": "^3.989.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-collapsible": "^1.1.11",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slider": "^1.3.5",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-switch": "^1.2.5",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@shadcn/ui": "^0.0.4",
|
"@shadcn/ui": "^0.0.4",
|
||||||
"@t3-oss/env-nextjs": "^0.13.8",
|
"@t3-oss/env-nextjs": "^0.13.10",
|
||||||
"@tanstack/react-query": "^5.69.0",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@trpc/client": "^11.0.0",
|
"@trpc/client": "^11.10.0",
|
||||||
"@trpc/react-query": "^11.0.0",
|
"@trpc/react-query": "^11.10.0",
|
||||||
"@trpc/server": "^11.0.0",
|
"@trpc/server": "^11.10.0",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -68,42 +69,44 @@
|
|||||||
"lucide-react": "^0.536.0",
|
"lucide-react": "^0.536.0",
|
||||||
"minio": "^8.0.6",
|
"minio": "^8.0.6",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-auth": "^5.0.0-beta.29",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"postgres": "^3.4.4",
|
"postgres": "^3.4.8",
|
||||||
"react": "^19.0.0",
|
"radix-ui": "^1.4.3",
|
||||||
"react-dom": "^19.0.0",
|
"react": "^19.2.4",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-day-picker": "^9.13.2",
|
||||||
"react-resizable-panels": "^3.0.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-hook-form": "^7.71.1",
|
||||||
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-webcam": "^7.2.0",
|
"react-webcam": "^7.2.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.4.0",
|
||||||
"ws": "^8.18.3",
|
"ws": "^8.19.0",
|
||||||
"zod": "^4.0.5",
|
"zod": "^4.3.6",
|
||||||
"zustand": "^4.5.5"
|
"zustand": "^4.5.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.3",
|
||||||
"@tailwindcss/postcss": "^4.0.15",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.19.33",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.2.3",
|
||||||
"drizzle-kit": "^0.30.5",
|
"drizzle-kit": "^0.30.6",
|
||||||
"eslint": "^9.23.0",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-next": "^15.2.3",
|
"eslint-config-next": "^15.5.12",
|
||||||
"eslint-plugin-drizzle": "^0.2.3",
|
"eslint-plugin-drizzle": "^0.2.3",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.8.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||||
"tailwindcss": "^4.0.15",
|
"tailwindcss": "^4.1.18",
|
||||||
"ts-unused-exports": "^11.0.1",
|
"ts-unused-exports": "^11.0.1",
|
||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.27.0"
|
"typescript-eslint": "^8.55.0"
|
||||||
},
|
},
|
||||||
"ct3aMetadata": {
|
"ct3aMetadata": {
|
||||||
"initVersion": "7.39.3"
|
"initVersion": "7.39.3"
|
||||||
|
|||||||
Submodule robot-plugins updated: d554891dab...f83a207b16
46
scripts/archive/check-db-actions.ts
Normal file
46
scripts/archive/check-db-actions.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
|
||||||
|
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 seeded actions...");
|
||||||
|
|
||||||
|
const actions = await db.query.actions.findMany({
|
||||||
|
where: (actions, { or, eq, like }) => or(
|
||||||
|
eq(actions.type, "sequence"),
|
||||||
|
eq(actions.type, "parallel"),
|
||||||
|
eq(actions.type, "loop"),
|
||||||
|
eq(actions.type, "branch"),
|
||||||
|
like(actions.type, "hristudio-core%")
|
||||||
|
),
|
||||||
|
limit: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Found ${actions.length} control actions.`);
|
||||||
|
|
||||||
|
for (const action of actions) {
|
||||||
|
console.log(`\nAction: ${action.name} (${action.type})`);
|
||||||
|
console.log(`ID: ${action.id}`);
|
||||||
|
// Explicitly log parameters to check structure
|
||||||
|
console.log("Parameters:", JSON.stringify(action.parameters, null, 2));
|
||||||
|
|
||||||
|
const params = action.parameters as any;
|
||||||
|
if (params.children) {
|
||||||
|
console.log(`✅ Has ${params.children.length} children in parameters.`);
|
||||||
|
} else if (params.trueBranch || params.falseBranch) {
|
||||||
|
console.log(`✅ Has branches in parameters.`);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ No children/branches found in parameters.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import * as schema from "../src/server/db/schema";
|
import * as schema from "../../src/server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
const connectionString = process.env.DATABASE_URL!;
|
const connectionString = process.env.DATABASE_URL!;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
import { db } from "../src/server/db";
|
import { db } from "../../src/server/db";
|
||||||
import { experiments, steps } from "../src/server/db/schema";
|
import { experiments, steps } from "../../src/server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
async function inspectAllSteps() {
|
async function inspectAllSteps() {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
import { db } from "../src/server/db";
|
import { db } from "../../src/server/db";
|
||||||
import { steps } from "../src/server/db/schema";
|
import { steps } from "../../src/server/db/schema";
|
||||||
import { eq, like } from "drizzle-orm";
|
import { eq, like } from "drizzle-orm";
|
||||||
|
|
||||||
async function checkSteps() {
|
async function checkSteps() {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
import { db } from "../src/server/db";
|
import { db } from "../../src/server/db";
|
||||||
import { experiments } from "../src/server/db/schema";
|
import { experiments } from "../../src/server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
async function inspectVisualDesign() {
|
async function inspectVisualDesign() {
|
||||||
76
scripts/archive/reproduce-hydration.ts
Normal file
76
scripts/archive/reproduce-hydration.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
|
||||||
|
import { convertDatabaseToSteps } from "../../src/lib/experiment-designer/block-converter";
|
||||||
|
import { type ExperimentStep } from "../../src/lib/experiment-designer/types";
|
||||||
|
|
||||||
|
// Mock DB Steps (simulating what experimentsRouter returns before conversion)
|
||||||
|
const mockDbSteps = [
|
||||||
|
{
|
||||||
|
id: "step-1",
|
||||||
|
name: "Step 1",
|
||||||
|
type: "wizard",
|
||||||
|
orderIndex: 0,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: "seq-1",
|
||||||
|
name: "Test Sequence",
|
||||||
|
type: "sequence",
|
||||||
|
parameters: {
|
||||||
|
children: [
|
||||||
|
{ id: "child-1", name: "Child 1", type: "wait", parameters: { duration: 1 } },
|
||||||
|
{ id: "child-2", name: "Child 2", type: "wait", parameters: { duration: 2 } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock Store Logic (simulating store.ts)
|
||||||
|
function cloneActions(actions: any[]): any[] {
|
||||||
|
return actions.map((a) => ({
|
||||||
|
...a,
|
||||||
|
children: a.children ? cloneActions(a.children) : undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneSteps(steps: any[]): any[] {
|
||||||
|
return steps.map((s) => ({
|
||||||
|
...s,
|
||||||
|
actions: cloneActions(s.actions),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🔹 Testing Hydration & Cloning...");
|
||||||
|
|
||||||
|
// 1. Convert DB -> Runtime
|
||||||
|
const runtimeSteps = convertDatabaseToSteps(mockDbSteps);
|
||||||
|
const seq = runtimeSteps[0]?.actions[0];
|
||||||
|
|
||||||
|
if (!seq) {
|
||||||
|
console.error("❌ Conversion Failed: Sequence action not found.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Runtime Children Count: ${seq.children?.length ?? "undefined"}`);
|
||||||
|
|
||||||
|
if (!seq.children || seq.children.length === 0) {
|
||||||
|
console.error("❌ Conversion Failed: Children not hydrated from parameters.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Store Cloning
|
||||||
|
const clonedSteps = cloneSteps(runtimeSteps);
|
||||||
|
const clonedSeq = clonedSteps[0]?.actions[0];
|
||||||
|
|
||||||
|
if (!clonedSeq) {
|
||||||
|
console.error("❌ Cloning Failed: Sequence action lost.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Cloned Children Count: ${clonedSeq.children?.length ?? "undefined"}`);
|
||||||
|
|
||||||
|
if (clonedSeq.children?.length === 2) {
|
||||||
|
console.log("✅ SUCCESS: Data hydrated and cloned correctly.");
|
||||||
|
} else {
|
||||||
|
console.error("❌ CLONING FAILED: Children lost during clone.");
|
||||||
|
}
|
||||||
121
scripts/archive/seed-control-demo-draft.ts
Normal file
121
scripts/archive/seed-control-demo-draft.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import postgres from "postgres";
|
||||||
|
import * as schema from "../../src/server/db/schema";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
// Database connection
|
||||||
|
const connectionString = process.env.DATABASE_URL!;
|
||||||
|
const connection = postgres(connectionString);
|
||||||
|
const db = drizzle(connection, { schema });
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("🌱 Seeding 'Control Flow Demo' experiment...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Find Admin User & Study
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: (users, { eq }) => eq(users.email, "sean@soconnor.dev")
|
||||||
|
});
|
||||||
|
if (!user) throw new Error("Admin user 'sean@soconnor.dev' not found. Run seed-dev.ts first.");
|
||||||
|
|
||||||
|
const study = await db.query.studies.findFirst({
|
||||||
|
where: (studies, { eq }) => eq(studies.name, "Comparative WoZ Study")
|
||||||
|
});
|
||||||
|
if (!study) throw new Error("Study 'Comparative WoZ Study' not found. Run seed-dev.ts first.");
|
||||||
|
|
||||||
|
// Find Robot
|
||||||
|
const robot = await db.query.robots.findFirst({
|
||||||
|
where: (robots, { eq }) => eq(robots.name, "NAO6")
|
||||||
|
});
|
||||||
|
if (!robot) throw new Error("Robot 'NAO6' not found. Run seed-dev.ts first.");
|
||||||
|
|
||||||
|
|
||||||
|
// 2. Create Experiment
|
||||||
|
const [experiment] = await db.insert(schema.experiments).values({
|
||||||
|
studyId: study.id,
|
||||||
|
name: "Control Flow Demo",
|
||||||
|
description: "Demonstration of enhanced control flow actions: Sequence, Parallel, Wait, Loop, Branch.",
|
||||||
|
version: 1,
|
||||||
|
status: "draft",
|
||||||
|
robotId: robot.id,
|
||||||
|
createdBy: user.id,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
if (!experiment) throw new Error("Failed to create experiment");
|
||||||
|
console.log(`✅ Created Experiment: ${experiment.id}`);
|
||||||
|
|
||||||
|
// 3. Create Steps
|
||||||
|
|
||||||
|
// Step 1: Sequence & Parallel
|
||||||
|
const [step1] = await db.insert(schema.steps).values({
|
||||||
|
experimentId: experiment.id,
|
||||||
|
name: "Complex Action Structures",
|
||||||
|
description: "Demonstrating Sequence and Parallel groups",
|
||||||
|
type: "robot",
|
||||||
|
orderIndex: 0,
|
||||||
|
required: true,
|
||||||
|
durationEstimate: 30
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
// Step 2: Loops & Waits
|
||||||
|
const [step2] = await db.insert(schema.steps).values({
|
||||||
|
experimentId: experiment.id,
|
||||||
|
name: "Repetition & Delays",
|
||||||
|
description: "Demonstrating Loop and Wait actions",
|
||||||
|
type: "robot",
|
||||||
|
orderIndex: 1,
|
||||||
|
required: true,
|
||||||
|
durationEstimate: 45
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
// 4. Create Actions
|
||||||
|
|
||||||
|
// --- Step 1 Actions ---
|
||||||
|
|
||||||
|
// Top-level Sequence
|
||||||
|
const seqId = `seq-${Date.now()}`;
|
||||||
|
await db.insert(schema.actions).values({
|
||||||
|
stepId: step1!.id,
|
||||||
|
name: "Introduction Sequence",
|
||||||
|
type: "sequence", // New type
|
||||||
|
orderIndex: 0,
|
||||||
|
parameters: {},
|
||||||
|
pluginId: "hristudio-core",
|
||||||
|
category: "control",
|
||||||
|
// No explicit children column in schema?
|
||||||
|
// Wait, schema.actions has "children" as jsonb or it's a recursive relationship?
|
||||||
|
// Let's check schema/types.
|
||||||
|
// Looking at ActionChip, it expects `action.children`.
|
||||||
|
// In DB, it's likely stored in `children` jsonb column if it exists, OR we need to perform recursive inserts if schema supports parentId.
|
||||||
|
// Checking `types.ts` or schema...
|
||||||
|
// Assuming flat list references for now or JSONB.
|
||||||
|
// Wait, `ExperimentAction` in types has `children?: ExperimentAction[]`.
|
||||||
|
// If the DB schema `actions` table handles nesting via `parameters` or specific column, I need to know.
|
||||||
|
// Defaulting to "children" property in JSON parameter if DB doesn't have parentId.
|
||||||
|
// Checking `schema.ts`: "children" is likely NOT a column if I haven't seen it in seed-dev.
|
||||||
|
// However, `ActionChip` uses `action.children`. Steps map to `actions`.
|
||||||
|
// If `actions` table has `parentId` or `children` JSONB.
|
||||||
|
// I will assume `children` is part of the `parameters` or a simplified representation for now,
|
||||||
|
// BUT `FlowWorkspace` treats `action.children` as real actions.
|
||||||
|
// Let's check `schema.ts` quickly.
|
||||||
|
});
|
||||||
|
|
||||||
|
// I need to check schema.actions definition effectively.
|
||||||
|
// For this pass, I will insert them as flat actions since I can't confirm nesting storage without checking schema.
|
||||||
|
// But the user WANTS to see the nesting (Sequence, Parallel).
|
||||||
|
// The `SortableActionChip` renders `action.children`.
|
||||||
|
// The `TrialExecutionEngine` executes `action.children`.
|
||||||
|
// So the data MUST include children.
|
||||||
|
// Most likely `actions` table has a `children` JSONB column.
|
||||||
|
|
||||||
|
// I will insert a Parallel action with embedded children in the `children` column (if it exists) or `parameters`.
|
||||||
|
// Re-reading `scripts/seed-dev.ts`: It doesn't show any nested actions.
|
||||||
|
// I will read `src/server/db/schema.ts` to be sure.
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// I'll write the file AFTER checking schema to ensure I structure the nested actions correctly.
|
||||||
241
scripts/archive/seed-control-demo.ts
Normal file
241
scripts/archive/seed-control-demo.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
|
||||||
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import postgres from "postgres";
|
||||||
|
import * as schema from "../../src/server/db/schema";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
// Database connection
|
||||||
|
const connectionString = process.env.DATABASE_URL!;
|
||||||
|
const connection = postgres(connectionString);
|
||||||
|
const db = drizzle(connection, { schema });
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("🌱 Seeding 'Control Flow Demo' experiment...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Find Admin User & Study
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: (users, { eq }) => eq(users.email, "sean@soconnor.dev")
|
||||||
|
});
|
||||||
|
if (!user) throw new Error("Admin user 'sean@soconnor.dev' not found. Run seed-dev.ts first.");
|
||||||
|
|
||||||
|
const study = await db.query.studies.findFirst({
|
||||||
|
where: (studies, { eq }) => eq(studies.name, "Comparative WoZ Study")
|
||||||
|
});
|
||||||
|
if (!study) throw new Error("Study 'Comparative WoZ Study' not found. Run seed-dev.ts first.");
|
||||||
|
|
||||||
|
// Find Robot
|
||||||
|
const robot = await db.query.robots.findFirst({
|
||||||
|
where: (robots, { eq }) => eq(robots.name, "NAO6")
|
||||||
|
});
|
||||||
|
if (!robot) throw new Error("Robot 'NAO6' not found. Run seed-dev.ts first.");
|
||||||
|
|
||||||
|
|
||||||
|
// 2. Create Experiment
|
||||||
|
const [experiment] = await db.insert(schema.experiments).values({
|
||||||
|
studyId: study.id,
|
||||||
|
name: "Control Flow Demo",
|
||||||
|
description: "Demonstration of enhanced control flow actions: Sequence, Parallel, Wait, Loop, Branch.",
|
||||||
|
version: 1,
|
||||||
|
status: "draft",
|
||||||
|
robotId: robot.id,
|
||||||
|
createdBy: user.id,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
if (!experiment) throw new Error("Failed to create experiment");
|
||||||
|
console.log(`✅ Created Experiment: ${experiment.id}`);
|
||||||
|
|
||||||
|
// 3. Create Steps
|
||||||
|
|
||||||
|
// Step 1: Sequence & Parallel
|
||||||
|
const [step1] = await db.insert(schema.steps).values({
|
||||||
|
experimentId: experiment.id,
|
||||||
|
name: "Complex Action Structures",
|
||||||
|
description: "Demonstrating Sequence and Parallel groups",
|
||||||
|
type: "robot",
|
||||||
|
orderIndex: 0,
|
||||||
|
required: true,
|
||||||
|
durationEstimate: 30
|
||||||
|
}).returning();
|
||||||
|
if (!step1) throw new Error("Failed to create step1");
|
||||||
|
|
||||||
|
// Step 2: Loops & Waits
|
||||||
|
const [step2] = await db.insert(schema.steps).values({
|
||||||
|
experimentId: experiment.id,
|
||||||
|
name: "Repetition & Delays",
|
||||||
|
description: "Demonstrating Loop and Wait actions",
|
||||||
|
type: "robot",
|
||||||
|
orderIndex: 1,
|
||||||
|
required: true,
|
||||||
|
durationEstimate: 45
|
||||||
|
}).returning();
|
||||||
|
if (!step2) throw new Error("Failed to create step2");
|
||||||
|
|
||||||
|
// 4. Create Actions
|
||||||
|
|
||||||
|
// --- Step 1 Actions ---
|
||||||
|
|
||||||
|
// Action 1: Sequence
|
||||||
|
// Note: Nested children are stored in 'children' property of the action object in frontend,
|
||||||
|
// but in DB 'parameters' is the JSONB field.
|
||||||
|
// However, looking at ActionChip, it expects `action.children`.
|
||||||
|
// The `ExperimentAction` type usually has `children` at top level.
|
||||||
|
// If the DB doesn't have it, the API must be hydrating it.
|
||||||
|
// BUT, for the purpose of this seed which writes to DB directly, I will put it in `parameters.children`
|
||||||
|
// and assume the frontend/API handles it or I'm missing a column.
|
||||||
|
// Actually, looking at schema again, `actions` table DOES NOT have children.
|
||||||
|
// So it MUST be in `parameters` or it's not persisted in this table structure yet (which would be a bug, but I'm seeding what exists).
|
||||||
|
// Wait, if I put it in parameters, does the UI read it?
|
||||||
|
// `ActionChip` reads `action.children`.
|
||||||
|
// I will try to put it in `parameters` and distinct `children` property in the JSON passed to `parameters`?
|
||||||
|
// No, `parameters` is jsonb.
|
||||||
|
// I will assume for now that the system expects it in parameters if it's not a column, OR it's not fully supported in DB yet.
|
||||||
|
// I will stick to what the UI likely consumes. `parameters: { children: [...] }`
|
||||||
|
|
||||||
|
// Sequence
|
||||||
|
await db.insert(schema.actions).values({
|
||||||
|
stepId: step1.id,
|
||||||
|
name: "Introduction Sequence",
|
||||||
|
type: "sequence",
|
||||||
|
orderIndex: 0,
|
||||||
|
// Embedding children here to demonstrate.
|
||||||
|
// Real implementation might vary if keys are strictly checked.
|
||||||
|
parameters: {
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: uuidv4(),
|
||||||
|
name: "Say Hello",
|
||||||
|
type: "nao6-ros2.say_text",
|
||||||
|
parameters: { text: "Hello there!" },
|
||||||
|
category: "interaction"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: uuidv4(),
|
||||||
|
name: "Wave Hand",
|
||||||
|
type: "nao6-ros2.move_arm",
|
||||||
|
parameters: { arm: "right", action: "wave" },
|
||||||
|
category: "movement"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
pluginId: "hristudio-core",
|
||||||
|
category: "control",
|
||||||
|
sourceKind: "core"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parallel
|
||||||
|
await db.insert(schema.actions).values({
|
||||||
|
stepId: step1.id,
|
||||||
|
name: "Parallel Actions",
|
||||||
|
type: "parallel",
|
||||||
|
orderIndex: 1,
|
||||||
|
parameters: {
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: uuidv4(),
|
||||||
|
name: "Say 'Moving'",
|
||||||
|
type: "nao6-ros2.say_text",
|
||||||
|
parameters: { text: "I am moving and talking." },
|
||||||
|
category: "interaction"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: uuidv4(),
|
||||||
|
name: "Walk Forward",
|
||||||
|
type: "nao6-ros2.move_to",
|
||||||
|
parameters: { x: 0.5, y: 0 },
|
||||||
|
category: "movement"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
pluginId: "hristudio-core",
|
||||||
|
category: "control",
|
||||||
|
sourceKind: "core"
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- Step 2 Actions ---
|
||||||
|
|
||||||
|
// Loop
|
||||||
|
await db.insert(schema.actions).values({
|
||||||
|
stepId: step2.id,
|
||||||
|
name: "Repeat Message",
|
||||||
|
type: "loop",
|
||||||
|
orderIndex: 0,
|
||||||
|
parameters: {
|
||||||
|
iterations: 3,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: uuidv4(),
|
||||||
|
name: "Say 'Echo'",
|
||||||
|
type: "nao6-ros2.say_text",
|
||||||
|
parameters: { text: "Echo" },
|
||||||
|
category: "interaction"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
pluginId: "hristudio-core",
|
||||||
|
category: "control",
|
||||||
|
sourceKind: "core"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait
|
||||||
|
await db.insert(schema.actions).values({
|
||||||
|
stepId: step2.id,
|
||||||
|
name: "Wait 5 Seconds",
|
||||||
|
type: "wait",
|
||||||
|
orderIndex: 1,
|
||||||
|
parameters: { duration: 5 },
|
||||||
|
pluginId: "hristudio-core",
|
||||||
|
category: "control",
|
||||||
|
sourceKind: "core"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Branch (Controls step routing, not nested actions)
|
||||||
|
// Note: Branch configuration is stored in step.trigger.conditions, not action.parameters
|
||||||
|
// The branch action itself is just a marker that this step has conditional routing
|
||||||
|
await db.insert(schema.actions).values({
|
||||||
|
stepId: step2.id,
|
||||||
|
name: "Conditional Routing",
|
||||||
|
type: "branch",
|
||||||
|
orderIndex: 2,
|
||||||
|
parameters: {
|
||||||
|
// Branch actions don't have nested children
|
||||||
|
// Routing is configured at the step level via trigger.conditions
|
||||||
|
},
|
||||||
|
pluginId: "hristudio-core",
|
||||||
|
category: "control",
|
||||||
|
sourceKind: "core"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update step2 to have conditional routing
|
||||||
|
await db.update(schema.steps)
|
||||||
|
.set({
|
||||||
|
type: "conditional",
|
||||||
|
conditions: {
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "High Score Path",
|
||||||
|
nextStepIndex: 2, // Would go to a hypothetical step 3
|
||||||
|
variant: "default"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Low Score Path",
|
||||||
|
nextStepIndex: 0, // Loop back to step 1
|
||||||
|
variant: "outline"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.where(sql`id = ${step2.id}`);
|
||||||
|
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await connection.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
60
scripts/archive/test-converter.ts
Normal file
60
scripts/archive/test-converter.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
|
||||||
|
import { convertDatabaseToAction } from "../../src/lib/experiment-designer/block-converter";
|
||||||
|
|
||||||
|
const mockDbAction = {
|
||||||
|
id: "eaf8f85b-75cf-4973-b436-092516b4e0e4",
|
||||||
|
name: "Introduction Sequence",
|
||||||
|
description: null,
|
||||||
|
type: "sequence",
|
||||||
|
orderIndex: 0,
|
||||||
|
parameters: {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "75018b01-a964-41fb-8612-940a29020d4a",
|
||||||
|
"name": "Say Hello",
|
||||||
|
"type": "nao6-ros2.say_text",
|
||||||
|
"category": "interaction",
|
||||||
|
"parameters": {
|
||||||
|
"text": "Hello there!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d7020530-6477-41f3-84a4-5141778c93da",
|
||||||
|
"name": "Wave Hand",
|
||||||
|
"type": "nao6-ros2.move_arm",
|
||||||
|
"category": "movement",
|
||||||
|
"parameters": {
|
||||||
|
"arm": "right",
|
||||||
|
"action": "wave"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
timeout: null,
|
||||||
|
retryCount: 0,
|
||||||
|
sourceKind: "core",
|
||||||
|
pluginId: "hristudio-core",
|
||||||
|
pluginVersion: null,
|
||||||
|
robotId: null,
|
||||||
|
baseActionId: null,
|
||||||
|
category: "control",
|
||||||
|
transport: null,
|
||||||
|
ros2: null,
|
||||||
|
rest: null,
|
||||||
|
retryable: null,
|
||||||
|
parameterSchemaRaw: null
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Testing convertDatabaseToAction...");
|
||||||
|
try {
|
||||||
|
const result = convertDatabaseToAction(mockDbAction);
|
||||||
|
console.log("Result:", JSON.stringify(result, null, 2));
|
||||||
|
|
||||||
|
if (result.children && result.children.length > 0) {
|
||||||
|
console.log("✅ Children hydrated successfully.");
|
||||||
|
} else {
|
||||||
|
console.error("❌ Children NOT hydrated.");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("❌ Error during conversion:", e);
|
||||||
|
}
|
||||||
71
scripts/archive/test-trpc-client.ts
Normal file
71
scripts/archive/test-trpc-client.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
|
||||||
|
import { appRouter } from "../../src/server/api/root";
|
||||||
|
import { createCallerFactory } from "../../src/server/api/trpc";
|
||||||
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import postgres from "postgres";
|
||||||
|
import * as schema from "../../src/server/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
// 1. Setup DB Context
|
||||||
|
const connectionString = process.env.DATABASE_URL!;
|
||||||
|
const connection = postgres(connectionString);
|
||||||
|
const db = drizzle(connection, { schema });
|
||||||
|
|
||||||
|
// 2. Mock Session
|
||||||
|
const mockSession = {
|
||||||
|
user: {
|
||||||
|
id: "0e830889-ab46-4b48-a8ba-1d4bd3e665ed", // Admin user ID from seed
|
||||||
|
name: "Sean O'Connor",
|
||||||
|
email: "sean@soconnor.dev"
|
||||||
|
},
|
||||||
|
expires: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Create Caller
|
||||||
|
const createCaller = createCallerFactory(appRouter);
|
||||||
|
const caller = createCaller({
|
||||||
|
db,
|
||||||
|
session: mockSession as any,
|
||||||
|
headers: new Headers()
|
||||||
|
});
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("🔍 Fetching experiment via TRPC caller...");
|
||||||
|
|
||||||
|
// Get ID first
|
||||||
|
const exp = await db.query.experiments.findFirst({
|
||||||
|
where: eq(schema.experiments.name, "Control Flow Demo"),
|
||||||
|
columns: { id: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!exp) {
|
||||||
|
console.error("❌ Experiment not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await caller.experiments.get({ id: exp.id });
|
||||||
|
|
||||||
|
console.log(`✅ Fetched experiment: ${result.name} (${result.id})`);
|
||||||
|
|
||||||
|
if (result.steps && result.steps.length > 0) {
|
||||||
|
console.log(`Checking ${result.steps.length} steps...`);
|
||||||
|
const actions = result.steps[0]!.actions; // Step 1 actions
|
||||||
|
console.log(`Step 1 has ${actions.length} actions.`);
|
||||||
|
|
||||||
|
actions.forEach(a => {
|
||||||
|
if (["sequence", "parallel", "loop", "branch"].includes(a.type)) {
|
||||||
|
console.log(`\nAction: ${a.name} (${a.type})`);
|
||||||
|
console.log(`Children Count: ${a.children ? a.children.length : 'UNDEFINED'}`);
|
||||||
|
if (a.children && a.children.length > 0) {
|
||||||
|
console.log(`First Child: ${a.children[0]!.name} (${a.children[0]!.type})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error("❌ No steps found in result.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
|
|
||||||
import { db } from "../src/server/db";
|
import { db } from "../../src/server/db";
|
||||||
import { experiments } from "../src/server/db/schema";
|
import { experiments } from "../../src/server/db/schema";
|
||||||
import { eq, asc } from "drizzle-orm";
|
import { eq, asc } from "drizzle-orm";
|
||||||
import { convertDatabaseToSteps } from "../src/lib/experiment-designer/block-converter";
|
import { convertDatabaseToSteps } from "../../src/lib/experiment-designer/block-converter";
|
||||||
|
|
||||||
async function verifyConversion() {
|
async function verifyConversion() {
|
||||||
const experiment = await db.query.experiments.findFirst({
|
const experiment = await db.query.experiments.findFirst({
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import * as schema from "../src/server/db/schema";
|
import * as schema from "../../src/server/db/schema";
|
||||||
|
|
||||||
const connectionString = process.env.DATABASE_URL!;
|
const connectionString = process.env.DATABASE_URL!;
|
||||||
const client = postgres(connectionString);
|
const client = postgres(connectionString);
|
||||||
26
scripts/get-demo-id.ts
Normal file
26
scripts/get-demo-id.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
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() {
|
||||||
|
const exp = await db.query.experiments.findFirst({
|
||||||
|
where: eq(schema.experiments.name, "Control Flow Demo"),
|
||||||
|
columns: { id: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (exp) {
|
||||||
|
console.log(`Experiment ID: ${exp.id}`);
|
||||||
|
} else {
|
||||||
|
console.error("Experiment not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
26
scripts/get-user-id.ts
Normal file
26
scripts/get-user-id.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
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() {
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(schema.users.email, "sean@soconnor.dev"),
|
||||||
|
columns: { id: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
console.log(`User ID: ${user.id}`);
|
||||||
|
} else {
|
||||||
|
console.error("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -3,7 +3,7 @@ import { drizzle } from "drizzle-orm/postgres-js";
|
|||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import * as schema from "../src/server/db/schema";
|
import * as schema from "../src/server/db/schema";
|
||||||
import { createHash } from "crypto";
|
import { createHash, randomUUID } from "crypto";
|
||||||
|
|
||||||
// Database connection
|
// Database connection
|
||||||
const connectionString = process.env.DATABASE_URL!;
|
const connectionString = process.env.DATABASE_URL!;
|
||||||
@@ -234,18 +234,18 @@ async function main() {
|
|||||||
await db.insert(schema.actions).values([
|
await db.insert(schema.actions).values([
|
||||||
{
|
{
|
||||||
stepId: step1!.id,
|
stepId: step1!.id,
|
||||||
name: "Introduce Story",
|
name: "Say Text",
|
||||||
type: "nao6-ros2.say_text",
|
type: "nao6-ros2.say_text",
|
||||||
orderIndex: 0,
|
orderIndex: 0,
|
||||||
parameters: { text: "Hello. I have a story to tell you about a space traveler. Are you ready?" },
|
parameters: { text: "Hello. I have a story to tell you about a space traveler. Are you ready?" },
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.1.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "interaction",
|
category: "interaction",
|
||||||
retryable: true
|
retryable: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stepId: step1!.id,
|
stepId: step1!.id,
|
||||||
name: "Welcome Gesture",
|
name: "Move Arm",
|
||||||
type: "nao6-ros2.move_arm",
|
type: "nao6-ros2.move_arm",
|
||||||
orderIndex: 1,
|
orderIndex: 1,
|
||||||
// Open hand/welcome position
|
// Open hand/welcome position
|
||||||
@@ -258,7 +258,7 @@ async function main() {
|
|||||||
speed: 0.4
|
speed: 0.4
|
||||||
},
|
},
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.1.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "movement",
|
category: "movement",
|
||||||
retryable: true
|
retryable: true
|
||||||
}
|
}
|
||||||
@@ -283,29 +283,29 @@ async function main() {
|
|||||||
orderIndex: 0,
|
orderIndex: 0,
|
||||||
parameters: { text: "The traveler flew to Mars. He found a red rock that glowed in the dark. He put it in his pocket." },
|
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",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.1.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "interaction",
|
category: "interaction",
|
||||||
retryable: true
|
retryable: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stepId: step2!.id,
|
stepId: step2!.id,
|
||||||
name: "Look Away (Thinking)",
|
name: "Turn Head",
|
||||||
type: "nao6-ros2.turn_head",
|
type: "nao6-ros2.turn_head",
|
||||||
orderIndex: 1,
|
orderIndex: 1,
|
||||||
parameters: { yaw: 1.5, pitch: 0.0, speed: 0.3 },
|
parameters: { yaw: 1.5, pitch: 0.0, speed: 0.3 },
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.1.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "movement",
|
category: "movement",
|
||||||
retryable: true
|
retryable: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stepId: step2!.id,
|
stepId: step2!.id,
|
||||||
name: "Look Back at Participant",
|
name: "Turn Head",
|
||||||
type: "nao6-ros2.turn_head",
|
type: "nao6-ros2.turn_head",
|
||||||
orderIndex: 2,
|
orderIndex: 2,
|
||||||
parameters: { yaw: 0.0, pitch: -0.1, speed: 0.4 },
|
parameters: { yaw: 0.0, pitch: -0.1, speed: 0.4 },
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.1.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "movement",
|
category: "movement",
|
||||||
retryable: true
|
retryable: true
|
||||||
}
|
}
|
||||||
@@ -359,12 +359,12 @@ async function main() {
|
|||||||
await db.insert(schema.actions).values([
|
await db.insert(schema.actions).values([
|
||||||
{
|
{
|
||||||
stepId: step3!.id,
|
stepId: step3!.id,
|
||||||
name: "Ask Question",
|
name: "Say Text",
|
||||||
type: "nao6-ros2.say_text",
|
type: "nao6-ros2.say_text",
|
||||||
orderIndex: 0,
|
orderIndex: 0,
|
||||||
parameters: { text: "What color was the rock the traveler found?" },
|
parameters: { text: "What color was the rock the traveler found?" },
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.1.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "interaction",
|
category: "interaction",
|
||||||
retryable: true
|
retryable: true
|
||||||
},
|
},
|
||||||
@@ -397,34 +397,34 @@ async function main() {
|
|||||||
await db.insert(schema.actions).values([
|
await db.insert(schema.actions).values([
|
||||||
{
|
{
|
||||||
stepId: step4a!.id,
|
stepId: step4a!.id,
|
||||||
name: "Confirm Correct Answer",
|
name: "Say Text with Emotion",
|
||||||
type: "nao6-ros2.say_with_emotion",
|
type: "nao6-ros2.say_with_emotion",
|
||||||
orderIndex: 0,
|
orderIndex: 0,
|
||||||
parameters: { text: "Yes! It was a glowing red rock.", emotion: "happy", speed: 1.0 },
|
parameters: { text: "Yes! It was a glowing red rock.", emotion: "happy", speed: 1.0 },
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.1.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "interaction",
|
category: "interaction",
|
||||||
retryable: true
|
retryable: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stepId: step4a!.id,
|
stepId: step4a!.id,
|
||||||
name: "Nod Head",
|
name: "Turn Head",
|
||||||
type: "nao6-ros2.turn_head",
|
type: "nao6-ros2.turn_head",
|
||||||
orderIndex: 1,
|
orderIndex: 1,
|
||||||
parameters: { yaw: 0.0, pitch: -0.3, speed: 0.5 },
|
parameters: { yaw: 0.0, pitch: -0.3, speed: 0.5 },
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.1.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "movement",
|
category: "movement",
|
||||||
retryable: true
|
retryable: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stepId: step4a!.id,
|
stepId: step4a!.id,
|
||||||
name: "Return to Neutral",
|
name: "Turn Head",
|
||||||
type: "nao6-ros2.turn_head",
|
type: "nao6-ros2.turn_head",
|
||||||
orderIndex: 2,
|
orderIndex: 2,
|
||||||
parameters: { yaw: 0.0, pitch: 0.0, speed: 0.4 },
|
parameters: { yaw: 0.0, pitch: 0.0, speed: 0.4 },
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.1.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "movement",
|
category: "movement",
|
||||||
retryable: true
|
retryable: true
|
||||||
}
|
}
|
||||||
@@ -440,40 +440,40 @@ async function main() {
|
|||||||
orderIndex: 0,
|
orderIndex: 0,
|
||||||
parameters: { text: "Actually, it was red." },
|
parameters: { text: "Actually, it was red." },
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.1.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "interaction",
|
category: "interaction",
|
||||||
retryable: true
|
retryable: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stepId: step4b!.id,
|
stepId: step4b!.id,
|
||||||
name: "Shake Head (Left)",
|
name: "Turn Head",
|
||||||
type: "nao6-ros2.turn_head",
|
type: "nao6-ros2.turn_head",
|
||||||
orderIndex: 1,
|
orderIndex: 1,
|
||||||
parameters: { yaw: -0.5, pitch: 0.0, speed: 0.5 },
|
parameters: { yaw: -0.5, pitch: 0.0, speed: 0.5 },
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.1.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "movement",
|
category: "movement",
|
||||||
retryable: true
|
retryable: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stepId: step4b!.id,
|
stepId: step4b!.id,
|
||||||
name: "Shake Head (Right)",
|
name: "Turn Head",
|
||||||
type: "nao6-ros2.turn_head",
|
type: "nao6-ros2.turn_head",
|
||||||
orderIndex: 2,
|
orderIndex: 2,
|
||||||
parameters: { yaw: 0.5, pitch: 0.0, speed: 0.5 },
|
parameters: { yaw: 0.5, pitch: 0.0, speed: 0.5 },
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.1.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "movement",
|
category: "movement",
|
||||||
retryable: true
|
retryable: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stepId: step4b!.id,
|
stepId: step4b!.id,
|
||||||
name: "Return to Center",
|
name: "Turn Head",
|
||||||
type: "nao6-ros2.turn_head",
|
type: "nao6-ros2.turn_head",
|
||||||
orderIndex: 3,
|
orderIndex: 3,
|
||||||
parameters: { yaw: 0.0, pitch: 0.0, speed: 0.4 },
|
parameters: { yaw: 0.0, pitch: 0.0, speed: 0.4 },
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.1.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "movement",
|
category: "movement",
|
||||||
retryable: true
|
retryable: true
|
||||||
}
|
}
|
||||||
@@ -498,7 +498,7 @@ async function main() {
|
|||||||
orderIndex: 0,
|
orderIndex: 0,
|
||||||
parameters: { text: "The End. Thank you for listening." },
|
parameters: { text: "The End. Thank you for listening." },
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.1.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "interaction",
|
category: "interaction",
|
||||||
retryable: true
|
retryable: true
|
||||||
},
|
},
|
||||||
@@ -516,12 +516,209 @@ async function main() {
|
|||||||
speed: 0.3
|
speed: 0.3
|
||||||
},
|
},
|
||||||
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
pluginVersion: "2.1.0",
|
pluginVersion: "2.2.0",
|
||||||
category: "movement",
|
category: "movement",
|
||||||
retryable: true
|
retryable: true
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 5b. Create "Control Flow Demo" Experiment
|
||||||
|
console.log("🧩 Creating 'Control Flow Demo' experiment...");
|
||||||
|
const [controlDemoExp] = await db.insert(schema.experiments).values({
|
||||||
|
studyId: study!.id,
|
||||||
|
name: "Control Flow Demo",
|
||||||
|
description: "Demonstration of enhanced control flow actions: Parallel, Wait, Loop, Branch.",
|
||||||
|
version: 2,
|
||||||
|
status: "draft",
|
||||||
|
robotId: naoRobot!.id,
|
||||||
|
createdBy: adminUser.id,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
// Step 1: Introduction (Parallel)
|
||||||
|
const [cdStep1] = await db.insert(schema.steps).values({
|
||||||
|
experimentId: controlDemoExp!.id,
|
||||||
|
name: "1. Introduction (Parallel)",
|
||||||
|
description: "Parallel execution demonstration",
|
||||||
|
type: "robot",
|
||||||
|
orderIndex: 0,
|
||||||
|
required: true,
|
||||||
|
durationEstimate: 30
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
// Step 5: Conclusion - Defined early for ID reference (Convergence point)
|
||||||
|
const [cdStep5] = await db.insert(schema.steps).values({
|
||||||
|
experimentId: controlDemoExp!.id,
|
||||||
|
name: "5. Conclusion",
|
||||||
|
description: "Convergence point",
|
||||||
|
type: "robot",
|
||||||
|
orderIndex: 4,
|
||||||
|
required: true,
|
||||||
|
durationEstimate: 15
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
// Step 4: Path B (Wait) - Defined early for ID reference
|
||||||
|
const [cdStep4] = await db.insert(schema.steps).values({
|
||||||
|
experimentId: controlDemoExp!.id,
|
||||||
|
name: "4. Path B (Wait)",
|
||||||
|
description: "Wait action demonstration",
|
||||||
|
type: "robot",
|
||||||
|
orderIndex: 3,
|
||||||
|
required: true,
|
||||||
|
durationEstimate: 10
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
// Step 3: Path A (Loop) - Defined early for ID reference
|
||||||
|
const [cdStep3] = await db.insert(schema.steps).values({
|
||||||
|
experimentId: controlDemoExp!.id,
|
||||||
|
name: "3. Path A (Loop)",
|
||||||
|
description: "Looping demonstration",
|
||||||
|
type: "robot",
|
||||||
|
orderIndex: 2,
|
||||||
|
required: true,
|
||||||
|
durationEstimate: 45,
|
||||||
|
conditions: { nextStepId: cdStep5!.id }
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
// Step 2: Branch Decision
|
||||||
|
const [cdStep2] = await db.insert(schema.steps).values({
|
||||||
|
experimentId: controlDemoExp!.id,
|
||||||
|
name: "2. Branch Decision",
|
||||||
|
description: "Choose between Loop (3) or Wait (4)",
|
||||||
|
type: "conditional",
|
||||||
|
orderIndex: 1,
|
||||||
|
required: true,
|
||||||
|
durationEstimate: 30,
|
||||||
|
conditions: {
|
||||||
|
variable: "demo_branch_choice",
|
||||||
|
options: [
|
||||||
|
{ label: "Go to Loop (Step 3)", value: "loop", nextStepId: cdStep3!.id, variant: "default" },
|
||||||
|
{ label: "Go to Wait (Step 4)", value: "wait", nextStepId: cdStep4!.id, variant: "secondary" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
// --- Step 1 Actions (Parallel) ---
|
||||||
|
await db.insert(schema.actions).values({
|
||||||
|
stepId: cdStep1!.id,
|
||||||
|
name: "Parallel Intro",
|
||||||
|
type: "parallel",
|
||||||
|
orderIndex: 0,
|
||||||
|
parameters: {
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
name: "Say Text",
|
||||||
|
type: "nao6-ros2.say_text",
|
||||||
|
parameters: { text: "Starting control flow demonstration." },
|
||||||
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
|
pluginVersion: "2.2.0",
|
||||||
|
category: "interaction"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
name: "Move Arm",
|
||||||
|
type: "nao6-ros2.move_arm",
|
||||||
|
parameters: { arm: "right", shoulder_roll: -0.5 },
|
||||||
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
|
pluginVersion: "2.2.0",
|
||||||
|
category: "movement"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
pluginId: "hristudio-core",
|
||||||
|
category: "control",
|
||||||
|
sourceKind: "core"
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Step 2 Actions (Branch) ---
|
||||||
|
await db.insert(schema.actions).values([
|
||||||
|
{
|
||||||
|
stepId: cdStep2!.id,
|
||||||
|
name: "Say Text",
|
||||||
|
type: "nao6-ros2.say_text",
|
||||||
|
orderIndex: 0,
|
||||||
|
parameters: { text: "Should I loop or wait?" },
|
||||||
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
|
pluginVersion: "2.2.0",
|
||||||
|
category: "interaction"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stepId: cdStep2!.id,
|
||||||
|
name: "Wizard Decision",
|
||||||
|
type: "wizard_wait_for_response",
|
||||||
|
orderIndex: 1,
|
||||||
|
parameters: {
|
||||||
|
prompt_text: "Choose the next path:",
|
||||||
|
options: [
|
||||||
|
{ label: "Loop Path", value: "loop", nextStepId: cdStep3!.id },
|
||||||
|
{ label: "Wait Path", value: "wait", nextStepId: cdStep4!.id }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
pluginId: "hristudio-woz",
|
||||||
|
category: "wizard",
|
||||||
|
sourceKind: "core"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stepId: cdStep2!.id,
|
||||||
|
name: "Execute Branch",
|
||||||
|
type: "branch",
|
||||||
|
orderIndex: 2,
|
||||||
|
parameters: {},
|
||||||
|
pluginId: "hristudio-core",
|
||||||
|
category: "control",
|
||||||
|
sourceKind: "core"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
// --- Step 3 Actions (Loop) ---
|
||||||
|
await db.insert(schema.actions).values({
|
||||||
|
stepId: cdStep3!.id,
|
||||||
|
name: "Loop 3 Times",
|
||||||
|
type: "loop",
|
||||||
|
orderIndex: 0,
|
||||||
|
parameters: {
|
||||||
|
iterations: 3,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
name: "Say Text",
|
||||||
|
type: "nao6-ros2.say_text",
|
||||||
|
parameters: { text: "I am looping." },
|
||||||
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
|
pluginVersion: "2.2.0",
|
||||||
|
category: "interaction"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
pluginId: "hristudio-core",
|
||||||
|
category: "control",
|
||||||
|
sourceKind: "core"
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Step 4 Actions (Wait) ---
|
||||||
|
await db.insert(schema.actions).values({
|
||||||
|
stepId: cdStep4!.id,
|
||||||
|
name: "Wait 3 Seconds",
|
||||||
|
type: "wait",
|
||||||
|
orderIndex: 0,
|
||||||
|
parameters: { duration: 3 },
|
||||||
|
pluginId: "hristudio-core",
|
||||||
|
category: "control",
|
||||||
|
sourceKind: "core"
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Step 5 Actions (Conclusion) ---
|
||||||
|
await db.insert(schema.actions).values({
|
||||||
|
stepId: cdStep5!.id,
|
||||||
|
name: "Say Text",
|
||||||
|
type: "nao6-ros2.say_text",
|
||||||
|
orderIndex: 0,
|
||||||
|
parameters: { text: "Demonstration complete. Returning to start." },
|
||||||
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
|
pluginVersion: "2.2.0",
|
||||||
|
category: "interaction"
|
||||||
|
});
|
||||||
|
|
||||||
// 6. Participants (N=20 for study)
|
// 6. Participants (N=20 for study)
|
||||||
console.log("👤 Creating 20 participants for N=20 study...");
|
console.log("👤 Creating 20 participants for N=20 study...");
|
||||||
const participants = [];
|
const participants = [];
|
||||||
@@ -550,6 +747,198 @@ async function main() {
|
|||||||
console.log(` - Step 5: Conclusion (ending + bow)`);
|
console.log(` - Step 5: Conclusion (ending + bow)`);
|
||||||
console.log(`- ${insertedParticipants.length} Participants`);
|
console.log(`- ${insertedParticipants.length} Participants`);
|
||||||
|
|
||||||
|
// 7. Seed a COMPLETED trial with rich analytics data for testing
|
||||||
|
console.log("📊 Seeding completed trial with analytics data...");
|
||||||
|
|
||||||
|
// Pick participant P101
|
||||||
|
const p101 = insertedParticipants.find(p => p.participantCode === "P101");
|
||||||
|
if (!p101) throw new Error("P101 not found");
|
||||||
|
|
||||||
|
const startTime = new Date();
|
||||||
|
startTime.setMinutes(startTime.getMinutes() - 10); // Started 10 mins ago
|
||||||
|
const endTime = new Date(); // Ended just now
|
||||||
|
|
||||||
|
// Create the trial
|
||||||
|
const [analyticsTrial] = await db.insert(schema.trials).values({
|
||||||
|
experimentId: experiment!.id,
|
||||||
|
participantId: p101.id,
|
||||||
|
// studyId is not in trials table, it is inferred from experiment
|
||||||
|
status: "completed",
|
||||||
|
startedAt: startTime,
|
||||||
|
completedAt: endTime,
|
||||||
|
currentStepId: step5!.id, // Ended at last step
|
||||||
|
runId: randomUUID(),
|
||||||
|
metadata: {
|
||||||
|
condition: "HRIStudio",
|
||||||
|
notes: "Seeded for analytics testing"
|
||||||
|
}
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
// Create a series of events
|
||||||
|
const timelineEvents = [];
|
||||||
|
let currentTime = new Date(startTime);
|
||||||
|
|
||||||
|
// Helper to advance time
|
||||||
|
const advance = (seconds: number) => {
|
||||||
|
currentTime = new Date(currentTime.getTime() + seconds * 1000);
|
||||||
|
return currentTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Trial Started
|
||||||
|
timelineEvents.push({
|
||||||
|
trialId: analyticsTrial!.id,
|
||||||
|
eventType: "trial_started",
|
||||||
|
timestamp: new Date(currentTime),
|
||||||
|
data: { experimentId: experiment!.id, participantId: p101.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Step 1: The Hook
|
||||||
|
advance(2);
|
||||||
|
timelineEvents.push({
|
||||||
|
trialId: analyticsTrial!.id,
|
||||||
|
eventType: "step_changed",
|
||||||
|
timestamp: new Date(currentTime),
|
||||||
|
data: { stepId: step1!.id, stepName: "The Hook" }
|
||||||
|
});
|
||||||
|
|
||||||
|
advance(1);
|
||||||
|
timelineEvents.push({
|
||||||
|
trialId: analyticsTrial!.id,
|
||||||
|
eventType: "action_executed",
|
||||||
|
timestamp: new Date(currentTime),
|
||||||
|
data: { actionName: "Say Text", text: "Hello..." }
|
||||||
|
});
|
||||||
|
|
||||||
|
advance(5);
|
||||||
|
timelineEvents.push({
|
||||||
|
trialId: analyticsTrial!.id,
|
||||||
|
eventType: "action_executed",
|
||||||
|
timestamp: new Date(currentTime),
|
||||||
|
data: { actionName: "Move Arm", arm: "right" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Step 2: The Narrative
|
||||||
|
advance(20);
|
||||||
|
timelineEvents.push({
|
||||||
|
trialId: analyticsTrial!.id,
|
||||||
|
eventType: "step_changed",
|
||||||
|
timestamp: new Date(currentTime),
|
||||||
|
data: { stepId: step2!.id, stepName: "The Narrative" }
|
||||||
|
});
|
||||||
|
|
||||||
|
advance(2);
|
||||||
|
timelineEvents.push({
|
||||||
|
trialId: analyticsTrial!.id,
|
||||||
|
eventType: "action_executed",
|
||||||
|
timestamp: new Date(currentTime),
|
||||||
|
data: { actionName: "Tell Story" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate an intervention/wizard action
|
||||||
|
advance(15);
|
||||||
|
timelineEvents.push({
|
||||||
|
trialId: analyticsTrial!.id,
|
||||||
|
eventType: "intervention",
|
||||||
|
timestamp: new Date(currentTime),
|
||||||
|
data: { type: "pause", reason: "participant_distracted" }
|
||||||
|
});
|
||||||
|
|
||||||
|
advance(10); // Paused for 10s
|
||||||
|
timelineEvents.push({
|
||||||
|
trialId: analyticsTrial!.id,
|
||||||
|
eventType: "intervention",
|
||||||
|
timestamp: new Date(currentTime),
|
||||||
|
data: { type: "resume" }
|
||||||
|
});
|
||||||
|
|
||||||
|
advance(2);
|
||||||
|
timelineEvents.push({
|
||||||
|
trialId: analyticsTrial!.id,
|
||||||
|
eventType: "action_executed",
|
||||||
|
timestamp: new Date(currentTime),
|
||||||
|
data: { actionName: "Turn Head", yaw: 1.5 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Step 3: Comprehension Check
|
||||||
|
advance(30);
|
||||||
|
timelineEvents.push({
|
||||||
|
trialId: analyticsTrial!.id,
|
||||||
|
eventType: "step_changed",
|
||||||
|
timestamp: new Date(currentTime),
|
||||||
|
data: { stepId: step3!.id, stepName: "Comprehension Check" }
|
||||||
|
});
|
||||||
|
|
||||||
|
advance(1);
|
||||||
|
timelineEvents.push({
|
||||||
|
trialId: analyticsTrial!.id,
|
||||||
|
eventType: "action_executed",
|
||||||
|
timestamp: new Date(currentTime),
|
||||||
|
data: { actionName: "Say Text", text: "What color..." }
|
||||||
|
});
|
||||||
|
|
||||||
|
advance(5);
|
||||||
|
timelineEvents.push({
|
||||||
|
trialId: analyticsTrial!.id,
|
||||||
|
eventType: "wizard_action",
|
||||||
|
timestamp: new Date(currentTime),
|
||||||
|
data: { action: "wait_for_response", prompt: "Did they answer Red?" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wizard selects "Correct"
|
||||||
|
advance(8);
|
||||||
|
timelineEvents.push({
|
||||||
|
trialId: analyticsTrial!.id,
|
||||||
|
eventType: "wizard_response",
|
||||||
|
timestamp: new Date(currentTime),
|
||||||
|
data: { response: "Correct", variable: "last_wizard_response" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Branch A
|
||||||
|
advance(1);
|
||||||
|
timelineEvents.push({
|
||||||
|
trialId: analyticsTrial!.id,
|
||||||
|
eventType: "step_changed",
|
||||||
|
timestamp: new Date(currentTime),
|
||||||
|
data: { stepId: step4a!.id, stepName: "Branch A: Correct" }
|
||||||
|
});
|
||||||
|
|
||||||
|
advance(1);
|
||||||
|
timelineEvents.push({
|
||||||
|
trialId: analyticsTrial!.id,
|
||||||
|
eventType: "action_executed",
|
||||||
|
timestamp: new Date(currentTime),
|
||||||
|
data: { actionName: "Say Text with Emotion", emotion: "happy" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Conclusion
|
||||||
|
advance(15);
|
||||||
|
timelineEvents.push({
|
||||||
|
trialId: analyticsTrial!.id,
|
||||||
|
eventType: "step_changed",
|
||||||
|
timestamp: new Date(currentTime),
|
||||||
|
data: { stepId: step5!.id, stepName: "Conclusion" }
|
||||||
|
});
|
||||||
|
|
||||||
|
advance(2);
|
||||||
|
timelineEvents.push({
|
||||||
|
trialId: analyticsTrial!.id,
|
||||||
|
eventType: "action_executed",
|
||||||
|
timestamp: new Date(currentTime),
|
||||||
|
data: { actionName: "End Story" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trial Complete
|
||||||
|
advance(5);
|
||||||
|
timelineEvents.push({
|
||||||
|
trialId: analyticsTrial!.id,
|
||||||
|
eventType: "trial_completed",
|
||||||
|
timestamp: new Date(currentTime),
|
||||||
|
data: { durationSeconds: (currentTime.getTime() - startTime.getTime()) / 1000 }
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(schema.trialEvents).values(timelineEvents);
|
||||||
|
console.log("✅ Seeded 1 completed trial with " + timelineEvents.length + " events.");
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ Seeding failed:", error);
|
console.error("❌ Seeding failed:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
145
src/app/(dashboard)/help/page.tsx
Normal file
145
src/app/(dashboard)/help/page.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
FlaskConical,
|
||||||
|
PlayCircle,
|
||||||
|
BarChart3,
|
||||||
|
HelpCircle,
|
||||||
|
FileText,
|
||||||
|
Video,
|
||||||
|
ExternalLink,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { PageLayout } from "~/components/ui/page-layout";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function HelpCenterPage() {
|
||||||
|
const guides = [
|
||||||
|
{
|
||||||
|
title: "Getting Started",
|
||||||
|
description: "Learn the basics of HRIStudio and set up your first study.",
|
||||||
|
icon: BookOpen,
|
||||||
|
items: [
|
||||||
|
{ label: "Platform Overview", href: "#" },
|
||||||
|
{ label: "Creating a New Study", href: "#" },
|
||||||
|
{ label: "Managing Team Members", href: "#" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Designing Experiments",
|
||||||
|
description: "Master the visual experiment designer and flow control.",
|
||||||
|
icon: FlaskConical,
|
||||||
|
items: [
|
||||||
|
{ label: "Using the Visual Designer", href: "#" },
|
||||||
|
{ label: "Robot Actions & Plugins", href: "#" },
|
||||||
|
{ label: "Variables & Logic", href: "#" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Running Trials",
|
||||||
|
description: "Execute experiments and manage Wizard of Oz sessions.",
|
||||||
|
icon: PlayCircle,
|
||||||
|
items: [
|
||||||
|
{ label: "Wizard Interface Guide", href: "#" },
|
||||||
|
{ label: "Participant Management", href: "#" },
|
||||||
|
{ label: "Handling Robot Errors", href: "#" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Analysis & Data",
|
||||||
|
description: "Analyze trial results and export research data.",
|
||||||
|
icon: BarChart3,
|
||||||
|
items: [
|
||||||
|
{ label: "Understanding Analytics", href: "#" },
|
||||||
|
{ label: "Exporting Data (CSV/JSON)", href: "#" },
|
||||||
|
{ label: "Video Replay & Annotation", href: "#" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout
|
||||||
|
title="Help Center"
|
||||||
|
description="Documentation, guides, and support for HRIStudio researchers."
|
||||||
|
>
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{guides.map((guide, index) => (
|
||||||
|
<Card key={index}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
|
<guide.icon className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl">{guide.title}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>{guide.description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{guide.items.map((item, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="h-auto p-0 text-foreground hover:text-primary justify-start font-normal"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href={item.href}>
|
||||||
|
<FileText className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight mb-4">
|
||||||
|
Video Tutorials
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
|
{[
|
||||||
|
"Introduction to HRIStudio",
|
||||||
|
"Advanced Flow Control",
|
||||||
|
"ROS2 Integration Deep Dive",
|
||||||
|
].map((title, i) => (
|
||||||
|
<Card key={i} className="overflow-hidden">
|
||||||
|
<div className="aspect-video bg-muted flex items-center justify-center relative group cursor-pointer hover:bg-muted/80 transition-colors">
|
||||||
|
<PlayCircle className="h-12 w-12 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||||
|
</div>
|
||||||
|
<CardHeader className="p-4">
|
||||||
|
<CardTitle className="text-base">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 bg-muted/50 rounded-xl p-8 text-center border">
|
||||||
|
<div className="mx-auto w-12 h-12 bg-background rounded-full flex items-center justify-center mb-4 shadow-sm">
|
||||||
|
<HelpCircle className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Still need help?</h2>
|
||||||
|
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
|
||||||
|
Contact your system administrator or check the official documentation for technical support.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center gap-4">
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
Official Docs
|
||||||
|
</Button>
|
||||||
|
<Button className="gap-2">Contact Support</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
import { DesignerRoot } from "~/components/experiments/designer/DesignerRoot";
|
import { DesignerRoot } from "~/components/experiments/designer/DesignerRoot";
|
||||||
|
import { useActionRegistry } from "~/components/experiments/designer/ActionRegistry";
|
||||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||||
import type { ExperimentStep } from "~/lib/experiment-designer/types";
|
import type { ExperimentStep } from "~/lib/experiment-designer/types";
|
||||||
|
|
||||||
@@ -9,6 +11,10 @@ interface DesignerPageClientProps {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
studyId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
study: {
|
study: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -28,6 +34,22 @@ export function DesignerPageClient({
|
|||||||
experiment,
|
experiment,
|
||||||
initialDesign,
|
initialDesign,
|
||||||
}: DesignerPageClientProps) {
|
}: DesignerPageClientProps) {
|
||||||
|
// Initialize action registry early to prevent CLS
|
||||||
|
useActionRegistry();
|
||||||
|
|
||||||
|
// Calculate design statistics
|
||||||
|
const designStats = useMemo(() => {
|
||||||
|
if (!initialDesign) return undefined;
|
||||||
|
|
||||||
|
const stepCount = initialDesign.steps.length;
|
||||||
|
const actionCount = initialDesign.steps.reduce(
|
||||||
|
(sum, step) => sum + step.actions.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return { stepCount, actionCount };
|
||||||
|
}, [initialDesign]);
|
||||||
|
|
||||||
// Set breadcrumbs
|
// Set breadcrumbs
|
||||||
useBreadcrumbsEffect([
|
useBreadcrumbsEffect([
|
||||||
{
|
{
|
||||||
@@ -55,5 +77,12 @@ export function DesignerPageClient({
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return <DesignerRoot experimentId={experiment.id} initialDesign={initialDesign} />;
|
return (
|
||||||
|
<DesignerRoot
|
||||||
|
experimentId={experiment.id}
|
||||||
|
initialDesign={initialDesign}
|
||||||
|
experiment={experiment}
|
||||||
|
designStats={designStats}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,8 @@ export default async function ExperimentDesignerPage({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
const mapped: ExperimentStep[] = exec.steps.map((s, idx) => {
|
const mapped: ExperimentStep[] = exec.steps.map((s, idx) => {
|
||||||
const actions: ExperimentAction[] = s.actions.map((a) => {
|
// Recursive function to hydrate actions with children
|
||||||
|
const hydrateAction = (a: any): ExperimentAction => {
|
||||||
// Normalize legacy plugin action ids and provenance
|
// Normalize legacy plugin action ids and provenance
|
||||||
const rawType = a.type ?? "";
|
const rawType = a.type ?? "";
|
||||||
|
|
||||||
@@ -188,11 +189,24 @@ export default async function ExperimentDesignerPage({
|
|||||||
const pluginId = legacy?.pluginId;
|
const pluginId = legacy?.pluginId;
|
||||||
const pluginVersion = legacy?.pluginVersion;
|
const pluginVersion = legacy?.pluginVersion;
|
||||||
|
|
||||||
|
// Extract children from parameters for control flow actions
|
||||||
|
const params = (a.parameters ?? {}) as Record<string, unknown>;
|
||||||
|
let children: ExperimentAction[] | undefined = undefined;
|
||||||
|
|
||||||
|
// Handle control flow structures (sequence, parallel, loop only)
|
||||||
|
// Branch actions control step routing, not nested actions
|
||||||
|
const childrenRaw = params.children;
|
||||||
|
|
||||||
|
// Recursively hydrate nested children for container actions
|
||||||
|
if (Array.isArray(childrenRaw) && childrenRaw.length > 0) {
|
||||||
|
children = childrenRaw.map((child: any) => hydrateAction(child));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: a.id,
|
id: a.id,
|
||||||
type: typeOut,
|
type: typeOut,
|
||||||
name: a.name,
|
name: a.name,
|
||||||
parameters: (a.parameters ?? {}) as Record<string, unknown>,
|
parameters: params,
|
||||||
category: categoryOut,
|
category: categoryOut,
|
||||||
source: {
|
source: {
|
||||||
kind: sourceKind,
|
kind: sourceKind,
|
||||||
@@ -202,8 +216,11 @@ export default async function ExperimentDesignerPage({
|
|||||||
baseActionId: legacy?.baseId,
|
baseActionId: legacy?.baseId,
|
||||||
},
|
},
|
||||||
execution,
|
execution,
|
||||||
|
children, // Add children at top level
|
||||||
};
|
};
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const actions: ExperimentAction[] = s.actions.map((a) => hydrateAction(a));
|
||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import * as z from "zod";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "~/components/ui/form";
|
|
||||||
import { Input } from "~/components/ui/input";
|
|
||||||
import { Textarea } from "~/components/ui/textarea";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "~/components/ui/select";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { type experiments, experimentStatusEnum } from "~/server/db/schema";
|
|
||||||
import { type InferSelectModel } from "drizzle-orm";
|
|
||||||
|
|
||||||
type Experiment = InferSelectModel<typeof experiments>;
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
name: z.string().min(2, {
|
|
||||||
message: "Name must be at least 2 characters.",
|
|
||||||
}),
|
|
||||||
description: z.string().optional(),
|
|
||||||
status: z.enum(experimentStatusEnum.enumValues),
|
|
||||||
});
|
|
||||||
|
|
||||||
interface ExperimentFormProps {
|
|
||||||
experiment: Experiment;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExperimentForm({ experiment }: ExperimentFormProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const updateExperiment = api.experiments.update.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Experiment updated successfully");
|
|
||||||
router.refresh();
|
|
||||||
router.push(`/studies/${experiment.studyId}/experiments/${experiment.id}`);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(`Error updating experiment: ${error.message}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
|
||||||
resolver: zodResolver(formSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: experiment.name,
|
|
||||||
description: experiment.description ?? "",
|
|
||||||
status: experiment.status,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
|
||||||
updateExperiment.mutate({
|
|
||||||
id: experiment.id,
|
|
||||||
name: values.name,
|
|
||||||
description: values.description,
|
|
||||||
status: values.status,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Experiment name" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
The name of your experiment.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="description"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Description</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
placeholder="Describe your experiment..."
|
|
||||||
className="resize-none"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
A short description of the experiment goals.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="status"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Status</FormLabel>
|
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a status" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="draft">Draft</SelectItem>
|
|
||||||
<SelectItem value="testing">Testing</SelectItem>
|
|
||||||
<SelectItem value="ready">Ready</SelectItem>
|
|
||||||
<SelectItem value="deprecated">Deprecated</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormDescription>
|
|
||||||
The current status of the experiment.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<Button type="submit" disabled={updateExperiment.isPending}>
|
|
||||||
{updateExperiment.isPending ? "Saving..." : "Save Changes"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
router.push(
|
|
||||||
`/studies/${experiment.studyId}/experiments/${experiment.id}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { notFound } from "next/navigation";
|
|
||||||
import { type experiments } from "~/server/db/schema";
|
|
||||||
import { type InferSelectModel } from "drizzle-orm";
|
|
||||||
|
|
||||||
type Experiment = InferSelectModel<typeof experiments>;
|
|
||||||
import { api } from "~/trpc/server";
|
|
||||||
import { ExperimentForm } from "./experiment-form";
|
|
||||||
import {
|
|
||||||
EntityView,
|
|
||||||
EntityViewHeader,
|
|
||||||
EntityViewSection,
|
|
||||||
} from "~/components/ui/entity-view";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { ArrowLeft } from "lucide-react";
|
|
||||||
|
|
||||||
interface ExperimentEditPageProps {
|
|
||||||
params: Promise<{ id: string; experimentId: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ExperimentEditPage({
|
|
||||||
params,
|
|
||||||
}: ExperimentEditPageProps) {
|
|
||||||
const { id: studyId, experimentId } = await params;
|
|
||||||
|
|
||||||
const experiment = await api.experiments.get({ id: experimentId });
|
|
||||||
|
|
||||||
if (!experiment) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure experiment belongs to study
|
|
||||||
if (experiment.studyId !== studyId) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to type expected by form
|
|
||||||
const experimentData: Experiment = {
|
|
||||||
...experiment,
|
|
||||||
status: experiment.status as Experiment["status"],
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EntityView>
|
|
||||||
<EntityViewHeader
|
|
||||||
title="Edit Experiment"
|
|
||||||
subtitle={`Update settings for ${experiment.name}`}
|
|
||||||
icon="Edit"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="max-w-2xl">
|
|
||||||
<EntityViewSection title="Experiment Details" icon="Settings">
|
|
||||||
<ExperimentForm experiment={experimentData} />
|
|
||||||
</EntityViewSection>
|
|
||||||
</div>
|
|
||||||
</EntityView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -195,12 +195,6 @@ export default function ExperimentDetailPage({
|
|||||||
actions={
|
actions={
|
||||||
canEdit ? (
|
canEdit ? (
|
||||||
<>
|
<>
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href={`/studies/${studyId}/experiments/${experimentId}/edit`}>
|
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
|
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
@@ -442,15 +436,9 @@ export default function ExperimentDetailPage({
|
|||||||
{
|
{
|
||||||
label: "Export Data",
|
label: "Export Data",
|
||||||
icon: "Download" as const,
|
icon: "Download" as const,
|
||||||
href: `/studies/${studyId}/experiments/${experimentId}/export`,
|
|
||||||
},
|
},
|
||||||
...(canEdit
|
...(canEdit
|
||||||
? [
|
? [
|
||||||
{
|
|
||||||
label: "Edit Experiment",
|
|
||||||
icon: "Edit" as const,
|
|
||||||
href: `/studies/${studyId}/experiments/${experimentId}/edit`,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "Open Designer",
|
label: "Open Designer",
|
||||||
icon: "Palette" as const,
|
icon: "Palette" as const,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { Logo } from "~/components/ui/logo";
|
import { Logo } from "~/components/ui/logo";
|
||||||
@@ -21,6 +22,7 @@ export default function SignInPage() {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [notRobot, setNotRobot] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@@ -28,6 +30,12 @@ export default function SignInPage() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
|
if (!notRobot) {
|
||||||
|
setError("Please confirm you're not a robot");
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await signIn("credentials", {
|
const result = await signIn("credentials", {
|
||||||
email,
|
email,
|
||||||
@@ -62,7 +70,7 @@ export default function SignInPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<Link href="/" className="inline-flex items-center justify-center transition-opacity hover:opacity-80">
|
<Link href="/" className="inline-flex items-center justify-center transition-opacity hover:opacity-80">
|
||||||
<Logo iconSize="lg" showText={false} />
|
<Logo iconSize="lg" showText={true} />
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="mt-6 text-2xl font-bold tracking-tight text-foreground">Welcome back</h1>
|
<h1 className="mt-6 text-2xl font-bold tracking-tight text-foreground">Welcome back</h1>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
@@ -116,6 +124,22 @@ export default function SignInPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 py-2">
|
||||||
|
<Checkbox
|
||||||
|
id="not-robot"
|
||||||
|
checked={notRobot}
|
||||||
|
onCheckedChange={(checked) => setNotRobot(checked === true)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="not-robot"
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||||
|
>
|
||||||
|
I'm not a robot{" "}
|
||||||
|
<span className="text-muted-foreground text-xs italic">(ironic, isn't it?)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading} size="lg">
|
<Button type="submit" className="w-full" disabled={isLoading} size="lg">
|
||||||
{isLoading ? "Signing in..." : "Sign In"}
|
{isLoading ? "Signing in..." : "Sign In"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -7,16 +7,18 @@ import { formatDistanceToNow } from "date-fns";
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Bot,
|
|
||||||
Calendar,
|
Calendar,
|
||||||
|
CheckCircle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
|
FlaskConical,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Play,
|
Play,
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
Plus,
|
Plus,
|
||||||
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -49,9 +51,11 @@ import { Badge } from "~/components/ui/badge";
|
|||||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { useTour } from "~/components/onboarding/TourProvider";
|
import { useTour } from "~/components/onboarding/TourProvider";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { startTour } = useTour();
|
const { startTour } = useTour();
|
||||||
|
const { data: session } = useSession();
|
||||||
const [studyFilter, setStudyFilter] = React.useState<string | null>(null);
|
const [studyFilter, setStudyFilter] = React.useState<string | null>(null);
|
||||||
|
|
||||||
// --- Data Fetching ---
|
// --- Data Fetching ---
|
||||||
@@ -81,14 +85,27 @@ export default function DashboardPage() {
|
|||||||
studyId: studyFilter ?? undefined,
|
studyId: studyFilter ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const userName = session?.user?.name ?? "Researcher";
|
||||||
|
|
||||||
|
const getWelcomeMessage = () => {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
let greeting = "Good evening";
|
||||||
|
if (hour < 12) greeting = "Good morning";
|
||||||
|
else if (hour < 18) greeting = "Good afternoon";
|
||||||
|
|
||||||
|
return `${greeting}, ${userName.split(" ")[0]}`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col space-y-8 animate-in fade-in duration-500">
|
<div className="space-y-8 animate-in fade-in duration-500">
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<div id="dashboard-header" className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div id="dashboard-header" className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">Dashboard</h1>
|
<h1 className="text-3xl font-bold tracking-tight text-foreground">
|
||||||
|
{getWelcomeMessage()}
|
||||||
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Overview of your research activities and upcoming tasks.
|
Here's what's happening with your research today.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -123,42 +140,141 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Main Stats Grid */}
|
||||||
<div id="tour-dashboard-stats" className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div id="tour-dashboard-stats" className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<StatsCard
|
|
||||||
title="Total Participants"
|
|
||||||
value={stats?.totalParticipants ?? 0}
|
|
||||||
icon={Users}
|
|
||||||
description="Across all studies"
|
|
||||||
trend="+2 this week"
|
|
||||||
/>
|
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Active Trials"
|
title="Active Trials"
|
||||||
value={stats?.activeTrials ?? 0}
|
value={stats?.activeTrials ?? 0}
|
||||||
icon={Activity}
|
icon={Activity}
|
||||||
description="Currently in progress"
|
description="Currently running sessions"
|
||||||
|
iconColor="text-emerald-500"
|
||||||
/>
|
/>
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Completed Trials"
|
title="Completed Today"
|
||||||
value={stats?.completedToday ?? 0}
|
value={stats?.completedToday ?? 0}
|
||||||
icon={CheckCircle2}
|
icon={CheckCircle}
|
||||||
description="Completed today"
|
description="Successful completions"
|
||||||
|
iconColor="text-blue-500"
|
||||||
/>
|
/>
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Scheduled"
|
title="Scheduled"
|
||||||
value={stats?.scheduledTrials ?? 0}
|
value={stats?.scheduledTrials ?? 0}
|
||||||
icon={Calendar}
|
icon={Calendar}
|
||||||
description="Upcoming sessions"
|
description="Upcoming sessions"
|
||||||
|
iconColor="text-violet-500"
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title="Total Studies"
|
||||||
|
value={userStudies.length}
|
||||||
|
icon={FlaskConical}
|
||||||
|
description="Active research projects"
|
||||||
|
iconColor="text-orange-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Action Center & Recent Activity */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||||
|
|
||||||
{/* Main Column: Scheduled Trials & Study Progress */}
|
{/* Quick Actions Card */}
|
||||||
<div className="col-span-4 space-y-4">
|
<Card className="col-span-3 bg-gradient-to-br from-primary/5 to-background border-primary/20 h-fit">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Quick Actions</CardTitle>
|
||||||
|
<CardDescription>Common tasks to get you started</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="justify-start h-auto py-4 px-4 border-primary/20 hover:border-primary/50 hover:bg-primary/5 group"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href="/studies/new">
|
||||||
|
<div className="p-2 bg-primary/10 rounded-full mr-4 group-hover:bg-primary/20 transition-colors">
|
||||||
|
<FlaskConical className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-semibold">Create New Study</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-normal">
|
||||||
|
Design a new experiment protocol
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="ml-auto h-4 w-4 text-muted-foreground group-hover:text-primary opacity-0 group-hover:opacity-100 transition-all" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* Scheduled Trials */}
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="justify-start h-auto py-4 px-4 group"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href="/studies">
|
||||||
|
<div className="p-2 bg-secondary rounded-full mr-4">
|
||||||
|
<Search className="h-5 w-5 text-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-semibold">Browse Studies</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-normal">
|
||||||
|
Find and manage existing studies
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="justify-start h-auto py-4 px-4 group"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href="/trials">
|
||||||
|
<div className="p-2 bg-emerald-500/10 rounded-full mr-4">
|
||||||
|
<Activity className="h-5 w-5 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-semibold">Monitor Active Trials</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-normal">
|
||||||
|
Jump into the Wizard Interface
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recent Activity Card */}
|
||||||
|
<Card id="tour-recent-activity" className="col-span-4 border-muted/40 shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent Activity</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Your latest interactions across the platform
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea className="h-[400px] pr-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{recentActivity?.map((activity) => (
|
||||||
|
<div key={activity.id} className="relative pl-4 pb-1 border-l last:border-0 border-muted-foreground/20">
|
||||||
|
<span className="absolute left-[-5px] top-1 h-2.5 w-2.5 rounded-full bg-primary/30 ring-4 ring-background" />
|
||||||
|
<div className="mb-1 text-sm font-medium leading-none">{activity.title}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">{activity.description}</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground/70 uppercase">
|
||||||
|
{formatDistanceToNow(activity.time, { addSuffix: true })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!recentActivity?.length && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
||||||
|
<Clock className="h-10 w-10 mb-3 opacity-20" />
|
||||||
|
<p>No recent activity recorded.</p>
|
||||||
|
<p className="text-sm">Start a trial to see updates here.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||||
|
{/* Scheduled Trials (Restored from previous page.tsx but styled to fit) */}
|
||||||
<Card id="tour-scheduled-trials" className="col-span-4 border-muted/40 shadow-sm">
|
<Card id="tour-scheduled-trials" className="col-span-4 border-muted/40 shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -214,7 +330,7 @@ export default function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Study Progress */}
|
{/* Study Progress */}
|
||||||
<Card className="border-muted/40 shadow-sm">
|
<Card className="col-span-3 border-muted/40 shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Study Progress</CardTitle>
|
<CardTitle>Study Progress</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -236,53 +352,6 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Side Column: Recent Activity & Quick Actions */}
|
|
||||||
<div className="col-span-3 space-y-4">
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Button variant="outline" className="h-24 flex-col gap-2 hover:border-primary/50 hover:bg-primary/5" asChild>
|
|
||||||
<Link href="/experiments/new">
|
|
||||||
<Bot className="h-6 w-6 mb-1" />
|
|
||||||
<span>New Experim.</span>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" className="h-24 flex-col gap-2 hover:border-primary/50 hover:bg-primary/5" asChild>
|
|
||||||
<Link href="/trials/new">
|
|
||||||
<PlayCircle className="h-6 w-6 mb-1" />
|
|
||||||
<span>Run Trial</span>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recent Activity */}
|
|
||||||
<Card id="tour-recent-activity" className="border-muted/40 shadow-sm h-full">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Recent Activity</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ScrollArea className="h-[400px] pr-4">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{recentActivity?.map((activity) => (
|
|
||||||
<div key={activity.id} className="relative pl-4 pb-1 border-l last:border-0 border-muted-foreground/20">
|
|
||||||
<span className="absolute left-[-5px] top-1 h-2.5 w-2.5 rounded-full bg-primary/30 ring-4 ring-background" />
|
|
||||||
<div className="mb-1 text-sm font-medium leading-none">{activity.title}</div>
|
|
||||||
<div className="text-xs text-muted-foreground mb-1">{activity.description}</div>
|
|
||||||
<div className="text-[10px] text-muted-foreground/70 uppercase">
|
|
||||||
{formatDistanceToNow(activity.time, { addSuffix: true })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!recentActivity?.length && (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-8">No recent activity.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -294,18 +363,20 @@ function StatsCard({
|
|||||||
icon: Icon,
|
icon: Icon,
|
||||||
description,
|
description,
|
||||||
trend,
|
trend,
|
||||||
|
iconColor,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
description: string;
|
description: string;
|
||||||
trend?: string;
|
trend?: string;
|
||||||
|
iconColor?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-muted/40 shadow-sm hover:shadow-md transition-all duration-200 hover:border-primary/20">
|
<Card className="border-muted/40 shadow-sm hover:shadow-md transition-all duration-200 hover:border-primary/20">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
<Icon className={`h-4 w-4 ${iconColor || "text-muted-foreground"}`} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{value}</div>
|
<div className="text-2xl font-bold">{value}</div>
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Activity, Calendar, CheckCircle, FlaskConical } from "lucide-react";
|
|
||||||
import { DashboardOverviewLayout } from "~/components/ui/page-layout";
|
|
||||||
|
|
||||||
interface DashboardContentProps {
|
|
||||||
userName: string;
|
|
||||||
userRole: string;
|
|
||||||
totalStudies: number;
|
|
||||||
activeTrials: number;
|
|
||||||
scheduledTrials: number;
|
|
||||||
completedToday: number;
|
|
||||||
canControl: boolean;
|
|
||||||
canManage: boolean;
|
|
||||||
_recentTrials: unknown[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardContent({
|
|
||||||
userName,
|
|
||||||
userRole,
|
|
||||||
totalStudies,
|
|
||||||
activeTrials,
|
|
||||||
scheduledTrials,
|
|
||||||
completedToday,
|
|
||||||
canControl,
|
|
||||||
canManage,
|
|
||||||
_recentTrials,
|
|
||||||
}: DashboardContentProps) {
|
|
||||||
const getWelcomeMessage = () => {
|
|
||||||
switch (userRole) {
|
|
||||||
case "wizard":
|
|
||||||
return "Ready to control trials";
|
|
||||||
case "researcher":
|
|
||||||
return "Your research platform awaits";
|
|
||||||
case "administrator":
|
|
||||||
return "System management dashboard";
|
|
||||||
default:
|
|
||||||
return "Welcome to HRIStudio";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const quickActions = [
|
|
||||||
...(canManage
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
title: "Create Study",
|
|
||||||
description: "Start a new research study",
|
|
||||||
icon: FlaskConical,
|
|
||||||
href: "/studies/new",
|
|
||||||
variant: "primary" as const,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(canControl
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
title: "Browse Studies",
|
|
||||||
description: "View and manage studies",
|
|
||||||
icon: Calendar,
|
|
||||||
href: "/studies",
|
|
||||||
variant: "default" as const,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
];
|
|
||||||
|
|
||||||
const stats = [
|
|
||||||
{
|
|
||||||
title: "Studies",
|
|
||||||
value: totalStudies,
|
|
||||||
description: "Research studies",
|
|
||||||
icon: FlaskConical,
|
|
||||||
variant: "primary" as const,
|
|
||||||
action: {
|
|
||||||
label: "View All",
|
|
||||||
href: "/studies",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Active Trials",
|
|
||||||
value: activeTrials,
|
|
||||||
description: "Currently running",
|
|
||||||
icon: Activity,
|
|
||||||
variant: "success" as const,
|
|
||||||
...(canControl && {
|
|
||||||
action: {
|
|
||||||
label: "View",
|
|
||||||
href: "/studies",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Scheduled",
|
|
||||||
value: scheduledTrials,
|
|
||||||
description: "Upcoming trials",
|
|
||||||
icon: Calendar,
|
|
||||||
variant: "default" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Completed Today",
|
|
||||||
value: completedToday,
|
|
||||||
description: "Finished trials",
|
|
||||||
icon: CheckCircle,
|
|
||||||
variant: "success" as const,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const alerts: never[] = [];
|
|
||||||
|
|
||||||
const recentActivity = null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardOverviewLayout
|
|
||||||
title={`${getWelcomeMessage()}, ${userName}`}
|
|
||||||
description="Monitor your HRI research activities and manage ongoing studies"
|
|
||||||
userName={userName}
|
|
||||||
userRole={userRole}
|
|
||||||
breadcrumb={[{ label: "Dashboard" }]}
|
|
||||||
quickActions={quickActions}
|
|
||||||
stats={stats}
|
|
||||||
alerts={alerts}
|
|
||||||
recentActivity={recentActivity}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { signOut, useSession } from "next-auth/react";
|
import { signOut, useSession } from "next-auth/react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
BookOpen,
|
||||||
Building,
|
Building,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
@@ -113,6 +114,14 @@ const adminItems = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const helpItems = [
|
||||||
|
{
|
||||||
|
title: "Help Center",
|
||||||
|
url: "/help",
|
||||||
|
icon: BookOpen, // Make sure to import this from lucide-react
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||||
userRole?: string;
|
userRole?: string;
|
||||||
}
|
}
|
||||||
@@ -126,9 +135,38 @@ export function AppSidebar({
|
|||||||
const isAdmin = userRole === "administrator";
|
const isAdmin = userRole === "administrator";
|
||||||
const { state: sidebarState } = useSidebar();
|
const { state: sidebarState } = useSidebar();
|
||||||
const isCollapsed = sidebarState === "collapsed";
|
const isCollapsed = sidebarState === "collapsed";
|
||||||
const { selectedStudyId, userStudies, selectStudy, refreshStudyData } =
|
const { selectedStudyId, userStudies, selectStudy, refreshStudyData, isLoadingUserStudies } =
|
||||||
useStudyManagement();
|
useStudyManagement();
|
||||||
|
|
||||||
|
// Reference to track if we've already attempted auto-selection to avoid fighting with manual clearing
|
||||||
|
const hasAutoSelected = useRef(false);
|
||||||
|
|
||||||
|
// Auto-select most recently touched study if none selected
|
||||||
|
useEffect(() => {
|
||||||
|
// Only run if not loading, no study selected, and we have studies available
|
||||||
|
// And only run once per session (using ref) to allow user to clear selection if desired
|
||||||
|
if (
|
||||||
|
!isLoadingUserStudies &&
|
||||||
|
!selectedStudyId &&
|
||||||
|
userStudies.length > 0 &&
|
||||||
|
!hasAutoSelected.current
|
||||||
|
) {
|
||||||
|
// userStudies is sorted by updatedAt desc from the API, so the first one is the most recent
|
||||||
|
// userStudies is sorted by updatedAt desc from the API, so the first one is the most recent
|
||||||
|
const mostRecent = userStudies[0];
|
||||||
|
if (mostRecent) {
|
||||||
|
console.log("Auto-selecting most recent study:", mostRecent.name);
|
||||||
|
void selectStudy(mostRecent.id);
|
||||||
|
hasAutoSelected.current = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isLoadingUserStudies,
|
||||||
|
selectedStudyId,
|
||||||
|
userStudies,
|
||||||
|
selectStudy,
|
||||||
|
]);
|
||||||
|
|
||||||
// Debug API call
|
// Debug API call
|
||||||
const { data: debugData } = api.dashboard.debug.useQuery(undefined, {
|
const { data: debugData } = api.dashboard.debug.useQuery(undefined, {
|
||||||
enabled: process.env.NODE_ENV === "development",
|
enabled: process.env.NODE_ENV === "development",
|
||||||
@@ -520,6 +558,44 @@ export function AppSidebar({
|
|||||||
)}
|
)}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
|
{/* Help Section */}
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Support</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{helpItems.map((item) => {
|
||||||
|
const isActive = pathname.startsWith(item.url);
|
||||||
|
|
||||||
|
const menuButton = (
|
||||||
|
<SidebarMenuButton asChild isActive={isActive}>
|
||||||
|
<Link href={item.url}>
|
||||||
|
<item.icon className="h-4 w-4" />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem key={item.title}>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{menuButton}</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" className="text-sm">
|
||||||
|
{item.title}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : (
|
||||||
|
menuButton
|
||||||
|
)}
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
|
||||||
{/* Debug info moved to footer tooltip button */}
|
{/* Debug info moved to footer tooltip button */}
|
||||||
|
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
|
|||||||
@@ -256,41 +256,35 @@ function ExperimentActions({ experiment }: { experiment: Experiment }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<div className="flex items-center gap-2">
|
||||||
<DropdownMenuTrigger asChild>
|
<Button
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
variant="ghost"
|
||||||
<span className="sr-only">Open menu</span>
|
size="icon"
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
asChild
|
||||||
</Button>
|
className="h-8 w-8 text-muted-foreground hover:text-primary"
|
||||||
</DropdownMenuTrigger>
|
title="Open Designer"
|
||||||
<DropdownMenuContent align="end">
|
>
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<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`}>
|
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
|
||||||
<LayoutTemplate className="mr-2 h-4 w-4" />
|
<LayoutTemplate className="h-4 w-4" />
|
||||||
Design
|
<span className="sr-only">Design</span>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
<Button
|
||||||
className="text-red-600 focus:text-red-700"
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm("Are you sure you want to delete this experiment?")) {
|
if (confirm("Are you sure you want to delete this experiment?")) {
|
||||||
deleteMutation.mutate({ id: experiment.id });
|
deleteMutation.mutate({ id: experiment.id });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
|
title="Delete Experiment"
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
Delete
|
<span className="sr-only">Delete</span>
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
</DropdownMenuContent>
|
</div>
|
||||||
</DropdownMenu>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ import {
|
|||||||
PanelRightClose,
|
PanelRightClose,
|
||||||
PanelRightOpen,
|
PanelRightOpen,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize2
|
Minimize2,
|
||||||
|
Settings
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { PageHeader } from "~/components/ui/page-header";
|
import { PageHeader } from "~/components/ui/page-header";
|
||||||
import { useTour } from "~/components/onboarding/TourProvider";
|
import { useTour } from "~/components/onboarding/TourProvider";
|
||||||
|
import { SettingsModal } from "./SettingsModal";
|
||||||
|
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
@@ -45,7 +47,8 @@ import {
|
|||||||
import { BottomStatusBar } from "./layout/BottomStatusBar";
|
import { BottomStatusBar } from "./layout/BottomStatusBar";
|
||||||
import { ActionLibraryPanel } from "./panels/ActionLibraryPanel";
|
import { ActionLibraryPanel } from "./panels/ActionLibraryPanel";
|
||||||
import { InspectorPanel } from "./panels/InspectorPanel";
|
import { InspectorPanel } from "./panels/InspectorPanel";
|
||||||
import { FlowWorkspace, SortableActionChip, StepCardPreview } from "./flow/FlowWorkspace";
|
import { FlowWorkspace, StepCardPreview } from "./flow/FlowWorkspace";
|
||||||
|
import { SortableActionChip } from "./flow/ActionChip";
|
||||||
import { GripVertical } from "lucide-react";
|
import { GripVertical } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -96,6 +99,23 @@ export interface DesignerRootProps {
|
|||||||
initialDesign?: ExperimentDesign;
|
initialDesign?: ExperimentDesign;
|
||||||
autoCompile?: boolean;
|
autoCompile?: boolean;
|
||||||
onPersist?: (design: ExperimentDesign) => void;
|
onPersist?: (design: ExperimentDesign) => void;
|
||||||
|
experiment?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
studyId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
study: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
designStats?: {
|
||||||
|
stepCount: number;
|
||||||
|
actionCount: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawExperiment {
|
interface RawExperiment {
|
||||||
@@ -114,10 +134,13 @@ interface RawExperiment {
|
|||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
|
function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
|
||||||
|
console.log('[adaptExistingDesign] Entry - exp.steps:', exp.steps);
|
||||||
|
|
||||||
// 1. Prefer database steps (Source of Truth) if valid, to ensure we have the latest
|
// 1. Prefer database steps (Source of Truth) if valid, to ensure we have the latest
|
||||||
// plugin provenance data (which might be missing from stale visualDesign snapshots).
|
// plugin provenance data (which might be missing from stale visualDesign snapshots).
|
||||||
// 1. Prefer database steps (Source of Truth) if valid.
|
// 1. Prefer database steps (Source of Truth) if valid.
|
||||||
if (Array.isArray(exp.steps) && exp.steps.length > 0) {
|
if (Array.isArray(exp.steps) && exp.steps.length > 0) {
|
||||||
|
console.log('[adaptExistingDesign] Has steps array, length:', exp.steps.length);
|
||||||
try {
|
try {
|
||||||
// Check if steps are already converted (have trigger property) to avoid double-conversion data loss
|
// Check if steps are already converted (have trigger property) to avoid double-conversion data loss
|
||||||
const firstStep = exp.steps[0] as any;
|
const firstStep = exp.steps[0] as any;
|
||||||
@@ -128,7 +151,17 @@ function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
|
|||||||
dbSteps = exp.steps as ExperimentStep[];
|
dbSteps = exp.steps as ExperimentStep[];
|
||||||
} else {
|
} else {
|
||||||
// Raw DB steps, need conversion
|
// Raw DB steps, need conversion
|
||||||
|
console.log('[adaptExistingDesign] Taking raw DB conversion path');
|
||||||
dbSteps = convertDatabaseToSteps(exp.steps);
|
dbSteps = convertDatabaseToSteps(exp.steps);
|
||||||
|
|
||||||
|
// DEBUG: Check children after conversion
|
||||||
|
dbSteps.forEach((step) => {
|
||||||
|
step.actions.forEach((action) => {
|
||||||
|
if (["sequence", "parallel", "loop", "branch"].includes(action.type)) {
|
||||||
|
console.log(`[adaptExistingDesign] Post-conversion ${action.type} (${action.name}) children:`, action.children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -196,6 +229,8 @@ export function DesignerRoot({
|
|||||||
initialDesign,
|
initialDesign,
|
||||||
autoCompile = true,
|
autoCompile = true,
|
||||||
onPersist,
|
onPersist,
|
||||||
|
experiment: experimentMetadata,
|
||||||
|
designStats,
|
||||||
}: DesignerRootProps) {
|
}: DesignerRootProps) {
|
||||||
// Subscribe to registry updates to ensure re-renders when actions load
|
// Subscribe to registry updates to ensure re-renders when actions load
|
||||||
useActionRegistry();
|
useActionRegistry();
|
||||||
@@ -218,8 +253,6 @@ export function DesignerRoot({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const updateExperiment = api.experiments.update.useMutation({
|
const updateExperiment = api.experiments.update.useMutation({
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
toast.error(`Save failed: ${err.message}`);
|
toast.error(`Save failed: ${err.message}`);
|
||||||
@@ -321,6 +354,7 @@ export function DesignerRoot({
|
|||||||
|
|
||||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||||
const [rightCollapsed, setRightCollapsed] = useState(false);
|
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
// Responsive initialization: Collapse left sidebar on smaller screens (<1280px)
|
// Responsive initialization: Collapse left sidebar on smaller screens (<1280px)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -353,14 +387,12 @@ export function DesignerRoot({
|
|||||||
|
|
||||||
/* ----------------------------- Initialization ---------------------------- */
|
/* ----------------------------- Initialization ---------------------------- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.log('[DesignerRoot] useEffect triggered', { initialized, loadingExperiment, hasExperiment: !!experiment, hasInitialDesign: !!initialDesign });
|
||||||
|
|
||||||
if (initialized) return;
|
if (initialized) return;
|
||||||
if (loadingExperiment && !initialDesign) return;
|
if (loadingExperiment && !initialDesign) return;
|
||||||
|
|
||||||
// console.log('[DesignerRoot] 🚀 INITIALIZING', {
|
console.log('[DesignerRoot] Proceeding with initialization');
|
||||||
// hasExperiment: !!experiment,
|
|
||||||
// hasInitialDesign: !!initialDesign,
|
|
||||||
// loadingExperiment,
|
|
||||||
// });
|
|
||||||
|
|
||||||
const adapted =
|
const adapted =
|
||||||
initialDesign ??
|
initialDesign ??
|
||||||
@@ -1004,10 +1036,8 @@ export function DesignerRoot({
|
|||||||
const defaultParams: Record<string, unknown> = {};
|
const defaultParams: Record<string, unknown> = {};
|
||||||
if (fullDef?.parameters) {
|
if (fullDef?.parameters) {
|
||||||
for (const param of fullDef.parameters) {
|
for (const param of fullDef.parameters) {
|
||||||
// @ts-expect-error - 'default' property access
|
if (param.value !== undefined) {
|
||||||
if (param.default !== undefined) {
|
defaultParams[param.id] = param.value;
|
||||||
// @ts-expect-error - 'default' property access
|
|
||||||
defaultParams[param.id] = param.default;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1097,6 +1127,16 @@ export function DesignerRoot({
|
|||||||
|
|
||||||
const actions = (
|
const actions = (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{experimentMetadata && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setSettingsOpen(true)}
|
||||||
|
title="Experiment Settings"
|
||||||
|
>
|
||||||
|
<Settings className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -1127,7 +1167,10 @@ export function DesignerRoot({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden bg-background">
|
<div className="relative flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden bg-background">
|
||||||
|
{/* Subtle Background Gradients */}
|
||||||
|
<div className="absolute top-0 left-1/2 -z-10 h-[400px] w-[800px] -translate-x-1/2 rounded-full bg-primary/10 blur-3xl opacity-20 dark:opacity-10" />
|
||||||
|
<div className="absolute bottom-0 right-0 -z-10 h-[250px] w-[250px] rounded-full bg-violet-500/5 blur-3xl" />
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={designMeta.name}
|
title={designMeta.name}
|
||||||
description={designMeta.description || "No description"}
|
description={designMeta.description || "No description"}
|
||||||
@@ -1289,6 +1332,16 @@ export function DesignerRoot({
|
|||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Settings Modal */}
|
||||||
|
{experimentMetadata && (
|
||||||
|
<SettingsModal
|
||||||
|
open={settingsOpen}
|
||||||
|
onOpenChange={setSettingsOpen}
|
||||||
|
experiment={experimentMetadata}
|
||||||
|
designStats={designStats}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,7 +170,30 @@ export function PropertiesPanelBase({
|
|||||||
|
|
||||||
/* -------------------------- Action Properties View -------------------------- */
|
/* -------------------------- Action Properties View -------------------------- */
|
||||||
if (selectedAction && containingStep) {
|
if (selectedAction && containingStep) {
|
||||||
const def = registry.getAction(selectedAction.type);
|
let def = registry.getAction(selectedAction.type);
|
||||||
|
|
||||||
|
// Fallback: If action not found in registry, try without plugin prefix
|
||||||
|
if (!def && selectedAction.type.includes('.')) {
|
||||||
|
const baseType = selectedAction.type.split('.').pop();
|
||||||
|
if (baseType) {
|
||||||
|
def = registry.getAction(baseType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final fallback: Create minimal definition from action data
|
||||||
|
if (!def) {
|
||||||
|
def = {
|
||||||
|
id: selectedAction.type,
|
||||||
|
type: selectedAction.type,
|
||||||
|
name: selectedAction.name,
|
||||||
|
description: `Action type: ${selectedAction.type}`,
|
||||||
|
category: selectedAction.category || 'control',
|
||||||
|
icon: 'Zap',
|
||||||
|
color: '#6366f1',
|
||||||
|
parameters: [],
|
||||||
|
source: selectedAction.source,
|
||||||
|
};
|
||||||
|
}
|
||||||
const categoryColors = {
|
const categoryColors = {
|
||||||
wizard: "bg-blue-500",
|
wizard: "bg-blue-500",
|
||||||
robot: "bg-emerald-500",
|
robot: "bg-emerald-500",
|
||||||
@@ -289,23 +312,29 @@ export function PropertiesPanelBase({
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="h-5 w-5 p-0"
|
className="h-5 w-5 p-0"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const currentOptions = (containingStep.trigger.conditions as any)?.options || [];
|
const currentOptions = ((containingStep.trigger.conditions as any).options as any[]) || [];
|
||||||
|
const newOptions = [
|
||||||
|
...currentOptions,
|
||||||
|
{ label: "New Option", nextStepId: design.steps[containingStep.order + 1]?.id, variant: "default" }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sync to Step Trigger (Source of Truth)
|
||||||
onStepUpdate(containingStep.id, {
|
onStepUpdate(containingStep.id, {
|
||||||
trigger: {
|
trigger: {
|
||||||
...containingStep.trigger,
|
...containingStep.trigger,
|
||||||
conditions: {
|
conditions: {
|
||||||
...containingStep.trigger.conditions,
|
...containingStep.trigger.conditions,
|
||||||
options: [
|
options: newOptions
|
||||||
...currentOptions,
|
|
||||||
{ label: "New Option", nextStepIndex: containingStep.order + 1, variant: "default" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Auto-upgrade step type if needed
|
// Sync to Action Params (for consistency)
|
||||||
if (containingStep.type !== "conditional") {
|
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||||
onStepUpdate(containingStep.id, { type: "conditional" });
|
parameters: {
|
||||||
|
...selectedAction.parameters,
|
||||||
|
options: newOptions
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
@@ -313,47 +342,60 @@ export function PropertiesPanelBase({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{((containingStep.trigger.conditions as any)?.options || []).map((opt: any, idx: number) => (
|
{(((containingStep.trigger.conditions as any).options as any[]) || []).map((opt: any, idx: number) => (
|
||||||
<div key={idx} className="space-y-2 p-2 rounded border bg-muted/50">
|
<div key={idx} className="space-y-2 p-2 rounded border bg-muted/50">
|
||||||
<div className="flex gap-2">
|
<div className="grid grid-cols-5 gap-2">
|
||||||
<div className="flex-1">
|
<div className="col-span-3">
|
||||||
<Label className="text-[10px]">Label</Label>
|
<Label className="text-[10px]">Label</Label>
|
||||||
<Input
|
<Input
|
||||||
value={opt.label}
|
value={opt.label}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newOpts = [...((containingStep.trigger.conditions as any)?.options || [])];
|
const currentOptions = ((containingStep.trigger.conditions as any).options as any[]) || [];
|
||||||
|
const newOpts = [...currentOptions];
|
||||||
newOpts[idx] = { ...newOpts[idx], label: e.target.value };
|
newOpts[idx] = { ...newOpts[idx], label: e.target.value };
|
||||||
|
|
||||||
onStepUpdate(containingStep.id, {
|
onStepUpdate(containingStep.id, {
|
||||||
trigger: { ...containingStep.trigger, conditions: { ...containingStep.trigger.conditions, options: newOpts } }
|
trigger: {
|
||||||
|
...containingStep.trigger,
|
||||||
|
conditions: { ...containingStep.trigger.conditions, options: newOpts }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||||
|
parameters: { ...selectedAction.parameters, options: newOpts }
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[80px]">
|
<div className="col-span-2">
|
||||||
<Label className="text-[10px]">Target Step</Label>
|
<Label className="text-[10px]">Target Step</Label>
|
||||||
|
{design.steps.length <= 1 ? (
|
||||||
|
<div className="h-7 flex items-center text-[10px] text-muted-foreground border rounded px-2 bg-muted/50 truncate" title="Add more steps to link">
|
||||||
|
No linkable steps
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Select
|
<Select
|
||||||
value={opt.nextStepId ?? design.steps[opt.nextStepIndex]?.id ?? ""}
|
value={opt.nextStepId ?? ""}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
const newOpts = [...((containingStep.trigger.conditions as any)?.options || [])];
|
const currentOptions = ((containingStep.trigger.conditions as any).options as any[]) || [];
|
||||||
// Find index for legacy support / display logic if needed
|
const newOpts = [...currentOptions];
|
||||||
const stepIdx = design.steps.findIndex(s => s.id === val);
|
newOpts[idx] = { ...newOpts[idx], nextStepId: val };
|
||||||
|
|
||||||
newOpts[idx] = {
|
|
||||||
...newOpts[idx],
|
|
||||||
nextStepId: val,
|
|
||||||
nextStepIndex: stepIdx !== -1 ? stepIdx : undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
onStepUpdate(containingStep.id, {
|
onStepUpdate(containingStep.id, {
|
||||||
trigger: { ...containingStep.trigger, conditions: { ...containingStep.trigger.conditions, options: newOpts } }
|
trigger: {
|
||||||
|
...containingStep.trigger,
|
||||||
|
conditions: { ...containingStep.trigger.conditions, options: newOpts }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||||
|
parameters: { ...selectedAction.parameters, options: newOpts }
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 text-xs">
|
<SelectTrigger className="h-7 text-xs w-full">
|
||||||
<SelectValue placeholder="Select step..." />
|
<SelectValue placeholder="Select..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent className="min-w-[180px]">
|
||||||
{design.steps.map((s) => (
|
{design.steps.map((s) => (
|
||||||
<SelectItem key={s.id} value={s.id} disabled={s.id === containingStep.id}>
|
<SelectItem key={s.id} value={s.id} disabled={s.id === containingStep.id}>
|
||||||
{s.order + 1}. {s.name}
|
{s.order + 1}. {s.name}
|
||||||
@@ -361,16 +403,25 @@ export function PropertiesPanelBase({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Select
|
<Select
|
||||||
value={opt.variant || "default"}
|
value={opt.variant || "default"}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
const newOpts = [...((containingStep.trigger.conditions as any)?.options || [])];
|
const currentOptions = ((containingStep.trigger.conditions as any).options as any[]) || [];
|
||||||
|
const newOpts = [...currentOptions];
|
||||||
newOpts[idx] = { ...newOpts[idx], variant: val };
|
newOpts[idx] = { ...newOpts[idx], variant: val };
|
||||||
|
|
||||||
onStepUpdate(containingStep.id, {
|
onStepUpdate(containingStep.id, {
|
||||||
trigger: { ...containingStep.trigger, conditions: { ...containingStep.trigger.conditions, options: newOpts } }
|
trigger: {
|
||||||
|
...containingStep.trigger,
|
||||||
|
conditions: { ...containingStep.trigger.conditions, options: newOpts }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||||
|
parameters: { ...selectedAction.parameters, options: newOpts }
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -389,10 +440,18 @@ export function PropertiesPanelBase({
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-red-500"
|
className="h-6 w-6 p-0 text-muted-foreground hover:text-red-500"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newOpts = [...((containingStep.trigger.conditions as any)?.options || [])];
|
const currentOptions = ((containingStep.trigger.conditions as any).options as any[]) || [];
|
||||||
|
const newOpts = [...currentOptions];
|
||||||
newOpts.splice(idx, 1);
|
newOpts.splice(idx, 1);
|
||||||
|
|
||||||
onStepUpdate(containingStep.id, {
|
onStepUpdate(containingStep.id, {
|
||||||
trigger: { ...containingStep.trigger, conditions: { ...containingStep.trigger.conditions, options: newOpts } }
|
trigger: {
|
||||||
|
...containingStep.trigger,
|
||||||
|
conditions: { ...containingStep.trigger.conditions, options: newOpts }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||||
|
parameters: { ...selectedAction.parameters, options: newOpts }
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -401,13 +460,46 @@ export function PropertiesPanelBase({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{(!((containingStep.trigger.conditions as any)?.options?.length)) && (
|
{(!(((containingStep.trigger.conditions as any).options as any[])?.length)) && (
|
||||||
<div className="text-center py-4 border border-dashed rounded text-xs text-muted-foreground">
|
<div className="text-center py-4 border border-dashed rounded text-xs text-muted-foreground">
|
||||||
No options defined.<br />Click + to add a branch.
|
No options defined.<br />Click + to add a branch.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : selectedAction.type === "loop" ? (
|
||||||
|
/* Loop Configuration */
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||||
|
Loop Configuration
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Iterations */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Iterations</Label>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Slider
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
step={1}
|
||||||
|
value={[Number(selectedAction.parameters.iterations || 1)]}
|
||||||
|
onValueChange={(vals) => {
|
||||||
|
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||||
|
parameters: {
|
||||||
|
...selectedAction.parameters,
|
||||||
|
iterations: vals[0],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-mono w-8 text-right">
|
||||||
|
{Number(selectedAction.parameters.iterations || 1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Standard Parameters */
|
/* Standard Parameters */
|
||||||
def?.parameters.length ? (
|
def?.parameters.length ? (
|
||||||
|
|||||||
53
src/components/experiments/designer/SettingsModal.tsx
Normal file
53
src/components/experiments/designer/SettingsModal.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SettingsTab } from "./tabs/SettingsTab";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
|
||||||
|
interface SettingsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
experiment: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
studyId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
study: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
designStats?: {
|
||||||
|
stepCount: number;
|
||||||
|
actionCount: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
experiment,
|
||||||
|
designStats,
|
||||||
|
}: SettingsModalProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] p-0">
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>Experiment Settings</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure experiment metadata and status
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<SettingsTab experiment={experiment} designStats={designStats} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
503
src/components/experiments/designer/flow/ActionChip.tsx
Normal file
503
src/components/experiments/designer/flow/ActionChip.tsx
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { useDroppable } from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
Trash2,
|
||||||
|
Clock,
|
||||||
|
GitBranch,
|
||||||
|
Repeat,
|
||||||
|
Layers,
|
||||||
|
List,
|
||||||
|
AlertCircle,
|
||||||
|
Play,
|
||||||
|
HelpCircle
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import { type ExperimentAction } from "~/lib/experiment-designer/types";
|
||||||
|
import { actionRegistry } from "../ActionRegistry";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { useDesignerStore } from "../state/store";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to determine visual style based on action type/category
|
||||||
|
*/
|
||||||
|
function getActionVisualStyle(action: ExperimentAction) {
|
||||||
|
const def = actionRegistry.getAction(action.type);
|
||||||
|
const category = def?.category || "other";
|
||||||
|
|
||||||
|
// Specific Control Types
|
||||||
|
if (action.type === "hristudio-core.wait" || action.type === "wait") {
|
||||||
|
return {
|
||||||
|
variant: "wait",
|
||||||
|
icon: Clock,
|
||||||
|
bg: "bg-amber-500/10 hover:bg-amber-500/20",
|
||||||
|
border: "border-amber-200 dark:border-amber-800",
|
||||||
|
text: "text-amber-700 dark:text-amber-400",
|
||||||
|
accent: "bg-amber-500",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === "hristudio-core.branch" || action.type === "branch") {
|
||||||
|
return {
|
||||||
|
variant: "branch",
|
||||||
|
icon: GitBranch,
|
||||||
|
bg: "bg-orange-500/10 hover:bg-orange-500/20",
|
||||||
|
border: "border-orange-200 dark:border-orange-800",
|
||||||
|
text: "text-orange-700 dark:text-orange-400",
|
||||||
|
accent: "bg-orange-500",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === "hristudio-core.loop" || action.type === "loop") {
|
||||||
|
return {
|
||||||
|
variant: "loop",
|
||||||
|
icon: Repeat,
|
||||||
|
bg: "bg-purple-500/10 hover:bg-purple-500/20",
|
||||||
|
border: "border-purple-200 dark:border-purple-800",
|
||||||
|
text: "text-purple-700 dark:text-purple-400",
|
||||||
|
accent: "bg-purple-500",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === "hristudio-core.parallel" || action.type === "parallel") {
|
||||||
|
return {
|
||||||
|
variant: "parallel",
|
||||||
|
icon: Layers,
|
||||||
|
bg: "bg-emerald-500/10 hover:bg-emerald-500/20",
|
||||||
|
border: "border-emerald-200 dark:border-emerald-800",
|
||||||
|
text: "text-emerald-700 dark:text-emerald-400",
|
||||||
|
accent: "bg-emerald-500",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// General Categories
|
||||||
|
if (category === "wizard") {
|
||||||
|
return {
|
||||||
|
variant: "wizard",
|
||||||
|
icon: HelpCircle,
|
||||||
|
bg: "bg-indigo-500/5 hover:bg-indigo-500/10",
|
||||||
|
border: "border-indigo-200 dark:border-indigo-800",
|
||||||
|
text: "text-indigo-700 dark:text-indigo-300",
|
||||||
|
accent: "bg-indigo-500",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((category as string) === "robot" || (category as string) === "movement" || (category as string) === "speech") {
|
||||||
|
return {
|
||||||
|
variant: "robot",
|
||||||
|
icon: Play, // Or specific robot icon if available
|
||||||
|
bg: "bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700",
|
||||||
|
border: "border-slate-200 dark:border-slate-700",
|
||||||
|
text: "text-slate-700 dark:text-slate-300",
|
||||||
|
accent: "bg-slate-500",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default
|
||||||
|
return {
|
||||||
|
variant: "default",
|
||||||
|
icon: undefined,
|
||||||
|
bg: "bg-muted/40 hover:bg-accent/40",
|
||||||
|
border: "border-border",
|
||||||
|
text: "text-foreground",
|
||||||
|
accent: "bg-muted-foreground",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function ActionChipVisuals({
|
||||||
|
action,
|
||||||
|
isSelected,
|
||||||
|
isDragging,
|
||||||
|
isOverNested,
|
||||||
|
onSelect,
|
||||||
|
onDelete,
|
||||||
|
onReorder,
|
||||||
|
dragHandleProps,
|
||||||
|
children,
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
|
validationStatus,
|
||||||
|
}: ActionChipVisualsProps) {
|
||||||
|
const def = actionRegistry.getAction(action.type);
|
||||||
|
const style = getActionVisualStyle(action);
|
||||||
|
const Icon = style.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group relative flex w-full flex-col items-start gap-1 rounded border px-3 py-2 text-[11px] transition-all duration-200",
|
||||||
|
style.bg,
|
||||||
|
style.border,
|
||||||
|
isSelected && "ring-2 ring-primary border-primary bg-accent/50",
|
||||||
|
isDragging && "opacity-70 shadow-lg scale-95",
|
||||||
|
isOverNested && !isDragging && "ring-2 ring-blue-400 ring-offset-1 bg-blue-50/50 dark:bg-blue-900/20"
|
||||||
|
)}
|
||||||
|
onClick={onSelect}
|
||||||
|
role="button"
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{/* Accent Bar logic for control flow */}
|
||||||
|
{style.variant !== "default" && style.variant !== "robot" && (
|
||||||
|
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l", style.accent)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={cn("flex w-full items-center gap-2", style.variant !== "default" && style.variant !== "robot" && "pl-2")}>
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
{Icon && <Icon className={cn("h-3.5 w-3.5 flex-shrink-0", style.text)} />}
|
||||||
|
<span className={cn("leading-snug font-medium break-words truncate", style.text)}>
|
||||||
|
{action.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Inline Info for Control Actions */}
|
||||||
|
{style.variant === "wait" && !!action.parameters.duration && (
|
||||||
|
<span className="ml-1 text-[10px] bg-background/50 px-1.5 py-0.5 rounded font-mono text-muted-foreground">
|
||||||
|
{String(action.parameters.duration ?? "")}s
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{style.variant === "loop" && (
|
||||||
|
<span className="ml-1 text-[10px] bg-background/50 px-1.5 py-0.5 rounded font-mono text-muted-foreground">
|
||||||
|
{String(action.parameters.iterations || 1)}x
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{style.variant === "loop" && action.parameters.requireApproval !== false && (
|
||||||
|
<span className="ml-1 text-[10px] bg-purple-500/20 px-1.5 py-0.5 rounded font-mono text-purple-700 dark:text-purple-300 flex items-center gap-0.5" title="Requires Wizard Approval">
|
||||||
|
<HelpCircle className="h-2 w-2" />
|
||||||
|
Ask
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{validationStatus === "error" && (
|
||||||
|
<div className="h-2 w-2 rounded-full bg-red-500 ring-1 ring-red-600 flex-shrink-0" aria-label="Error" />
|
||||||
|
)}
|
||||||
|
{validationStatus === "warning" && (
|
||||||
|
<div className="h-2 w-2 rounded-full bg-amber-500 ring-1 ring-amber-600 flex-shrink-0" aria-label="Warning" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-0.5 mr-1 bg-background/50 rounded-md border border-border/50 shadow-sm px-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<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-destructive rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
|
aria-label="Delete action"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description / Subtext */}
|
||||||
|
{
|
||||||
|
def?.description && (
|
||||||
|
<div className={cn("text-muted-foreground line-clamp-2 w-full text-[10px] leading-snug pl-2 mt-0.5", style.variant !== "default" && style.variant !== "robot" && "pl-4")}>
|
||||||
|
{def.description}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* Tags for parameters (hide for specialized control blocks that show inline) */}
|
||||||
|
{
|
||||||
|
def?.parameters?.length && (style.variant === 'default' || style.variant === 'robot') ? (
|
||||||
|
<div className="flex flex-wrap gap-1 pt-1">
|
||||||
|
{def.parameters.slice(0, 3).map((p) => (
|
||||||
|
<span
|
||||||
|
key={p.id}
|
||||||
|
className="bg-background/80 text-muted-foreground ring-border rounded px-1 py-0.5 text-[9px] font-medium ring-1 truncate max-w-[80px]"
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{def.parameters.length > 3 && (
|
||||||
|
<span className="text-[9px] text-muted-foreground">+{def.parameters.length - 3}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div >
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SortableActionChip({
|
||||||
|
stepId,
|
||||||
|
action,
|
||||||
|
parentId,
|
||||||
|
selectedActionId,
|
||||||
|
onSelectAction,
|
||||||
|
onDeleteAction,
|
||||||
|
onReorderAction,
|
||||||
|
dragHandle,
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
|
}: ActionChipProps) {
|
||||||
|
const isSelected = selectedActionId === action.id;
|
||||||
|
|
||||||
|
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
|
||||||
|
const steps = useDesignerStore((s) => s.steps);
|
||||||
|
const currentStep = steps.find((s) => s.id === stepId);
|
||||||
|
|
||||||
|
// Branch Options Visualization
|
||||||
|
const branchOptions = useMemo(() => {
|
||||||
|
if (!action.type.includes("branch") || !currentStep) return null;
|
||||||
|
|
||||||
|
const options = (currentStep.trigger as any)?.conditions?.options;
|
||||||
|
if (!options?.length && !(currentStep.trigger as any)?.conditions?.nextStepId) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 text-muted-foreground/60 italic text-center py-2 text-[10px] bg-background/50 rounded border border-dashed">
|
||||||
|
No branches configured. Add options in properties.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine explicit options and unconditional nextStepId
|
||||||
|
// The original FlowWorkspace logic iterated options. logic there:
|
||||||
|
// (step.trigger.conditions as any).options.map...
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 space-y-1 w-full">
|
||||||
|
{options?.map((opt: any, idx: number) => {
|
||||||
|
// Resolve ID to name for display
|
||||||
|
let targetName = "Unlinked";
|
||||||
|
let targetIndex = -1;
|
||||||
|
|
||||||
|
if (opt.nextStepId) {
|
||||||
|
const target = steps.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-1.5 text-[10px]">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Badge variant="outline" className={cn(
|
||||||
|
"text-[9px] uppercase font-bold tracking-wider px-1 py-0 min-w-[60px] 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>
|
||||||
|
<ChevronRight className="h-3 w-3 text-muted-foreground/50 flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 text-right min-w-0 max-w-[60%] justify-end">
|
||||||
|
<span className="font-medium truncate text-foreground/80" title={targetName}>
|
||||||
|
{targetName}
|
||||||
|
</span>
|
||||||
|
{targetIndex !== -1 && (
|
||||||
|
<Badge variant="secondary" className="px-1 py-0 h-3.5 text-[9px] min-w-[18px] justify-center tabular-nums bg-slate-100 dark:bg-slate-800">
|
||||||
|
#{targetIndex + 1}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Visual indicator for unconditional jump if present and no options matched (though usually logic handles this) */}
|
||||||
|
{/* For now keeping parity with FlowWorkspace which only showed options */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [action.type, currentStep, steps]);
|
||||||
|
|
||||||
|
const displayChildren = useMemo(() => {
|
||||||
|
if (
|
||||||
|
insertionProjection?.stepId === stepId &&
|
||||||
|
insertionProjection.parentId === action.id
|
||||||
|
) {
|
||||||
|
const copy = [...(action.children || [])];
|
||||||
|
copy.splice(insertionProjection.index, 0, insertionProjection.action);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
return action.children || [];
|
||||||
|
}, [action.children, action.id, stepId, insertionProjection]);
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------ */
|
||||||
|
/* Main Sortable Logic */
|
||||||
|
/* ------------------------------------------------------------------------ */
|
||||||
|
const isPlaceholder = action.id === "projection-placeholder";
|
||||||
|
|
||||||
|
// 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 = dragHandle || false;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------ */
|
||||||
|
/* Nested Droppable (for control flow containers) */
|
||||||
|
/* ------------------------------------------------------------------------ */
|
||||||
|
const def = actionRegistry.getAction(action.type);
|
||||||
|
const nestedDroppableId = `container-${action.id}`;
|
||||||
|
const {
|
||||||
|
isOver: isOverNested,
|
||||||
|
setNodeRef: setNestedNodeRef
|
||||||
|
} = useDroppable({
|
||||||
|
id: nestedDroppableId,
|
||||||
|
disabled: !def?.nestable || isPlaceholder, // Disable droppable for placeholder
|
||||||
|
data: {
|
||||||
|
type: "container",
|
||||||
|
stepId,
|
||||||
|
parentId: action.id,
|
||||||
|
action // Pass full action for projection logic
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const shouldRenderChildren = !!def?.nestable;
|
||||||
|
|
||||||
|
if (isPlaceholder) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full flex-col items-start gap-1 rounded border border-dashed px-3 py-2 text-[11px]",
|
||||||
|
"bg-blue-50/50 dark:bg-blue-900/20 border-blue-400 opacity-70"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex w-full items-center gap-2">
|
||||||
|
<span className="font-medium text-blue-700 italic">
|
||||||
|
{action.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div >
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionChipVisuals
|
||||||
|
action={action}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isDragging={isDragging}
|
||||||
|
isOverNested={isOverNested && !isDragging}
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelectAction(stepId, action.id);
|
||||||
|
}}
|
||||||
|
onDelete={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteAction(stepId, action.id);
|
||||||
|
}}
|
||||||
|
onReorder={(direction) => onReorderAction?.(stepId, action.id, direction)}
|
||||||
|
isFirst={isFirst}
|
||||||
|
isLast={isLast}
|
||||||
|
validationStatus={validationStatus}
|
||||||
|
>
|
||||||
|
{/* Branch Options Visualization */}
|
||||||
|
{branchOptions}
|
||||||
|
|
||||||
|
{/* Nested Children Rendering (e.g. for Loops/Parallel) */}
|
||||||
|
{shouldRenderChildren && (
|
||||||
|
<div
|
||||||
|
ref={setNestedNodeRef}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 w-full space-y-2 rounded border border-dashed p-1.5 transition-colors",
|
||||||
|
isOverNested
|
||||||
|
? "bg-blue-100/50 dark:bg-blue-900/20 border-blue-400"
|
||||||
|
: "bg-muted/20 dark:bg-muted/10 border-border/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{displayChildren?.length === 0 ? (
|
||||||
|
<div className="py-2 text-center text-[10px] text-muted-foreground/60 italic">
|
||||||
|
Empty container
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
displayChildren?.map((child, idx) => (
|
||||||
|
<SortableActionChip
|
||||||
|
key={child.id}
|
||||||
|
stepId={stepId}
|
||||||
|
action={child}
|
||||||
|
parentId={action.id}
|
||||||
|
selectedActionId={selectedActionId}
|
||||||
|
onSelectAction={onSelectAction}
|
||||||
|
onDeleteAction={onDeleteAction}
|
||||||
|
onReorderAction={onReorderAction}
|
||||||
|
isFirst={idx === 0}
|
||||||
|
isLast={idx === (displayChildren?.length || 0) - 1}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ActionChipVisuals>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
GitBranch,
|
GitBranch,
|
||||||
Edit3,
|
Edit3,
|
||||||
CornerDownRight,
|
CornerDownRight,
|
||||||
|
Repeat,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -41,6 +42,7 @@ import { actionRegistry } from "../ActionRegistry";
|
|||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { SortableActionChip } from "./ActionChip";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FlowWorkspace
|
* FlowWorkspace
|
||||||
@@ -296,75 +298,41 @@ function StepRow({
|
|||||||
|
|
||||||
|
|
||||||
{/* Conditional Branching Visualization */}
|
{/* Conditional Branching Visualization */}
|
||||||
{/* Conditional Branching Visualization */}
|
|
||||||
{step.type === "conditional" && (
|
|
||||||
|
{/* Loop Visualization */}
|
||||||
|
{step.type === "loop" && (
|
||||||
<div className="mx-3 my-3 rounded-md border text-xs" style={{
|
<div className="mx-3 my-3 rounded-md border text-xs" style={{
|
||||||
backgroundColor: 'var(--validation-warning-bg)', // Semantic background
|
backgroundColor: 'var(--validation-info-bg, #f0f9ff)',
|
||||||
borderColor: 'var(--validation-warning-border)', // Semantic border
|
borderColor: 'var(--validation-info-border, #bae6fd)',
|
||||||
}}>
|
}}>
|
||||||
<div className="flex items-center gap-2 border-b px-3 py-2 font-medium" style={{
|
<div className="flex items-center gap-2 border-b px-3 py-2 font-medium" style={{
|
||||||
borderColor: 'var(--validation-warning-border)',
|
borderColor: 'var(--validation-info-border, #bae6fd)',
|
||||||
color: 'var(--validation-warning-text)'
|
color: 'var(--validation-info-text, #0369a1)'
|
||||||
}}>
|
}}>
|
||||||
<GitBranch className="h-3.5 w-3.5" />
|
<Repeat className="h-3.5 w-3.5" />
|
||||||
<span>Branching Logic</span>
|
<span>Loop Logic</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-2 space-y-2">
|
<div className="p-2 space-y-2">
|
||||||
{!(step.trigger.conditions as any)?.options?.length ? (
|
<div className="flex items-center gap-2 text-[11px]">
|
||||||
<div className="text-muted-foreground/60 italic text-center py-2 text-[11px]">
|
<span className="text-muted-foreground">Repeat:</span>
|
||||||
No branches configured. Add options in properties.
|
<Badge variant="outline" className="font-mono">
|
||||||
</div>
|
{(step.trigger.conditions as any).loop?.iterations || 1} times
|
||||||
) : (
|
|
||||||
(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>
|
</Badge>
|
||||||
<span className="text-muted-foreground text-[10px]">then go to</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-[11px]">
|
||||||
<div className="flex items-center gap-1.5 text-right min-w-0 max-w-[50%]">
|
<span className="text-muted-foreground">Approval:</span>
|
||||||
<span className="font-medium truncate text-[11px] block text-foreground" title={targetName}>
|
<Badge variant={(step.trigger.conditions as any).loop?.requireApproval !== false ? "default" : "secondary"}>
|
||||||
{targetName}
|
{(step.trigger.conditions as any).loop?.requireApproval !== false ? "Required" : "Auto-proceed"}
|
||||||
</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>
|
</Badge>
|
||||||
)}
|
|
||||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50 flex-shrink-0" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Action List (Collapsible/Virtual content) */}
|
{/* Action List (Collapsible/Virtual content) */}
|
||||||
{step.expanded && (
|
{step.expanded && (
|
||||||
<div className="bg-background/40 min-h-[3rem] space-y-2 p-2 pb-8">
|
<div className="bg-background/40 min-h-[3rem] space-y-2 p-2 pb-8">
|
||||||
@@ -497,330 +465,7 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* Sortable Action Chip */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* 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 isSelected = selectedActionId === action.id;
|
|
||||||
|
|
||||||
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
|
|
||||||
const displayChildren = useMemo(() => {
|
|
||||||
if (
|
|
||||||
insertionProjection?.stepId === stepId &&
|
|
||||||
insertionProjection.parentId === action.id
|
|
||||||
) {
|
|
||||||
const copy = [...(action.children || [])];
|
|
||||||
copy.splice(insertionProjection.index, 0, insertionProjection.action);
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
return action.children;
|
|
||||||
}, [action.children, action.id, stepId, insertionProjection]);
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------------ */
|
|
||||||
/* Main Sortable Logic */
|
|
||||||
/* ------------------------------------------------------------------------ */
|
|
||||||
const isPlaceholder = action.id === "projection-placeholder";
|
|
||||||
|
|
||||||
// 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 = dragHandle || false;
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
// 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,
|
|
||||||
setNodeRef: setNestedNodeRef
|
|
||||||
} = useDroppable({
|
|
||||||
id: nestedDroppableId,
|
|
||||||
disabled: !def?.nestable || isPlaceholder, // Disable droppable for placeholder
|
|
||||||
data: {
|
|
||||||
type: "container",
|
|
||||||
stepId,
|
|
||||||
parentId: action.id,
|
|
||||||
action // Pass full action for projection logic
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const shouldRenderChildren = def?.nestable;
|
|
||||||
|
|
||||||
if (isPlaceholder) {
|
|
||||||
const { setNodeRef: setPlaceholderRef } = useDroppable({
|
|
||||||
id: "projection-placeholder",
|
|
||||||
data: { type: "placeholder" }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Render simplified placeholder without hooks refs
|
|
||||||
// We still render the content matching the action type for visual fidelity
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setPlaceholderRef}
|
|
||||||
className="group relative flex w-full flex-col items-start gap-1 rounded border-2 border-dashed border-blue-300 bg-blue-50/50 px-3 py-2 text-[11px] opacity-70"
|
|
||||||
>
|
|
||||||
<div className="flex w-full items-center gap-2">
|
|
||||||
<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-gray-400"
|
|
||||||
)} />
|
|
||||||
<span className="font-medium text-foreground">{def?.name ?? action.name}</span>
|
|
||||||
</div>
|
|
||||||
{def?.description && (
|
|
||||||
<div className="text-muted-foreground line-clamp-3 w-full text-[10px] leading-snug">
|
|
||||||
{def.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
{...attributes}
|
|
||||||
>
|
|
||||||
<ActionChipVisuals
|
|
||||||
action={action}
|
|
||||||
isSelected={isSelected}
|
|
||||||
isDragging={isDragging}
|
|
||||||
isOverNested={isOverNested}
|
|
||||||
onSelect={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onSelectAction(stepId, action.id);
|
|
||||||
}}
|
|
||||||
onDelete={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDeleteAction(stepId, action.id);
|
|
||||||
}}
|
|
||||||
onReorder={(direction) => onReorderAction?.(stepId, action.id, direction)}
|
|
||||||
dragHandleProps={listeners}
|
|
||||||
isLast={isLast}
|
|
||||||
validationStatus={validationStatus}
|
|
||||||
>
|
|
||||||
{/* Nested Actions Container */}
|
|
||||||
{shouldRenderChildren && (
|
|
||||||
<div
|
|
||||||
ref={setNestedNodeRef}
|
|
||||||
className={cn(
|
|
||||||
"mt-2 w-full flex flex-col gap-2 pl-4 border-l-2 border-border/40 transition-all min-h-[0.5rem] pb-4",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={(displayChildren ?? action.children ?? [])
|
|
||||||
.filter(c => c.id !== "projection-placeholder")
|
|
||||||
.map(c => sortableActionId(c.id))}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{(displayChildren || action.children || []).map((child) => (
|
|
||||||
<SortableActionChip
|
|
||||||
key={child.id}
|
|
||||||
stepId={stepId}
|
|
||||||
action={child}
|
|
||||||
parentId={action.id}
|
|
||||||
selectedActionId={selectedActionId}
|
|
||||||
onSelectAction={onSelectAction}
|
|
||||||
onDeleteAction={onDeleteAction}
|
|
||||||
onReorderAction={onReorderAction}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{(!displayChildren?.length && !action.children?.length) && (
|
|
||||||
<div className="text-[10px] text-muted-foreground/60 italic py-1">
|
|
||||||
Drag actions here
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SortableContext>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ActionChipVisuals>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
/* FlowWorkspace Component */
|
/* FlowWorkspace Component */
|
||||||
|
|||||||
@@ -158,10 +158,17 @@ export interface DesignerState {
|
|||||||
/* Helpers */
|
/* Helpers */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
function cloneActions(actions: ExperimentAction[]): ExperimentAction[] {
|
||||||
|
return actions.map((a) => ({
|
||||||
|
...a,
|
||||||
|
children: a.children ? cloneActions(a.children) : undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function cloneSteps(steps: ExperimentStep[]): ExperimentStep[] {
|
function cloneSteps(steps: ExperimentStep[]): ExperimentStep[] {
|
||||||
return steps.map((s) => ({
|
return steps.map((s) => ({
|
||||||
...s,
|
...s,
|
||||||
actions: s.actions.map((a) => ({ ...a })),
|
actions: cloneActions(s.actions),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,8 +255,10 @@ function insertActionIntoTree(
|
|||||||
/* Store Implementation */
|
/* Store Implementation */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
export const useDesignerStore = create<DesignerState>((set, get) => ({
|
export const createDesignerStore = (props: {
|
||||||
steps: [],
|
initialSteps?: ExperimentStep[];
|
||||||
|
}) => create<DesignerState>((set, get) => ({
|
||||||
|
steps: props.initialSteps ? reindexSteps(cloneSteps(props.initialSteps)) : [],
|
||||||
dirtyEntities: new Set<string>(),
|
dirtyEntities: new Set<string>(),
|
||||||
validationIssues: {},
|
validationIssues: {},
|
||||||
actionSignatureIndex: new Map(),
|
actionSignatureIndex: new Map(),
|
||||||
@@ -541,6 +550,8 @@ export const useDesignerStore = create<DesignerState>((set, get) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const useDesignerStore = createDesignerStore({});
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
/* Convenience Selectors */
|
/* Convenience Selectors */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|||||||
294
src/components/experiments/designer/tabs/SettingsTab.tsx
Normal file
294
src/components/experiments/designer/tabs/SettingsTab.tsx
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import * as z from "zod";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "~/components/ui/form";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { experimentStatusEnum } from "~/server/db/schema";
|
||||||
|
import { Save, ExternalLink } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
name: z.string().min(2, {
|
||||||
|
message: "Name must be at least 2 characters.",
|
||||||
|
}),
|
||||||
|
description: z.string().optional(),
|
||||||
|
status: z.enum(experimentStatusEnum.enumValues),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface SettingsTabProps {
|
||||||
|
experiment: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
studyId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
study: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
designStats?: {
|
||||||
|
stepCount: number;
|
||||||
|
actionCount: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsTab({ experiment, designStats }: SettingsTabProps) {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const updateExperiment = api.experiments.update.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("Experiment settings saved successfully");
|
||||||
|
// Invalidate experiments list to refresh data
|
||||||
|
await utils.experiments.list.invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(`Error saving settings: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: experiment.name,
|
||||||
|
description: experiment.description ?? "",
|
||||||
|
status: experiment.status as z.infer<typeof formSchema>["status"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
updateExperiment.mutate({
|
||||||
|
id: experiment.id,
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
status: values.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirty = form.formState.isDirty;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto p-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Experiment Settings</h2>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Configure experiment metadata and status
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* Left Column: Basic Information (Spans 2) */}
|
||||||
|
<div className="md:col-span-2 space-y-6">
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Basic Information</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
The name and description help identify this experiment
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Experiment name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
A clear, descriptive name for your experiment
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Describe your experiment goals, methodology, and expected outcomes..."
|
||||||
|
className="resize-none min-h-[300px]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Detailed description of the experiment purpose and design
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Status & Metadata (Spans 1) */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Status Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Status</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Track lifecycle stage
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="status"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Current Status</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="draft">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary">Draft</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">WIP</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="testing">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline">Testing</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">Validation</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="ready">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="default" className="bg-green-500">Ready</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">Live</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="deprecated">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="destructive">Deprecated</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">Retired</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Metadata Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Metadata</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Read-only information
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1">Study</p>
|
||||||
|
<Link
|
||||||
|
href={`/studies/${experiment.study.id}`}
|
||||||
|
className="text-sm hover:underline flex items-center gap-1 text-primary truncate"
|
||||||
|
>
|
||||||
|
{experiment.study.name}
|
||||||
|
<ExternalLink className="h-3 w-3 flex-shrink-0" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1">Experiment ID</p>
|
||||||
|
<p className="text-xs font-mono bg-muted p-1 rounded select-all">{experiment.id.split('-')[0]}...</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1">Created</p>
|
||||||
|
<p className="text-xs">{new Date(experiment.createdAt).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1">Updated</p>
|
||||||
|
<p className="text-xs">{new Date(experiment.updatedAt).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{designStats && (
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-2">Statistics</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 bg-muted/50 px-2 py-1 rounded text-xs">
|
||||||
|
<span className="font-semibold">{designStats.stepCount}</span>
|
||||||
|
<span className="text-muted-foreground">Steps</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 bg-muted/50 px-2 py-1 rounded text-xs">
|
||||||
|
<span className="font-semibold">{designStats.actionCount}</span>
|
||||||
|
<span className="text-muted-foreground">Actions</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="flex justify-end pt-4 border-t">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={updateExperiment.isPending || !isDirty}
|
||||||
|
className="min-w-[120px]"
|
||||||
|
>
|
||||||
|
{updateExperiment.isPending ? (
|
||||||
|
"Saving..."
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
Save Changes
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Edit,
|
Edit,
|
||||||
Eye,
|
Eye,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
|
LayoutTemplate,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Play,
|
Play,
|
||||||
TestTube,
|
TestTube,
|
||||||
@@ -99,43 +100,33 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<div className="flex items-center gap-2">
|
||||||
<DropdownMenuTrigger asChild>
|
<Button
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
variant="ghost"
|
||||||
<span className="sr-only">Open menu</span>
|
size="icon"
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
asChild
|
||||||
</Button>
|
className="h-8 w-8 text-muted-foreground hover:text-primary"
|
||||||
</DropdownMenuTrigger>
|
title="Open Designer"
|
||||||
<DropdownMenuContent align="end">
|
>
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<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`}>
|
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
|
||||||
<FlaskConical className="mr-2 h-4 w-4" />
|
<LayoutTemplate className="h-4 w-4" />
|
||||||
Design
|
<span className="sr-only">Design</span>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
|
|
||||||
{experiment.canDelete && (
|
{experiment.canDelete && (
|
||||||
<>
|
<Button
|
||||||
<DropdownMenuSeparator />
|
variant="ghost"
|
||||||
<DropdownMenuItem
|
size="icon"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
className="text-red-600 focus:text-red-600"
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
|
title="Delete Experiment"
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
Delete
|
<span className="sr-only">Delete</span>
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</DropdownMenuContent>
|
</div>
|
||||||
</DropdownMenu>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
EntityForm,
|
EntityForm,
|
||||||
FormField,
|
FormField,
|
||||||
FormSection,
|
FormSection,
|
||||||
NextSteps,
|
|
||||||
Tips,
|
Tips,
|
||||||
} from "~/components/ui/entity-form";
|
} from "~/components/ui/entity-form";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
@@ -27,10 +26,113 @@ import { Textarea } from "~/components/ui/textarea";
|
|||||||
import { useStudyContext } from "~/lib/study-context";
|
import { useStudyContext } from "~/lib/study-context";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
|
import { Calendar as CalendarIcon, Clock } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Calendar } from "~/components/ui/calendar";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "~/components/ui/popover";
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
|
|
||||||
|
// Custom DatePickerTime component based on user request
|
||||||
|
function DateTimePicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: Date | undefined;
|
||||||
|
onChange: (date: Date | undefined) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
// Parse time from value or default
|
||||||
|
const timeValue = value ? format(value, "HH:mm") : "12:00";
|
||||||
|
|
||||||
|
const onDateSelect = (newDate: Date | undefined) => {
|
||||||
|
if (!newDate) {
|
||||||
|
onChange(undefined);
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve existing time or use default
|
||||||
|
const [hours, minutes] = timeValue.split(":").map(Number);
|
||||||
|
const updatedDate = new Date(newDate);
|
||||||
|
updatedDate.setHours(hours || 0);
|
||||||
|
updatedDate.setMinutes(minutes || 0);
|
||||||
|
updatedDate.setSeconds(0);
|
||||||
|
|
||||||
|
onChange(updatedDate);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newTime = e.target.value;
|
||||||
|
if (!value) return; // Can't set time without date
|
||||||
|
|
||||||
|
const [hours, minutes] = newTime.split(":").map(Number);
|
||||||
|
const updatedDate = new Date(value);
|
||||||
|
updatedDate.setHours(hours || 0);
|
||||||
|
updatedDate.setMinutes(minutes || 0);
|
||||||
|
updatedDate.setSeconds(0);
|
||||||
|
|
||||||
|
onChange(updatedDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="date-picker" className="text-xs">Date</Label>
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={"outline"}
|
||||||
|
id="date-picker"
|
||||||
|
className={cn(
|
||||||
|
"w-[240px] justify-start text-left font-normal",
|
||||||
|
!value && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{value ? format(value, "PPP") : <span>Pick a date</span>}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={value}
|
||||||
|
onSelect={onDateSelect}
|
||||||
|
initialFocus
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="time-picker" className="text-xs">Time</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="time-picker"
|
||||||
|
type="time"
|
||||||
|
value={timeValue}
|
||||||
|
onChange={onTimeChange}
|
||||||
|
disabled={!value}
|
||||||
|
className="w-[120px]"
|
||||||
|
/>
|
||||||
|
<Clock className="absolute right-3 top-2.5 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const trialSchema = z.object({
|
const trialSchema = z.object({
|
||||||
experimentId: z.string().uuid("Please select an experiment"),
|
experimentId: z.string().uuid("Please select an experiment"),
|
||||||
participantId: z.string().uuid("Please select a participant"),
|
participantId: z.string().uuid("Please select a participant"),
|
||||||
scheduledAt: z.string().min(1, "Please select a date and time"),
|
scheduledAt: z.date(),
|
||||||
wizardId: z.string().uuid().optional(),
|
wizardId: z.string().uuid().optional(),
|
||||||
notes: z.string().max(1000, "Notes cannot exceed 1000 characters").optional(),
|
notes: z.string().max(1000, "Notes cannot exceed 1000 characters").optional(),
|
||||||
sessionNumber: z
|
sessionNumber: z
|
||||||
@@ -52,7 +154,6 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
const { selectedStudyId } = useStudyContext();
|
const { selectedStudyId } = useStudyContext();
|
||||||
const contextStudyId = studyId ?? selectedStudyId;
|
const contextStudyId = studyId ?? selectedStudyId;
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [isDeleting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const form = useForm<TrialFormData>({
|
const form = useForm<TrialFormData>({
|
||||||
@@ -90,6 +191,22 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
const { data: usersData, isLoading: usersLoading } =
|
const { data: usersData, isLoading: usersLoading } =
|
||||||
api.users.getWizards.useQuery();
|
api.users.getWizards.useQuery();
|
||||||
|
|
||||||
|
// Auto-increment session number
|
||||||
|
const selectedParticipantId = form.watch("participantId");
|
||||||
|
const { data: latestSession } = api.trials.getLatestSession.useQuery(
|
||||||
|
{ participantId: selectedParticipantId },
|
||||||
|
{
|
||||||
|
enabled: !!selectedParticipantId && mode === "create",
|
||||||
|
refetchOnWindowFocus: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (latestSession !== undefined && mode === "create") {
|
||||||
|
form.setValue("sessionNumber", latestSession + 1);
|
||||||
|
}
|
||||||
|
}, [latestSession, mode, form]);
|
||||||
|
|
||||||
// Set breadcrumbs
|
// Set breadcrumbs
|
||||||
const breadcrumbs = [
|
const breadcrumbs = [
|
||||||
{ label: "Dashboard", href: "/dashboard" },
|
{ label: "Dashboard", href: "/dashboard" },
|
||||||
@@ -133,9 +250,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
form.reset({
|
form.reset({
|
||||||
experimentId: trial.experimentId,
|
experimentId: trial.experimentId,
|
||||||
participantId: trial?.participantId ?? "",
|
participantId: trial?.participantId ?? "",
|
||||||
scheduledAt: trial.scheduledAt
|
scheduledAt: trial.scheduledAt ? new Date(trial.scheduledAt) : undefined,
|
||||||
? new Date(trial.scheduledAt).toISOString().slice(0, 16)
|
|
||||||
: "",
|
|
||||||
wizardId: trial.wizardId ?? undefined,
|
wizardId: trial.wizardId ?? undefined,
|
||||||
notes: trial.notes ?? "",
|
notes: trial.notes ?? "",
|
||||||
sessionNumber: trial.sessionNumber ?? 1,
|
sessionNumber: trial.sessionNumber ?? 1,
|
||||||
@@ -153,24 +268,26 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (mode === "create") {
|
if (mode === "create") {
|
||||||
const newTrial = await createTrialMutation.mutateAsync({
|
await createTrialMutation.mutateAsync({
|
||||||
experimentId: data.experimentId,
|
experimentId: data.experimentId,
|
||||||
participantId: data.participantId,
|
participantId: data.participantId,
|
||||||
scheduledAt: new Date(data.scheduledAt),
|
scheduledAt: data.scheduledAt,
|
||||||
wizardId: data.wizardId,
|
wizardId: data.wizardId,
|
||||||
sessionNumber: data.sessionNumber ?? 1,
|
sessionNumber: data.sessionNumber ?? 1,
|
||||||
notes: data.notes ?? undefined,
|
notes: data.notes ?? undefined,
|
||||||
});
|
});
|
||||||
router.push(`/studies/${contextStudyId}/trials/${newTrial!.id}`);
|
// Redirect to trials table instead of detail page
|
||||||
|
router.push(`/studies/${contextStudyId}/trials`);
|
||||||
} else {
|
} else {
|
||||||
const updatedTrial = await updateTrialMutation.mutateAsync({
|
await updateTrialMutation.mutateAsync({
|
||||||
id: trialId!,
|
id: trialId!,
|
||||||
scheduledAt: new Date(data.scheduledAt),
|
scheduledAt: data.scheduledAt,
|
||||||
wizardId: data.wizardId,
|
wizardId: data.wizardId,
|
||||||
sessionNumber: data.sessionNumber ?? 1,
|
sessionNumber: data.sessionNumber ?? 1,
|
||||||
notes: data.notes ?? undefined,
|
notes: data.notes ?? undefined,
|
||||||
});
|
});
|
||||||
router.push(`/studies/${contextStudyId}/trials/${updatedTrial!.id}`);
|
// Redirect to trials table on update too
|
||||||
|
router.push(`/studies/${contextStudyId}/trials`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(
|
setError(
|
||||||
@@ -181,9 +298,6 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delete handler (trials cannot be deleted in this version)
|
|
||||||
const onDelete = undefined;
|
|
||||||
|
|
||||||
// Loading state for edit mode
|
// Loading state for edit mode
|
||||||
if (mode === "edit" && isLoading) {
|
if (mode === "edit" && isLoading) {
|
||||||
return <div>Loading trial...</div>;
|
return <div>Loading trial...</div>;
|
||||||
@@ -194,13 +308,36 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
return <div>Error loading trial: {fetchError.message}</div>;
|
return <div>Error loading trial: {fetchError.message}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Form fields
|
return (
|
||||||
const formFields = (
|
<EntityForm
|
||||||
<>
|
mode={mode}
|
||||||
<FormSection
|
entityName="Trial"
|
||||||
title="Trial Setup"
|
entityNamePlural="Trials"
|
||||||
description="Configure the basic details for this experimental trial."
|
backUrl={`/studies/${contextStudyId}/trials`}
|
||||||
|
listUrl={`/studies/${contextStudyId}/trials`}
|
||||||
|
title={
|
||||||
|
mode === "create"
|
||||||
|
? "Schedule New Trial"
|
||||||
|
: `Edit ${trial ? `Trial ${trial.sessionNumber || trial.id.slice(-8)}` : "Trial"}`
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
mode === "create"
|
||||||
|
? "Schedule a new experimental trial with a participant"
|
||||||
|
: "Update trial scheduling and assignment details"
|
||||||
|
}
|
||||||
|
icon={TestTube}
|
||||||
|
form={form}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
error={error}
|
||||||
|
sidebar={undefined}
|
||||||
|
submitText={mode === "create" ? "Schedule Trial" : "Save Changes"}
|
||||||
|
layout="full-width"
|
||||||
>
|
>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* Left Column: Main Info (Spans 2) */}
|
||||||
|
<div className="md:col-span-2 space-y-6">
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
<FormField>
|
<FormField>
|
||||||
<Label htmlFor="experimentId">Experiment *</Label>
|
<Label htmlFor="experimentId">Experiment *</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -281,16 +418,20 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
<FormField>
|
<FormField>
|
||||||
<Label htmlFor="scheduledAt">Scheduled Date & Time *</Label>
|
<Label htmlFor="scheduledAt">Scheduled Date & Time *</Label>
|
||||||
<Input
|
<Controller
|
||||||
id="scheduledAt"
|
control={form.control}
|
||||||
type="datetime-local"
|
name="scheduledAt"
|
||||||
{...form.register("scheduledAt")}
|
render={({ field }) => (
|
||||||
className={
|
<DateTimePicker
|
||||||
form.formState.errors.scheduledAt ? "border-red-500" : ""
|
value={field.value}
|
||||||
}
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
{form.formState.errors.scheduledAt && (
|
{form.formState.errors.scheduledAt && (
|
||||||
<p className="text-sm text-red-600">
|
<p className="text-sm text-red-600">
|
||||||
@@ -320,15 +461,14 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
Session number for this participant (for multi-session studies)
|
Auto-incremented based on participant history
|
||||||
</p>
|
</p>
|
||||||
</FormField>
|
</FormField>
|
||||||
</FormSection>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormSection
|
{/* Right Column: Assignment & Notes (Spans 1) */}
|
||||||
title="Assignment & Notes"
|
<div className="space-y-6">
|
||||||
description="Optional wizard assignment and trial-specific notes."
|
|
||||||
>
|
|
||||||
<FormField>
|
<FormField>
|
||||||
<Label htmlFor="wizardId">Assigned Wizard</Label>
|
<Label htmlFor="wizardId">Assigned Wizard</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -359,17 +499,17 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
Optional: Assign a specific wizard to operate this trial
|
Who will operate the robot?
|
||||||
</p>
|
</p>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField>
|
<FormField>
|
||||||
<Label htmlFor="notes">Trial Notes</Label>
|
<Label htmlFor="notes">Notes</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="notes"
|
id="notes"
|
||||||
{...form.register("notes")}
|
{...form.register("notes")}
|
||||||
placeholder="Special instructions, conditions, or notes for this trial..."
|
placeholder="Special instructions..."
|
||||||
rows={3}
|
rows={5}
|
||||||
className={form.formState.errors.notes ? "border-red-500" : ""}
|
className={form.formState.errors.notes ? "border-red-500" : ""}
|
||||||
/>
|
/>
|
||||||
{form.formState.errors.notes && (
|
{form.formState.errors.notes && (
|
||||||
@@ -377,80 +517,9 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
{form.formState.errors.notes.message}
|
{form.formState.errors.notes.message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Optional: Notes about special conditions, instructions, or context
|
|
||||||
for this trial
|
|
||||||
</p>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
</FormSection>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
|
||||||
|
|
||||||
// Sidebar content
|
|
||||||
const sidebar = (
|
|
||||||
<>
|
|
||||||
<NextSteps
|
|
||||||
steps={[
|
|
||||||
{
|
|
||||||
title: "Execute Trial",
|
|
||||||
description: "Use the wizard interface to run the trial",
|
|
||||||
completed: mode === "edit",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Monitor Progress",
|
|
||||||
description: "Track trial execution and data collection",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Review Data",
|
|
||||||
description: "Analyze collected trial data and results",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Generate Reports",
|
|
||||||
description: "Export data and create analysis reports",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Tips
|
|
||||||
tips={[
|
|
||||||
"Schedule ahead: Allow sufficient time between trials for setup and data review.",
|
|
||||||
"Assign wizards: Pre-assign experienced wizards to complex trials.",
|
|
||||||
"Document conditions: Use notes to record any special circumstances or variations.",
|
|
||||||
"Test connectivity: Verify robot and system connections before scheduled trials.",
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EntityForm
|
|
||||||
mode={mode}
|
|
||||||
entityName="Trial"
|
|
||||||
entityNamePlural="Trials"
|
|
||||||
backUrl={`/studies/${contextStudyId}/trials`}
|
|
||||||
listUrl={`/studies/${contextStudyId}/trials`}
|
|
||||||
title={
|
|
||||||
mode === "create"
|
|
||||||
? "Schedule New Trial"
|
|
||||||
: `Edit ${trial ? `Trial ${trial.sessionNumber || trial.id.slice(-8)}` : "Trial"}`
|
|
||||||
}
|
|
||||||
description={
|
|
||||||
mode === "create"
|
|
||||||
? "Schedule a new experimental trial with a participant"
|
|
||||||
: "Update trial scheduling and assignment details"
|
|
||||||
}
|
|
||||||
icon={TestTube}
|
|
||||||
form={form}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
error={error}
|
|
||||||
onDelete={
|
|
||||||
mode === "edit" && trial?.status === "scheduled" ? onDelete : undefined
|
|
||||||
}
|
|
||||||
isDeleting={isDeleting}
|
|
||||||
sidebar={sidebar}
|
|
||||||
submitText={mode === "create" ? "Schedule Trial" : "Save Changes"}
|
|
||||||
>
|
|
||||||
{formFields}
|
|
||||||
</EntityForm>
|
</EntityForm>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { type ColumnDef } from "@tanstack/react-table";
|
import { type ColumnDef } from "@tanstack/react-table";
|
||||||
import { ArrowUpDown, ChevronDown, MoreHorizontal, Play, Gamepad2, LineChart, Ban } from "lucide-react";
|
import { ArrowUpDown, ChevronDown, MoreHorizontal, Play, Gamepad2, LineChart, Ban, Printer } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { format, formatDistanceToNow } from "date-fns";
|
import { format, formatDistanceToNow } from "date-fns";
|
||||||
@@ -108,10 +108,25 @@ export const columns: ColumnDef<Trial>[] = [
|
|||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const sessionNumber = row.getValue("sessionNumber");
|
const sessionNumber = row.getValue("sessionNumber");
|
||||||
|
const status = row.original.status;
|
||||||
|
const trialId = row.original.id;
|
||||||
|
const studyId = row.original.studyId;
|
||||||
|
|
||||||
|
let href = `/studies/${studyId}/trials/${trialId}`; // Fallback
|
||||||
|
if (status === "scheduled" || status === "in_progress") {
|
||||||
|
href = `/studies/${studyId}/trials/${trialId}/wizard`;
|
||||||
|
} else if (status === "completed") {
|
||||||
|
href = `/studies/${studyId}/trials/${trialId}/analysis`;
|
||||||
|
} else {
|
||||||
|
// for aborted/failed, maybe still link to detail or nowhere?
|
||||||
|
// Let's keep detail for now as a fallback for metadata
|
||||||
|
href = `/studies/${studyId}/trials/${trialId}`;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="font-mono text-sm">
|
<div className="font-mono text-sm">
|
||||||
<Link
|
<Link
|
||||||
href={`/studies/${row.original.studyId}/trials/${row.original.id}`}
|
href={href}
|
||||||
className="hover:underline"
|
className="hover:underline"
|
||||||
>
|
>
|
||||||
#{Number(sessionNumber)}
|
#{Number(sessionNumber)}
|
||||||
@@ -343,63 +358,52 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
|
|||||||
const trial = row.original;
|
const trial = row.original;
|
||||||
// ActionsCell is a component rendered by the table.
|
// ActionsCell is a component rendered by the table.
|
||||||
|
|
||||||
// importing useRouter is fine.
|
|
||||||
|
|
||||||
const utils = api.useUtils();
|
|
||||||
const duplicateMutation = api.trials.duplicate.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.trials.list.invalidate();
|
|
||||||
// toast.success("Trial duplicated"); // We need toast
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!trial?.id) {
|
if (!trial?.id) {
|
||||||
return <span className="text-muted-foreground text-sm">No actions</span>;
|
return <span className="text-muted-foreground text-sm">No actions</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<div className="flex items-center gap-2 justify-end">
|
||||||
<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>
|
|
||||||
{trial.status === "scheduled" && (
|
{trial.status === "scheduled" && (
|
||||||
<DropdownMenuItem asChild>
|
<Button size="sm" asChild>
|
||||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<Play className="mr-1.5 h-3.5 w-3.5" />
|
||||||
Start Trial
|
Start
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{trial.status === "in_progress" && (
|
{trial.status === "in_progress" && (
|
||||||
<DropdownMenuItem asChild>
|
<Button size="sm" variant="secondary" asChild>
|
||||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
||||||
<Gamepad2 className="mr-2 h-4 w-4" />
|
<Gamepad2 className="mr-1.5 h-3.5 w-3.5" />
|
||||||
Control Trial
|
Control
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{trial.status === "completed" && (
|
{trial.status === "completed" && (
|
||||||
<DropdownMenuItem asChild>
|
<>
|
||||||
|
<Button size="sm" variant="outline" asChild>
|
||||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/analysis`}>
|
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/analysis`}>
|
||||||
<LineChart className="mr-2 h-4 w-4" />
|
<LineChart className="mr-1.5 h-3.5 w-3.5" />
|
||||||
Analysis
|
View
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" asChild>
|
||||||
|
{/* We link to the analysis page with a query param to trigger print/export */}
|
||||||
|
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/analysis?export=true`}>
|
||||||
|
<Printer className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Export
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{(trial.status === "scheduled" || trial.status === "failed") && (
|
{(trial.status === "scheduled" || trial.status === "failed") && (
|
||||||
<DropdownMenuItem className="text-red-600">
|
<Button size="sm" variant="ghost" className="h-8 w-8 p-0 text-muted-foreground hover:text-red-600">
|
||||||
<Ban className="mr-2 h-4 w-4" />
|
<Ban className="h-4 w-4" />
|
||||||
Cancel
|
<span className="sr-only">Cancel</span>
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuContent>
|
</div>
|
||||||
</DropdownMenu>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Events</SelectItem>
|
<SelectItem value="all">All Events</SelectItem>
|
||||||
|
<SelectItem value="action_executed">Actions</SelectItem>
|
||||||
|
<SelectItem value="action_skipped">Skipped Actions</SelectItem>
|
||||||
<SelectItem value="intervention">Interventions</SelectItem>
|
<SelectItem value="intervention">Interventions</SelectItem>
|
||||||
<SelectItem value="robot">Robot Actions</SelectItem>
|
<SelectItem value="robot">Robot Actions</SelectItem>
|
||||||
<SelectItem value="step">Step Changes</SelectItem>
|
<SelectItem value="step">Step Changes</SelectItem>
|
||||||
|
|||||||
@@ -73,8 +73,7 @@ export function EventTimeline() {
|
|||||||
|
|
||||||
const currentProgress = (currentTime * 1000 / effectiveDuration) * 100;
|
const currentProgress = (currentTime * 1000 / effectiveDuration) * 100;
|
||||||
|
|
||||||
// Generate ticks for "number line" look
|
// Generate ticks
|
||||||
// We want a major tick every ~10% or meaningful time interval
|
|
||||||
const ticks = useMemo(() => {
|
const ticks = useMemo(() => {
|
||||||
const count = 10;
|
const count = 10;
|
||||||
return Array.from({ length: count + 1 }).map((_, i) => ({
|
return Array.from({ length: count + 1 }).map((_, i) => ({
|
||||||
@@ -84,106 +83,75 @@ export function EventTimeline() {
|
|||||||
}, [effectiveDuration]);
|
}, [effectiveDuration]);
|
||||||
|
|
||||||
const getEventIcon = (type: string) => {
|
const getEventIcon = (type: string) => {
|
||||||
if (type.includes("intervention") || type.includes("wizard")) return <User className="h-3 w-3" />;
|
if (type.includes("intervention") || type.includes("wizard")) return <User className="h-4 w-4" />;
|
||||||
if (type.includes("robot") || type.includes("action")) return <Bot className="h-3 w-3" />;
|
if (type.includes("robot") || type.includes("action")) return <Bot className="h-4 w-4" />;
|
||||||
if (type.includes("completed")) return <CheckCircle className="h-3 w-3" />;
|
if (type.includes("completed")) return <CheckCircle className="h-4 w-4" />;
|
||||||
if (type.includes("start")) return <Flag className="h-3 w-3" />;
|
if (type.includes("start")) return <Flag className="h-4 w-4" />;
|
||||||
if (type.includes("note")) return <MessageSquare className="h-3 w-3" />;
|
if (type.includes("note")) return <MessageSquare className="h-4 w-4" />;
|
||||||
if (type.includes("error")) return <AlertTriangle className="h-3 w-3" />;
|
if (type.includes("error")) return <AlertTriangle className="h-4 w-4" />;
|
||||||
return <Activity className="h-3 w-3" />;
|
return <Activity className="h-4 w-4" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getEventColor = (type: string) => {
|
const getEventColor = (type: string) => {
|
||||||
if (type.includes("intervention") || type.includes("wizard")) return "text-orange-500 border-orange-200 bg-orange-50";
|
if (type.includes("intervention") || type.includes("wizard")) return "bg-orange-100 text-orange-600 border-orange-200";
|
||||||
if (type.includes("robot") || type.includes("action")) return "text-purple-500 border-purple-200 bg-purple-50";
|
if (type.includes("robot") || type.includes("action")) return "bg-purple-100 text-purple-600 border-purple-200";
|
||||||
if (type.includes("completed")) return "text-green-500 border-green-200 bg-green-50";
|
if (type.includes("completed")) return "bg-green-100 text-green-600 border-green-200";
|
||||||
if (type.includes("start")) return "text-blue-500 border-blue-200 bg-blue-50";
|
if (type.includes("start")) return "bg-blue-100 text-blue-600 border-blue-200";
|
||||||
if (type.includes("error")) return "text-red-500 border-red-200 bg-red-50";
|
if (type.includes("error")) return "bg-red-100 text-red-600 border-red-200";
|
||||||
return "text-slate-500 border-slate-200 bg-slate-50";
|
return "bg-slate-100 text-slate-600 border-slate-200";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex flex-col select-none py-2">
|
<div className="w-full h-28 flex flex-col justify-center px-8 select-none">
|
||||||
<TooltipProvider>
|
<TooltipProvider delayDuration={0}>
|
||||||
{/* Timeline Track Container */}
|
{/* Main Interactive Area */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative w-full flex-1 min-h-[80px] group cursor-crosshair border-b border-border/50"
|
className="relative w-full h-16 flex items-center cursor-pointer group"
|
||||||
onClick={handleSeek}
|
onClick={handleSeek}
|
||||||
>
|
>
|
||||||
{/* Background Grid/Ticks */}
|
{/* The Timeline Line (Horizontal) */}
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
<div className="absolute left-0 right-0 h-0.5 top-1/2 -mt-px bg-border group-hover:bg-border/80 transition-colors" />
|
||||||
{/* Major Ticks */}
|
|
||||||
{ticks.map((tick, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="absolute top-0 bottom-0 border-l border-border/30 flex flex-col justify-end"
|
|
||||||
style={{ left: `${tick.pct}%` }}
|
|
||||||
>
|
|
||||||
<span className="text-[10px] font-mono text-muted-foreground -ml-3 mb-1 bg-background/80 px-1 rounded">
|
|
||||||
{tick.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Central Axis Line */}
|
{/* Progress Fill */}
|
||||||
<div className="absolute top-1/2 left-0 right-0 h-px bg-border z-0" />
|
|
||||||
|
|
||||||
{/* Progress Fill (Subtle) */}
|
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 bottom-0 left-0 bg-primary/5 z-0 pointer-events-none"
|
className="absolute left-0 h-0.5 bg-primary/30 pointer-events-none"
|
||||||
style={{ width: `${currentProgress}%` }}
|
style={{ width: `${currentProgress}%`, top: '50%', marginTop: '-1px' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Playhead */}
|
{/* Playhead (Scanner) */}
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 bottom-0 w-px bg-red-500 z-30 pointer-events-none transition-all duration-75"
|
className="absolute h-16 w-px bg-red-500 z-30 pointer-events-none transition-all duration-75"
|
||||||
style={{ left: `${currentProgress}%` }}
|
style={{ left: `${currentProgress}%`, top: '50%', transform: 'translateY(-50%)' }}
|
||||||
>
|
>
|
||||||
<div className="absolute -top-1 -ml-1.5 p-0.5 bg-red-500 rounded text-[8px] font-bold text-white w-3 h-3 flex items-center justify-center">
|
{/* Knob */}
|
||||||
▼
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-3 h-3 bg-red-500 rounded-full shadow border border-white" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Events "Lollipops" */}
|
{/* Events (Avatars/Dots) */}
|
||||||
{sortedEvents.map((event, i) => {
|
{sortedEvents.map((event, i) => {
|
||||||
const pct = getPercentage(new Date(event.timestamp).getTime());
|
const pct = getPercentage(new Date(event.timestamp).getTime());
|
||||||
const isTop = i % 2 === 0; // Stagger events top/bottom
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip key={i}>
|
<Tooltip key={i}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className="absolute z-20 flex flex-col items-center group/event"
|
className="absolute z-20 top-1/2 left-0 transform -translate-x-1/2 -translate-y-1/2 flex flex-col items-center group/event"
|
||||||
style={{
|
style={{ left: `${pct}%` }}
|
||||||
left: `${pct}%`,
|
|
||||||
top: '50%',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
height: '100%'
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
seekTo((new Date(event.timestamp).getTime() - startTime) / 1000);
|
seekTo((new Date(event.timestamp).getTime() - startTime) / 1000);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* The Stem */}
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"w-px transition-all duration-200 bg-border group-hover/event:bg-primary group-hover/event:h-full",
|
"flex h-8 w-8 items-center justify-center rounded-full border shadow-sm transition-transform hover:scale-125 hover:z-50 bg-background relative z-20",
|
||||||
isTop ? "h-8 mb-auto" : "h-8 mt-auto"
|
getEventColor(event.eventType)
|
||||||
)} />
|
|
||||||
|
|
||||||
{/* The Node */}
|
|
||||||
<div className={cn(
|
|
||||||
"absolute w-6 h-6 rounded-full border shadow-sm flex items-center justify-center transition-transform hover:scale-110 cursor-pointer bg-background z-10",
|
|
||||||
getEventColor(event.eventType),
|
|
||||||
isTop ? "-top-2" : "-bottom-2"
|
|
||||||
)}>
|
)}>
|
||||||
{getEventIcon(event.eventType)}
|
{getEventIcon(event.eventType)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side={isTop ? "top" : "bottom"}>
|
<TooltipContent side="top">
|
||||||
<div className="text-xs font-semibold uppercase tracking-wider mb-0.5">{event.eventType.replace(/_/g, " ")}</div>
|
<div className="text-xs font-semibold uppercase tracking-wider mb-0.5">{event.eventType.replace(/_/g, " ")}</div>
|
||||||
<div className="text-[10px] font-mono opacity-70 mb-1">
|
<div className="text-[10px] font-mono opacity-70 mb-1">
|
||||||
{new Date(event.timestamp).toLocaleTimeString()}
|
{new Date(event.timestamp).toLocaleTimeString()}
|
||||||
@@ -197,9 +165,21 @@ export function EventTimeline() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Ticks (Below) */}
|
||||||
|
{ticks.map((tick, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="absolute top-10 text-[10px] font-mono text-muted-foreground transform -translate-x-1/2 pointer-events-none flex flex-col items-center"
|
||||||
|
style={{ left: `${tick.pct}%` }}
|
||||||
|
>
|
||||||
|
{/* Tick Mark */}
|
||||||
|
<div className="w-px h-2 bg-border mb-1" />
|
||||||
|
{tick.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
|
||||||
|
import { PageHeader } from "~/components/ui/page-header";
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { LineChart, BarChart, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info, Bot, Activity, ArrowLeft } from "lucide-react";
|
import { LineChart, BarChart, Printer, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info, Bot, Activity, ArrowLeft } from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { PlaybackProvider } from "../playback/PlaybackContext";
|
import { PlaybackProvider } from "../playback/PlaybackContext";
|
||||||
import { PlaybackPlayer } from "../playback/PlaybackPlayer";
|
import { PlaybackPlayer } from "../playback/PlaybackPlayer";
|
||||||
import { EventTimeline } from "../playback/EventTimeline";
|
import { EventTimeline } from "../playback/EventTimeline";
|
||||||
@@ -25,7 +27,7 @@ interface TrialAnalysisViewProps {
|
|||||||
startedAt: Date | null;
|
startedAt: Date | null;
|
||||||
completedAt: Date | null;
|
completedAt: Date | null;
|
||||||
duration: number | null;
|
duration: number | null;
|
||||||
experiment: { name: string };
|
experiment: { name: string; studyId: string };
|
||||||
participant: { participantCode: string };
|
participant: { participantCode: string };
|
||||||
eventCount?: number;
|
eventCount?: number;
|
||||||
mediaCount?: number;
|
mediaCount?: number;
|
||||||
@@ -41,6 +43,17 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
|||||||
limit: 1000
|
limit: 1000
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-print effect
|
||||||
|
useEffect(() => {
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
if (searchParams.get('export') === 'true') {
|
||||||
|
// Small delay to ensure rendering
|
||||||
|
setTimeout(() => {
|
||||||
|
window.print();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const videoMedia = trial.media?.find(m => m.contentType.startsWith("video/"));
|
const videoMedia = trial.media?.find(m => m.contentType.startsWith("video/"));
|
||||||
const videoUrl = videoMedia?.url;
|
const videoUrl = videoMedia?.url;
|
||||||
|
|
||||||
@@ -51,50 +64,130 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}>
|
<PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}>
|
||||||
<div className="flex h-full flex-col gap-4 p-4 text-sm">
|
<div id="trial-analysis-content" className="flex h-full flex-col gap-4 p-4 text-sm">
|
||||||
{/* Header Context */}
|
{/* Header Context */}
|
||||||
<div className="flex items-center justify-between pb-2 border-b">
|
<PageHeader
|
||||||
<div className="flex items-center gap-4">
|
title={trial.experiment.name}
|
||||||
<Button variant="ghost" size="icon" asChild className="-ml-2">
|
description={`Session ${trial.id.slice(0, 8)} • ${trial.startedAt?.toLocaleDateString() ?? 'Unknown Date'} ${trial.startedAt?.toLocaleTimeString() ?? ''}`}
|
||||||
<Link href={backHref}>
|
badges={[
|
||||||
<ArrowLeft className="h-4 w-4" />
|
{
|
||||||
</Link>
|
label: trial.status.toUpperCase(),
|
||||||
|
variant: trial.status === 'completed' ? 'default' : 'secondary',
|
||||||
|
className: trial.status === 'completed' ? 'bg-green-500 hover:bg-green-600' : ''
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<style jsx global>{`
|
||||||
|
@media print {
|
||||||
|
@page {
|
||||||
|
size: auto;
|
||||||
|
margin: 15mm;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: white;
|
||||||
|
color: black;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
/* Hide everything by default */
|
||||||
|
body * {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
/* Show only our content */
|
||||||
|
#trial-analysis-content, #trial-analysis-content * {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
#trial-analysis-content {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide specific non-printable elements */
|
||||||
|
#tour-trial-video,
|
||||||
|
button,
|
||||||
|
.no-print,
|
||||||
|
[role="dialog"],
|
||||||
|
header,
|
||||||
|
nav {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust Metrics for Print */
|
||||||
|
#tour-trial-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
#tour-trial-metrics .rounded-xl {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expand Timeline */
|
||||||
|
.h-28 {
|
||||||
|
height: 120px !important;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove Panel Resizing constraints */
|
||||||
|
[data-panel-group-direction="vertical"] {
|
||||||
|
flex-direction: column !important;
|
||||||
|
display: block !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
[data-panel] {
|
||||||
|
flex: none !important;
|
||||||
|
height: auto !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
[data-panel-resize-handle] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table Styles: Clean & Full Width */
|
||||||
|
#tour-trial-events {
|
||||||
|
display: block !important;
|
||||||
|
border: none !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
#tour-trial-events [data-radix-scroll-area-viewport] {
|
||||||
|
overflow: visible !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
/* Hide "Filter" input wrapper if visible */
|
||||||
|
#tour-trial-events .border-b {
|
||||||
|
border-bottom: 2px solid #000 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() => window.print()}
|
||||||
|
>
|
||||||
|
<Printer className="h-4 w-4" />
|
||||||
|
Export Report
|
||||||
</Button>
|
</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-lg font-semibold leading-none tracking-tight">
|
|
||||||
{trial.experiment.name}
|
|
||||||
</h1>
|
|
||||||
<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>
|
|
||||||
</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 className="text-xs font-mono">
|
|
||||||
{trial.startedAt?.toLocaleDateString()} {trial.startedAt?.toLocaleTimeString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Metrics Header */}
|
{/* Metrics Header */}
|
||||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4" id="tour-trial-metrics">
|
<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">
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
<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>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Duration</CardTitle>
|
||||||
<Clock className="h-4 w-4 text-blue-500" />
|
<Clock className="h-4 w-4 text-blue-500" />
|
||||||
@@ -111,7 +204,7 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-gradient-to-br from-purple-50 to-transparent dark:from-purple-950/20">
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
<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>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Robot Actions</CardTitle>
|
||||||
<Bot className="h-4 w-4 text-purple-500" />
|
<Bot className="h-4 w-4 text-purple-500" />
|
||||||
@@ -122,7 +215,7 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-gradient-to-br from-orange-50 to-transparent dark:from-orange-950/20">
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
<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>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Interventions</CardTitle>
|
||||||
<AlertTriangle className="h-4 w-4 text-orange-500" />
|
<AlertTriangle className="h-4 w-4 text-orange-500" />
|
||||||
@@ -133,7 +226,7 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-gradient-to-br from-green-50 to-transparent dark:from-green-950/20">
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
<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>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Completeness</CardTitle>
|
||||||
<Activity className="h-4 w-4 text-green-500" />
|
<Activity className="h-4 w-4 text-green-500" />
|
||||||
@@ -154,47 +247,48 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Workspace: Vertical Layout */}
|
{/* Main Workspace: Vertical Layout */}
|
||||||
<div className="flex-1 min-h-0 rounded-xl border shadow-sm overflow-hidden bg-background">
|
<div className="flex-1 min-h-0 rounded-xl border shadow-sm overflow-hidden bg-background flex flex-col">
|
||||||
|
|
||||||
|
{/* FIXED TIMELINE: Always visible at top */}
|
||||||
|
<div className="shrink-0 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 p-1">
|
||||||
|
<EventTimeline />
|
||||||
|
</div>
|
||||||
|
|
||||||
<ResizablePanelGroup direction="vertical">
|
<ResizablePanelGroup direction="vertical">
|
||||||
|
|
||||||
{/* TOP: Video & Timeline */}
|
{/* TOP: Video (Optional) */}
|
||||||
<ResizablePanel defaultSize={50} minSize={30} className="flex flex-col min-h-0 bg-black/5 dark:bg-black/40" id="tour-trial-timeline">
|
{videoUrl && (
|
||||||
|
<>
|
||||||
|
<ResizablePanel defaultSize={40} minSize={20} className="flex flex-col min-h-0 bg-black/5 dark:bg-black/40" id="tour-trial-video">
|
||||||
<div className="relative flex-1 min-h-0 flex items-center justify-center">
|
<div className="relative flex-1 min-h-0 flex items-center justify-center">
|
||||||
{videoUrl ? (
|
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<PlaybackPlayer src={videoUrl} />
|
<PlaybackPlayer src={videoUrl} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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>
|
</ResizablePanel>
|
||||||
|
|
||||||
<ResizableHandle withHandle className="bg-border/50" />
|
<ResizableHandle withHandle className="bg-border/50" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* BOTTOM: Events Table */}
|
{/* BOTTOM: Events Table */}
|
||||||
<ResizablePanel defaultSize={50} minSize={20} className="flex flex-col min-h-0 bg-background" id="tour-trial-events">
|
<ResizablePanel defaultSize={videoUrl ? 60 : 100} 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 justify-between px-4 py-3 border-b shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileText className="h-4 w-4 text-primary" />
|
<FileText className="h-4 w-4 text-primary" />
|
||||||
<h3 className="font-semibold text-sm">Event Log</h3>
|
<h3 className="font-semibold text-sm">Event Log</h3>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Filter events..."
|
||||||
|
className="h-8 w-[200px]"
|
||||||
|
disabled
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
<Badge variant="secondary" className="text-xs">{events.length} Events</Badge>
|
<Badge variant="secondary" className="text-xs">{events.length} Events</Badge>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="flex-1">
|
</div>
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<EventsDataTable
|
<EventsDataTable
|
||||||
data={events.map(e => ({ ...e, timestamp: new Date(e.timestamp) }))}
|
data={events.map(e => ({ ...e, timestamp: new Date(e.timestamp) }))}
|
||||||
@@ -202,6 +296,7 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,6 +305,9 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper specific to this file if needed, otherwise ignore.
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
|
||||||
function formatTime(ms: number) {
|
function formatTime(ms: number) {
|
||||||
if (ms < 0) return "0:00";
|
if (ms < 0) return "0:00";
|
||||||
const totalSeconds = Math.floor(ms / 1000);
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
|||||||
273
src/components/trials/wizard/RobotSettingsModal.tsx
Normal file
273
src/components/trials/wizard/RobotSettingsModal.tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Switch } from "~/components/ui/switch";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||||
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
import { Loader2, Settings2 } from "lucide-react";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface RobotSettingsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
studyId: string;
|
||||||
|
pluginId: string;
|
||||||
|
settingsSchema: SettingsSchema | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsSchema {
|
||||||
|
type: "object";
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
properties: Record<string, PropertySchema>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PropertySchema {
|
||||||
|
type: "object" | "string" | "number" | "integer" | "boolean";
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
properties?: Record<string, PropertySchema>;
|
||||||
|
enum?: string[];
|
||||||
|
enumNames?: string[];
|
||||||
|
minimum?: number;
|
||||||
|
maximum?: number;
|
||||||
|
default?: unknown;
|
||||||
|
pattern?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RobotSettingsModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
studyId,
|
||||||
|
pluginId,
|
||||||
|
settingsSchema,
|
||||||
|
}: RobotSettingsModalProps) {
|
||||||
|
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// Fetch current settings
|
||||||
|
const { data: currentSettings, isLoading } = api.studies.getPluginConfiguration.useQuery(
|
||||||
|
{ studyId, pluginId },
|
||||||
|
{ enabled: open }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update settings mutation
|
||||||
|
const updateSettings = api.studies.updatePluginConfiguration.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Robot settings updated successfully");
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (error: { message: string }) => {
|
||||||
|
toast.error(`Failed to update settings: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize settings from current configuration
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
useState(() => {
|
||||||
|
if (currentSettings) {
|
||||||
|
setSettings(currentSettings as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
await updateSettings.mutateAsync({
|
||||||
|
studyId,
|
||||||
|
pluginId,
|
||||||
|
configuration: settings,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderField = (key: string, schema: PropertySchema, parentPath: string = "") => {
|
||||||
|
const fullPath = parentPath ? `${parentPath}.${key}` : key;
|
||||||
|
const value = getNestedValue(settings, fullPath);
|
||||||
|
const defaultValue = schema.default;
|
||||||
|
|
||||||
|
const updateValue = (newValue: unknown) => {
|
||||||
|
setSettings((prev) => setNestedValue({ ...prev }, fullPath, newValue));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Object type - render nested fields
|
||||||
|
if (schema.type === "object" && schema.properties) {
|
||||||
|
return (
|
||||||
|
<div key={fullPath} className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h4 className="text-sm font-semibold">{schema.title || key}</h4>
|
||||||
|
{schema.description && (
|
||||||
|
<p className="text-xs text-muted-foreground">{schema.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 space-y-3">
|
||||||
|
{Object.entries(schema.properties).map(([subKey, subSchema]) =>
|
||||||
|
renderField(subKey, subSchema, fullPath)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boolean type - render switch
|
||||||
|
if (schema.type === "boolean") {
|
||||||
|
return (
|
||||||
|
<div key={fullPath} className="flex items-center justify-between space-x-2">
|
||||||
|
<div className="space-y-0.5 flex-1">
|
||||||
|
<Label htmlFor={fullPath}>{schema.title || key}</Label>
|
||||||
|
{schema.description && (
|
||||||
|
<p className="text-xs text-muted-foreground">{schema.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id={fullPath}
|
||||||
|
checked={(value ?? defaultValue) as boolean}
|
||||||
|
onCheckedChange={updateValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enum type - render select
|
||||||
|
if (schema.enum) {
|
||||||
|
return (
|
||||||
|
<div key={fullPath} className="space-y-2">
|
||||||
|
<Label htmlFor={fullPath}>{schema.title || key}</Label>
|
||||||
|
{schema.description && (
|
||||||
|
<p className="text-xs text-muted-foreground">{schema.description}</p>
|
||||||
|
)}
|
||||||
|
<Select
|
||||||
|
value={(value ?? defaultValue) as string}
|
||||||
|
onValueChange={updateValue}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={fullPath}>
|
||||||
|
<SelectValue placeholder="Select an option" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{schema.enum.map((option, idx) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{schema.enumNames?.[idx] || option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number/Integer type - render number input
|
||||||
|
if (schema.type === "number" || schema.type === "integer") {
|
||||||
|
return (
|
||||||
|
<div key={fullPath} className="space-y-2">
|
||||||
|
<Label htmlFor={fullPath}>{schema.title || key}</Label>
|
||||||
|
{schema.description && (
|
||||||
|
<p className="text-xs text-muted-foreground">{schema.description}</p>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
id={fullPath}
|
||||||
|
type="number"
|
||||||
|
min={schema.minimum}
|
||||||
|
max={schema.maximum}
|
||||||
|
step={schema.type === "integer" ? 1 : 0.1}
|
||||||
|
value={(value ?? defaultValue) as number}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = schema.type === "integer"
|
||||||
|
? parseInt(e.target.value, 10)
|
||||||
|
: parseFloat(e.target.value);
|
||||||
|
updateValue(isNaN(newValue) ? defaultValue : newValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// String type - render text input
|
||||||
|
return (
|
||||||
|
<div key={fullPath} className="space-y-2">
|
||||||
|
<Label htmlFor={fullPath}>{schema.title || key}</Label>
|
||||||
|
{schema.description && (
|
||||||
|
<p className="text-xs text-muted-foreground">{schema.description}</p>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
id={fullPath}
|
||||||
|
type="text"
|
||||||
|
pattern={schema.pattern}
|
||||||
|
value={(value ?? defaultValue) as string}
|
||||||
|
onChange={(e) => updateValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!settingsSchema) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Settings2 className="h-5 w-5" />
|
||||||
|
{settingsSchema.title || "Robot Settings"}
|
||||||
|
</DialogTitle>
|
||||||
|
{settingsSchema.description && (
|
||||||
|
<DialogDescription>{settingsSchema.description}</DialogDescription>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
{Object.entries(settingsSchema.properties).map(([key, schema], idx) => (
|
||||||
|
<div key={key}>
|
||||||
|
{renderField(key, schema)}
|
||||||
|
{idx < Object.keys(settingsSchema.properties).length - 1 && (
|
||||||
|
<Separator className="mt-6" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={isSaving || isLoading}>
|
||||||
|
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Save Settings
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for nested object access
|
||||||
|
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||||
|
return path.split(".").reduce((current, key) => {
|
||||||
|
return current && typeof current === "object" ? (current as Record<string, unknown>)[key] : undefined;
|
||||||
|
}, obj as unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> {
|
||||||
|
const keys = path.split(".");
|
||||||
|
const lastKey = keys.pop()!;
|
||||||
|
const target = keys.reduce((current, key) => {
|
||||||
|
if (!current[key] || typeof current[key] !== "object") {
|
||||||
|
current[key] = {};
|
||||||
|
}
|
||||||
|
return current[key] as Record<string, unknown>;
|
||||||
|
}, obj);
|
||||||
|
target[lastKey] = value;
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
@@ -113,7 +113,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
const [executionPanelTab, setExecutionPanelTab] = useState<"current" | "timeline" | "events">("timeline");
|
const [executionPanelTab, setExecutionPanelTab] = useState<"current" | "timeline" | "events">("timeline");
|
||||||
const [obsTab, setObsTab] = useState<"notes" | "timeline">("notes");
|
|
||||||
const [isExecutingAction, setIsExecutingAction] = useState(false);
|
const [isExecutingAction, setIsExecutingAction] = useState(false);
|
||||||
const [monitoringPanelTab, setMonitoringPanelTab] = useState<
|
const [monitoringPanelTab, setMonitoringPanelTab] = useState<
|
||||||
"status" | "robot" | "events"
|
"status" | "robot" | "events"
|
||||||
@@ -202,13 +202,23 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
connect: connectRos,
|
connect: connectRos,
|
||||||
disconnect: disconnectRos,
|
disconnect: disconnectRos,
|
||||||
executeRobotAction: executeRosAction,
|
executeRobotAction: executeRosAction,
|
||||||
setAutonomousLife,
|
setAutonomousLife: setAutonomousLifeRaw,
|
||||||
} = useWizardRos({
|
} = useWizardRos({
|
||||||
autoConnect: true,
|
autoConnect: true,
|
||||||
onActionCompleted,
|
onActionCompleted,
|
||||||
onActionFailed,
|
onActionFailed,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wrap setAutonomousLife in a stable callback to prevent infinite re-renders
|
||||||
|
// The raw function from useWizardRos is recreated when isConnected changes,
|
||||||
|
// which would cause WizardControlPanel (wrapped in React.memo) to re-render infinitely
|
||||||
|
const setAutonomousLife = useCallback(
|
||||||
|
async (enabled: boolean) => {
|
||||||
|
return setAutonomousLifeRaw(enabled);
|
||||||
|
},
|
||||||
|
[setAutonomousLifeRaw]
|
||||||
|
);
|
||||||
|
|
||||||
// Use polling for trial status updates (no trial WebSocket server exists)
|
// Use polling for trial status updates (no trial WebSocket server exists)
|
||||||
const { data: pollingData } = api.trials.get.useQuery(
|
const { data: pollingData } = api.trials.get.useQuery(
|
||||||
{ id: trial.id },
|
{ id: trial.id },
|
||||||
@@ -237,7 +247,14 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
pollingData.startedAt?.getTime() !== trial.startedAt?.getTime() ||
|
pollingData.startedAt?.getTime() !== trial.startedAt?.getTime() ||
|
||||||
pollingData.completedAt?.getTime() !== trial.completedAt?.getTime()) {
|
pollingData.completedAt?.getTime() !== trial.completedAt?.getTime()) {
|
||||||
|
|
||||||
setTrial((prev) => ({
|
setTrial((prev) => {
|
||||||
|
// Double check inside setter to be safe
|
||||||
|
if (prev.status === pollingData.status &&
|
||||||
|
prev.startedAt?.getTime() === pollingData.startedAt?.getTime() &&
|
||||||
|
prev.completedAt?.getTime() === pollingData.completedAt?.getTime()) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
status: pollingData.status,
|
status: pollingData.status,
|
||||||
startedAt: pollingData.startedAt
|
startedAt: pollingData.startedAt
|
||||||
@@ -246,10 +263,12 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
completedAt: pollingData.completedAt
|
completedAt: pollingData.completedAt
|
||||||
? new Date(pollingData.completedAt)
|
? new Date(pollingData.completedAt)
|
||||||
: prev.completedAt,
|
: prev.completedAt,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [pollingData, trial]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [pollingData]);
|
||||||
|
|
||||||
// Auto-start trial on mount if scheduled
|
// Auto-start trial on mount if scheduled
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -259,7 +278,6 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
}, []); // Run once on mount
|
}, []); // Run once on mount
|
||||||
|
|
||||||
// Trial events from robot actions
|
// Trial events from robot actions
|
||||||
|
|
||||||
const trialEvents = useMemo<
|
const trialEvents = useMemo<
|
||||||
Array<{
|
Array<{
|
||||||
type: string;
|
type: string;
|
||||||
@@ -301,7 +319,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
}, [fetchedEvents]);
|
}, [fetchedEvents]);
|
||||||
|
|
||||||
// Transform experiment steps to component format
|
// Transform experiment steps to component format
|
||||||
const steps: StepData[] =
|
const steps: StepData[] = useMemo(() =>
|
||||||
experimentSteps?.map((step, index) => ({
|
experimentSteps?.map((step, index) => ({
|
||||||
id: step.id,
|
id: step.id,
|
||||||
name: step.name ?? `Step ${index + 1}`,
|
name: step.name ?? `Step ${index + 1}`,
|
||||||
@@ -320,7 +338,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
order: action.order,
|
order: action.order,
|
||||||
pluginId: action.pluginId,
|
pluginId: action.pluginId,
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
})) ?? [];
|
})) ?? [], [experimentSteps]);
|
||||||
|
|
||||||
|
|
||||||
const currentStep = steps[currentStepIndex] ?? null;
|
const currentStep = steps[currentStepIndex] ?? null;
|
||||||
const totalSteps = steps.length;
|
const totalSteps = steps.length;
|
||||||
@@ -451,6 +470,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
const result = await startTrialMutation.mutateAsync({ id: trial.id });
|
const result = await startTrialMutation.mutateAsync({ id: trial.id });
|
||||||
console.log("[WizardInterface] Trial started successfully", result);
|
console.log("[WizardInterface] Trial started successfully", result);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Update local state immediately
|
// Update local state immediately
|
||||||
setTrial((prev) => ({
|
setTrial((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -471,6 +492,11 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
const handlePauseTrial = async () => {
|
const handlePauseTrial = async () => {
|
||||||
try {
|
try {
|
||||||
await pauseTrialMutation.mutateAsync({ id: trial.id });
|
await pauseTrialMutation.mutateAsync({ id: trial.id });
|
||||||
|
logEventMutation.mutate({
|
||||||
|
trialId: trial.id,
|
||||||
|
type: "trial_paused",
|
||||||
|
data: { timestamp: new Date() }
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to pause trial:", error);
|
console.error("Failed to pause trial:", error);
|
||||||
}
|
}
|
||||||
@@ -482,6 +508,20 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
// Find step by index to ensure safety
|
// Find step by index to ensure safety
|
||||||
if (targetIndex >= 0 && targetIndex < steps.length) {
|
if (targetIndex >= 0 && targetIndex < steps.length) {
|
||||||
console.log(`[WizardInterface] Manual jump to step ${targetIndex}`);
|
console.log(`[WizardInterface] Manual jump to step ${targetIndex}`);
|
||||||
|
|
||||||
|
// Log manual jump
|
||||||
|
logEventMutation.mutate({
|
||||||
|
trialId: trial.id,
|
||||||
|
type: "step_jumped",
|
||||||
|
data: {
|
||||||
|
fromIndex: currentStepIndex,
|
||||||
|
toIndex: targetIndex,
|
||||||
|
fromStepId: steps[currentStepIndex]?.id,
|
||||||
|
toStepId: steps[targetIndex]?.id,
|
||||||
|
reason: "manual_choice"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
setCompletedActionsCount(0);
|
setCompletedActionsCount(0);
|
||||||
setCurrentStepIndex(targetIndex);
|
setCurrentStepIndex(targetIndex);
|
||||||
setLastResponse(null);
|
setLastResponse(null);
|
||||||
@@ -500,6 +540,18 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
const targetIndex = steps.findIndex(s => s.id === matchedOption.nextStepId);
|
const targetIndex = steps.findIndex(s => s.id === matchedOption.nextStepId);
|
||||||
if (targetIndex !== -1) {
|
if (targetIndex !== -1) {
|
||||||
console.log(`[WizardInterface] Branching to step ${targetIndex} (${matchedOption.label})`);
|
console.log(`[WizardInterface] Branching to step ${targetIndex} (${matchedOption.label})`);
|
||||||
|
|
||||||
|
logEventMutation.mutate({
|
||||||
|
trialId: trial.id,
|
||||||
|
type: "step_branched",
|
||||||
|
data: {
|
||||||
|
fromIndex: currentStepIndex,
|
||||||
|
toIndex: targetIndex,
|
||||||
|
condition: matchedOption.label,
|
||||||
|
value: lastResponse
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
setCurrentStepIndex(targetIndex);
|
setCurrentStepIndex(targetIndex);
|
||||||
setLastResponse(null); // Reset after consuming
|
setLastResponse(null); // Reset after consuming
|
||||||
return;
|
return;
|
||||||
@@ -514,6 +566,17 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
const targetIndex = steps.findIndex(s => s.id === nextId);
|
const targetIndex = steps.findIndex(s => s.id === nextId);
|
||||||
if (targetIndex !== -1) {
|
if (targetIndex !== -1) {
|
||||||
console.log(`[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`);
|
console.log(`[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`);
|
||||||
|
|
||||||
|
logEventMutation.mutate({
|
||||||
|
trialId: trial.id,
|
||||||
|
type: "step_jumped",
|
||||||
|
data: {
|
||||||
|
fromIndex: currentStepIndex,
|
||||||
|
toIndex: targetIndex,
|
||||||
|
reason: "condition_next_step"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
setCurrentStepIndex(targetIndex);
|
setCurrentStepIndex(targetIndex);
|
||||||
setCompletedActionsCount(0);
|
setCompletedActionsCount(0);
|
||||||
return;
|
return;
|
||||||
@@ -549,6 +612,9 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
const handleCompleteTrial = async () => {
|
const handleCompleteTrial = async () => {
|
||||||
try {
|
try {
|
||||||
await completeTrialMutation.mutateAsync({ id: trial.id });
|
await completeTrialMutation.mutateAsync({ id: trial.id });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Trigger archive in background
|
// Trigger archive in background
|
||||||
archiveTrialMutation.mutate({ id: trial.id });
|
archiveTrialMutation.mutate({ id: trial.id });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -559,6 +625,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
const handleAbortTrial = async () => {
|
const handleAbortTrial = async () => {
|
||||||
try {
|
try {
|
||||||
await abortTrialMutation.mutateAsync({ id: trial.id });
|
await abortTrialMutation.mutateAsync({ id: trial.id });
|
||||||
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to abort trial:", error);
|
console.error("Failed to abort trial:", error);
|
||||||
}
|
}
|
||||||
@@ -638,6 +706,16 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
description: String(parameters?.content || "Quick note"),
|
description: String(parameters?.content || "Quick note"),
|
||||||
category: String(parameters?.category || "quick_note")
|
category: String(parameters?.category || "quick_note")
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Generic action logging
|
||||||
|
await logEventMutation.mutateAsync({
|
||||||
|
trialId: trial.id,
|
||||||
|
type: "action_executed",
|
||||||
|
data: {
|
||||||
|
actionId,
|
||||||
|
parameters
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Action execution can be enhanced later with tRPC mutations
|
// Note: Action execution can be enhanced later with tRPC mutations
|
||||||
@@ -733,6 +811,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
options?: { autoAdvance?: boolean },
|
options?: { autoAdvance?: boolean },
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
|
// If it's a robot action (indicated by pluginName), use the robot logger
|
||||||
|
if (pluginName) {
|
||||||
await logRobotActionMutation.mutateAsync({
|
await logRobotActionMutation.mutateAsync({
|
||||||
trialId: trial.id,
|
trialId: trial.id,
|
||||||
pluginName,
|
pluginName,
|
||||||
@@ -741,6 +821,17 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
duration: 0,
|
duration: 0,
|
||||||
result: { skipped: true },
|
result: { skipped: true },
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Generic skip logging
|
||||||
|
await logEventMutation.mutateAsync({
|
||||||
|
trialId: trial.id,
|
||||||
|
type: "action_skipped",
|
||||||
|
data: {
|
||||||
|
actionId,
|
||||||
|
parameters
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
toast.info(`Action skipped: ${actionId}`);
|
toast.info(`Action skipped: ${actionId}`);
|
||||||
if (options?.autoAdvance) {
|
if (options?.autoAdvance) {
|
||||||
@@ -849,8 +940,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
<PanelLeftClose className="h-4 w-4" />
|
<PanelLeftClose className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
|
<div className="flex-1 overflow-hidden min-h-0 bg-muted/10">
|
||||||
<div id="tour-wizard-controls" className="h-full">
|
<div id="tour-wizard-controls-wrapper" className="h-full">
|
||||||
<WizardControlPanel
|
<WizardControlPanel
|
||||||
trial={trial}
|
trial={trial}
|
||||||
currentStep={currentStep}
|
currentStep={currentStep}
|
||||||
@@ -862,11 +953,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
onCompleteTrial={handleCompleteTrial}
|
onCompleteTrial={handleCompleteTrial}
|
||||||
onAbortTrial={handleAbortTrial}
|
onAbortTrial={handleAbortTrial}
|
||||||
onExecuteAction={handleExecuteAction}
|
onExecuteAction={handleExecuteAction}
|
||||||
onExecuteRobotAction={handleExecuteRobotAction}
|
|
||||||
studyId={trial.experiment.studyId}
|
|
||||||
_isConnected={rosConnected}
|
|
||||||
isStarting={startTrialMutation.isPending}
|
isStarting={startTrialMutation.isPending}
|
||||||
onSetAutonomousLife={setAutonomousLife}
|
|
||||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -937,6 +1024,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
|
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
|
||||||
onCompleteTrial={handleCompleteTrial}
|
onCompleteTrial={handleCompleteTrial}
|
||||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||||
|
rosConnected={rosConnected}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -946,7 +1034,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
{!rightCollapsed && (
|
{!rightCollapsed && (
|
||||||
<div className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm w-80">
|
<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">
|
<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>
|
<span className="text-sm font-medium">Robot Control & Status</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -966,6 +1054,10 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
connectRos={connectRos}
|
connectRos={connectRos}
|
||||||
disconnectRos={disconnectRos}
|
disconnectRos={disconnectRos}
|
||||||
executeRosAction={executeRosAction}
|
executeRosAction={executeRosAction}
|
||||||
|
onSetAutonomousLife={setAutonomousLife}
|
||||||
|
onExecuteRobotAction={handleExecuteRobotAction}
|
||||||
|
studyId={trial.experiment.studyId}
|
||||||
|
trialId={trial.id}
|
||||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -976,13 +1068,9 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
|
|
||||||
{/* Bottom Row - Observations (Full Width, Collapsible) */}
|
{/* Bottom Row - Observations (Full Width, Collapsible) */}
|
||||||
{!obsCollapsed && (
|
{!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 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">
|
<div className="flex items-center border-b px-3 py-2 bg-muted/30 gap-3">
|
||||||
<span className="text-sm font-medium">Observations</span>
|
<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" />
|
<div className="flex-1" />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -999,10 +1087,9 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
isSubmitting={addAnnotationMutation.isPending}
|
isSubmitting={addAnnotationMutation.isPending}
|
||||||
trialEvents={trialEvents}
|
trialEvents={trialEvents}
|
||||||
readOnly={trial.status === 'completed'}
|
readOnly={trial.status === 'completed'}
|
||||||
activeTab={obsTab}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</div>
|
||||||
)}
|
)}
|
||||||
{
|
{
|
||||||
obsCollapsed && (
|
obsCollapsed && (
|
||||||
|
|||||||
498
src/components/trials/wizard/panels/WizardActionItem.tsx
Normal file
498
src/components/trials/wizard/panels/WizardActionItem.tsx
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
import React, { useState, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
CheckCircle,
|
||||||
|
RotateCcw,
|
||||||
|
Clock,
|
||||||
|
Repeat,
|
||||||
|
Split,
|
||||||
|
Layers,
|
||||||
|
ChevronRight,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
|
||||||
|
export interface ActionData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
type: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
order: number;
|
||||||
|
pluginId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WizardActionItemProps {
|
||||||
|
action: ActionData;
|
||||||
|
index: number;
|
||||||
|
isActive: boolean;
|
||||||
|
isCompleted: boolean;
|
||||||
|
onExecute: (actionId: string, parameters?: Record<string, unknown>) => void;
|
||||||
|
onExecuteRobot: (
|
||||||
|
pluginName: string,
|
||||||
|
actionId: string,
|
||||||
|
parameters: Record<string, unknown>,
|
||||||
|
options?: { autoAdvance?: boolean }
|
||||||
|
) => Promise<void>;
|
||||||
|
onSkip: (
|
||||||
|
pluginName: string,
|
||||||
|
actionId: string,
|
||||||
|
parameters: Record<string, unknown>,
|
||||||
|
options?: { autoAdvance?: boolean }
|
||||||
|
) => Promise<void>;
|
||||||
|
onCompleted: () => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
isExecuting?: boolean;
|
||||||
|
depth?: number;
|
||||||
|
isRobotConnected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WizardActionItem({
|
||||||
|
action,
|
||||||
|
index,
|
||||||
|
isActive,
|
||||||
|
isCompleted,
|
||||||
|
onExecute,
|
||||||
|
onExecuteRobot,
|
||||||
|
onSkip,
|
||||||
|
onCompleted,
|
||||||
|
readOnly,
|
||||||
|
isExecuting,
|
||||||
|
depth = 0,
|
||||||
|
isRobotConnected = false,
|
||||||
|
}: WizardActionItemProps): React.JSX.Element {
|
||||||
|
// Local state for container children completion
|
||||||
|
const [completedChildren, setCompletedChildren] = useState<Set<number>>(new Set());
|
||||||
|
// Local state for loop iterations
|
||||||
|
const [currentIteration, setCurrentIteration] = useState(1);
|
||||||
|
// Local state to track execution of this specific item
|
||||||
|
const [isRunningLocal, setIsRunningLocal] = useState(false);
|
||||||
|
// Local state for wait countdown
|
||||||
|
const [countdown, setCountdown] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const isContainer =
|
||||||
|
action.type === "hristudio-core.sequence" ||
|
||||||
|
action.type === "hristudio-core.parallel" ||
|
||||||
|
action.type === "hristudio-core.loop" ||
|
||||||
|
action.type === "sequence" ||
|
||||||
|
action.type === "parallel" ||
|
||||||
|
action.type === "loop";
|
||||||
|
|
||||||
|
// Branch support
|
||||||
|
const isBranch = action.type === "hristudio-core.branch" || action.type === "branch";
|
||||||
|
const isWait = action.type === "hristudio-core.wait" || action.type === "wait";
|
||||||
|
|
||||||
|
// Helper to get children
|
||||||
|
const children = (action.parameters.children as ActionData[]) || [];
|
||||||
|
const iterations = (action.parameters.iterations as number) || 1;
|
||||||
|
|
||||||
|
// Recursive helper to check for robot actions
|
||||||
|
const hasRobotActions = useCallback((item: ActionData): boolean => {
|
||||||
|
if (item.type === "robot_action" || !!item.pluginId) return true;
|
||||||
|
if (item.parameters?.children && Array.isArray(item.parameters.children)) {
|
||||||
|
return (item.parameters.children as ActionData[]).some(hasRobotActions);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const containsRobotActions = hasRobotActions(action);
|
||||||
|
|
||||||
|
// Countdown effect
|
||||||
|
React.useEffect(() => {
|
||||||
|
let interval: NodeJS.Timeout;
|
||||||
|
if (isRunningLocal && countdown !== null && countdown > 0) {
|
||||||
|
interval = setInterval(() => {
|
||||||
|
setCountdown((prev) => (prev !== null && prev > 0 ? prev - 1 : 0));
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isRunningLocal, countdown]);
|
||||||
|
|
||||||
|
// Derived state for disabled button
|
||||||
|
const isButtonDisabled =
|
||||||
|
isExecuting ||
|
||||||
|
isRunningLocal ||
|
||||||
|
(!isWait && !isRobotConnected && (action.type === 'robot_action' || !!action.pluginId || (isContainer && containsRobotActions)));
|
||||||
|
|
||||||
|
|
||||||
|
// Handler for child completion
|
||||||
|
const handleChildCompleted = useCallback((childIndex: number) => {
|
||||||
|
setCompletedChildren(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(childIndex);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handler for next loop iteration
|
||||||
|
const handleNextIteration = useCallback(() => {
|
||||||
|
if (currentIteration < iterations) {
|
||||||
|
setCompletedChildren(new Set());
|
||||||
|
setCurrentIteration(prev => prev + 1);
|
||||||
|
} else {
|
||||||
|
// Loop finished - allow manual completion of the loop action
|
||||||
|
}
|
||||||
|
}, [currentIteration, iterations]);
|
||||||
|
|
||||||
|
// Check if current iteration is complete (all children done)
|
||||||
|
const isIterationComplete = children.length > 0 && children.every((_, idx) => completedChildren.has(idx));
|
||||||
|
const isLoopComplete = isIterationComplete && currentIteration >= iterations;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative pb-2 last:pb-0 transition-all duration-300",
|
||||||
|
depth > 0 && "ml-4 mt-2 border-l pl-4 border-l-border/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Visual Connection Line for Root items is handled by parent list,
|
||||||
|
but for nested items we handle it via border-l above */}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border transition-all duration-300",
|
||||||
|
isActive
|
||||||
|
? "bg-card border-primary/50 shadow-md p-4"
|
||||||
|
: "bg-muted/5 border-transparent p-3 opacity-80 hover:opacity-100",
|
||||||
|
isContainer && "bg-muted/10 border-border/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Header Row */}
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Icon based on type */}
|
||||||
|
{isContainer && action.type.includes("loop") && <Repeat className="h-4 w-4 text-blue-500 dark:text-blue-400" />}
|
||||||
|
{isContainer && action.type.includes("parallel") && <Layers className="h-4 w-4 text-purple-500 dark:text-purple-400" />}
|
||||||
|
{isBranch && <Split className="h-4 w-4 text-orange-500 dark:text-orange-400" />}
|
||||||
|
{isWait && <Clock className="h-4 w-4 text-amber-500 dark:text-amber-400" />}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"text-base font-medium leading-none",
|
||||||
|
isCompleted && "line-through text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{action.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Completion Badge */}
|
||||||
|
{isCompleted && <CheckCircle className="h-4 w-4 text-green-500 dark:text-green-400" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{action.description && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{action.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Details for Control Flow */}
|
||||||
|
{isWait && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-amber-700 bg-amber-50/80 dark:text-amber-300 dark:bg-amber-900/30 w-fit px-2 py-1 rounded border border-amber-100 dark:border-amber-800/50">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
Wait {String(action.parameters.duration || 1)}s
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{action.type.includes("loop") && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-blue-700 bg-blue-50/80 dark:text-blue-300 dark:bg-blue-900/30 w-fit px-2 py-1 rounded border border-blue-100 dark:border-blue-800/50">
|
||||||
|
<Repeat className="h-3 w-3" />
|
||||||
|
{String(action.parameters.iterations || 1)} Iterations
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{((!!isContainer && children.length > 0) ? (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{/* Loop Iteration Status & Controls */}
|
||||||
|
{action.type.includes("loop") && (
|
||||||
|
<div className="flex items-center justify-between bg-blue-50/50 dark:bg-blue-900/20 p-2 rounded mb-2 border border-blue-100 dark:border-blue-800/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="bg-white dark:bg-zinc-900 dark:text-zinc-100 border-zinc-200 dark:border-zinc-700">
|
||||||
|
Iteration {currentIteration} of {iterations}
|
||||||
|
</Badge>
|
||||||
|
{isIterationComplete && currentIteration < iterations && (
|
||||||
|
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium animate-pulse">
|
||||||
|
All actions complete. Ready for next iteration.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isLoopComplete && (
|
||||||
|
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
|
||||||
|
Loop complete!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoopComplete ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onCompleted();
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs bg-green-600 hover:bg-green-700 text-white dark:bg-green-600 dark:hover:bg-green-500"
|
||||||
|
>
|
||||||
|
<CheckCircle className="mr-1 h-3 w-3" />
|
||||||
|
Finish Loop
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
isIterationComplete && currentIteration < iterations && !readOnly && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onCompleted();
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
<ChevronRight className="mr-1 h-3 w-3" />
|
||||||
|
Exit Loop
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleNextIteration();
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
<Repeat className="mr-1 h-3 w-3" />
|
||||||
|
Next Iteration
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||||
|
{action.type.includes("loop") ? "Loop Body" : "Actions"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children.map((child, idx) => (
|
||||||
|
<WizardActionItem
|
||||||
|
key={`${child.id || idx}-${currentIteration}`}
|
||||||
|
action={child as ActionData}
|
||||||
|
index={idx}
|
||||||
|
isActive={isActive && !isCompleted && !completedChildren.has(idx)}
|
||||||
|
isCompleted={isCompleted || completedChildren.has(idx)}
|
||||||
|
onExecute={onExecute}
|
||||||
|
onExecuteRobot={onExecuteRobot}
|
||||||
|
onSkip={onSkip}
|
||||||
|
onCompleted={() => handleChildCompleted(idx)}
|
||||||
|
readOnly={readOnly || isCompleted || completedChildren.has(idx) || (action.type.includes("parallel") && true)}
|
||||||
|
isExecuting={isExecuting}
|
||||||
|
depth={depth + 1}
|
||||||
|
isRobotConnected={isRobotConnected}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null) as any}
|
||||||
|
|
||||||
|
{/* Active Action Controls */}
|
||||||
|
{isActive && !readOnly && (
|
||||||
|
<div className="pt-3 flex flex-wrap items-center gap-3">
|
||||||
|
{/* Parallel Container Controls */}
|
||||||
|
{isContainer && action.type.includes("parallel") ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"shadow-sm min-w-[100px]",
|
||||||
|
isButtonDisabled && "opacity-50 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// Run all child robot actions
|
||||||
|
const children = (action.parameters.children as ActionData[]) || [];
|
||||||
|
for (const child of children) {
|
||||||
|
if (child.pluginId) {
|
||||||
|
// Fire and forget - don't await sequentially
|
||||||
|
onExecuteRobot(
|
||||||
|
child.pluginId,
|
||||||
|
child.type.includes(".") ? child.type.split(".").pop()! : child.type,
|
||||||
|
child.parameters || {},
|
||||||
|
{ autoAdvance: false }
|
||||||
|
).catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isButtonDisabled}
|
||||||
|
title={isButtonDisabled && !isExecuting ? "Robot disconnected" : undefined}
|
||||||
|
>
|
||||||
|
<Play className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Run All
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onCompleted();
|
||||||
|
}}
|
||||||
|
disabled={isExecuting}
|
||||||
|
>
|
||||||
|
<CheckCircle className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Mark Group Complete
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* Standard Single Action Controls */
|
||||||
|
(action.pluginId && !["hristudio-woz"].includes(action.pluginId!) && (action.pluginId !== "hristudio-core" || isWait)) ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"shadow-sm min-w-[100px]",
|
||||||
|
isButtonDisabled && "opacity-50 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsRunningLocal(true);
|
||||||
|
|
||||||
|
if (isWait) {
|
||||||
|
const duration = Number(action.parameters.duration || 1);
|
||||||
|
setCountdown(Math.ceil(duration));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onExecuteRobot(
|
||||||
|
action.pluginId!,
|
||||||
|
action.type.includes(".") ? action.type.split(".").pop()! : action.type,
|
||||||
|
action.parameters || {},
|
||||||
|
{ autoAdvance: false }
|
||||||
|
);
|
||||||
|
onCompleted();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Action execution error:", error);
|
||||||
|
} finally {
|
||||||
|
setIsRunningLocal(false);
|
||||||
|
setCountdown(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isExecuting || isRunningLocal || (!isWait && !isRobotConnected)}
|
||||||
|
title={!isWait && !isRobotConnected ? "Robot disconnected" : undefined}
|
||||||
|
>
|
||||||
|
{isRunningLocal ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||||
|
{isWait ? (countdown !== null && countdown > 0 ? `Wait (${countdown}s)...` : "Finishing...") : "Running..."}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Run
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onCompleted();
|
||||||
|
}}
|
||||||
|
disabled={isExecuting}
|
||||||
|
>
|
||||||
|
<CheckCircle className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Mark Complete
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (onSkip) {
|
||||||
|
onSkip(action.pluginId!, action.type.includes(".") ? action.type.split(".").pop()! : action.type, action.parameters || {}, { autoAdvance: false });
|
||||||
|
}
|
||||||
|
onCompleted();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Manual/Wizard Actions (Leaf nodes)
|
||||||
|
!isContainer && action.type !== "wizard_wait_for_response" && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onCompleted();
|
||||||
|
}}
|
||||||
|
disabled={isExecuting}
|
||||||
|
>
|
||||||
|
<CheckCircle className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Mark Complete
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Branching / Choice UI */}
|
||||||
|
{isActive &&
|
||||||
|
(action.type === "wizard_wait_for_response" || isBranch) &&
|
||||||
|
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) => {
|
||||||
|
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
|
||||||
|
key={optIdx}
|
||||||
|
variant="outline"
|
||||||
|
className="justify-start h-auto py-3 px-4 text-left hover:border-primary hover:bg-primary/5"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onExecute(action.id, { value, label, nextStepId });
|
||||||
|
onCompleted();
|
||||||
|
}}
|
||||||
|
disabled={readOnly || isExecuting}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start gap-1">
|
||||||
|
<span className="font-medium">{String(label)}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Retry for failed/completed robot actions */}
|
||||||
|
{isCompleted && action.pluginId && !isContainer && (
|
||||||
|
<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();
|
||||||
|
onExecuteRobot(
|
||||||
|
action.pluginId!,
|
||||||
|
action.type.includes(".") ? action.type.split(".").pop()! : action.type,
|
||||||
|
action.parameters || {},
|
||||||
|
{ autoAdvance: false }
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={isExecuting}
|
||||||
|
>
|
||||||
|
<RotateCcw className="mr-1.5 h-3 w-3" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,12 +18,8 @@ import {
|
|||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Progress } from "~/components/ui/progress";
|
import { Progress } from "~/components/ui/progress";
|
||||||
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 { Alert, AlertDescription } from "~/components/ui/alert";
|
||||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
import { RobotActionsPanel } from "../RobotActionsPanel";
|
|
||||||
|
|
||||||
interface StepData {
|
interface StepData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -95,16 +91,7 @@ interface WizardControlPanelProps {
|
|||||||
actionId: string,
|
actionId: string,
|
||||||
parameters?: Record<string, unknown>,
|
parameters?: Record<string, unknown>,
|
||||||
) => void;
|
) => void;
|
||||||
onExecuteRobotAction?: (
|
|
||||||
pluginName: string,
|
|
||||||
actionId: string,
|
|
||||||
parameters: Record<string, unknown>,
|
|
||||||
) => Promise<void>;
|
|
||||||
studyId?: string;
|
|
||||||
_isConnected: boolean;
|
|
||||||
|
|
||||||
isStarting?: boolean;
|
isStarting?: boolean;
|
||||||
onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>;
|
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,30 +106,10 @@ export const WizardControlPanel = React.memo(function WizardControlPanel({
|
|||||||
onCompleteTrial,
|
onCompleteTrial,
|
||||||
onAbortTrial,
|
onAbortTrial,
|
||||||
onExecuteAction,
|
onExecuteAction,
|
||||||
onExecuteRobotAction,
|
|
||||||
studyId,
|
|
||||||
_isConnected,
|
|
||||||
isStarting = false,
|
isStarting = false,
|
||||||
onSetAutonomousLife,
|
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
}: WizardControlPanelProps) {
|
}: WizardControlPanelProps) {
|
||||||
const [autonomousLife, setAutonomousLife] = React.useState(true);
|
|
||||||
|
|
||||||
const handleAutonomousLifeChange = async (checked: boolean) => {
|
|
||||||
setAutonomousLife(checked); // Optimistic update
|
|
||||||
if (onSetAutonomousLife) {
|
|
||||||
try {
|
|
||||||
const result = await onSetAutonomousLife(checked);
|
|
||||||
if (result === false) {
|
|
||||||
throw new Error("Service unavailable");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to set autonomous life:", error);
|
|
||||||
setAutonomousLife(!checked); // Revert on failure
|
|
||||||
// Optional: Toast error?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col" id="tour-wizard-controls">
|
<div className="flex h-full flex-col" id="tour-wizard-controls">
|
||||||
@@ -170,7 +137,7 @@ export const WizardControlPanel = React.memo(function WizardControlPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800"
|
className="w-full justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-700/50 dark:hover:bg-yellow-900/40"
|
||||||
onClick={() => onExecuteAction("intervene")}
|
onClick={() => onExecuteAction("intervene")}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
@@ -207,50 +174,27 @@ export const WizardControlPanel = React.memo(function WizardControlPanel({
|
|||||||
Controls available during trial
|
Controls available during trial
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Step Navigation */}
|
||||||
|
<div className="pt-4 border-t space-y-2">
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Navigation</span>
|
||||||
|
<select
|
||||||
|
className="w-full text-xs p-2 rounded-md border bg-background"
|
||||||
|
value={currentStepIndex}
|
||||||
|
onChange={(e) => onNextStep(parseInt(e.target.value, 10))}
|
||||||
|
disabled={readOnly}
|
||||||
|
>
|
||||||
|
{steps.map((step, idx) => (
|
||||||
|
<option key={step.id} value={idx}>
|
||||||
|
{idx + 1}. {step.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
|
||||||
studyId={studyId}
|
|
||||||
trialId={trial.id}
|
|
||||||
onExecuteAction={onExecuteRobotAction}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-muted-foreground text-center py-2">Robot actions unavailable</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div >
|
</div>
|
||||||
</div >
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { WizardActionItem } from "./WizardActionItem";
|
||||||
import {
|
import {
|
||||||
Play,
|
Play,
|
||||||
SkipForward,
|
SkipForward,
|
||||||
@@ -111,6 +113,7 @@ interface WizardExecutionPanelProps {
|
|||||||
completedActionsCount: number;
|
completedActionsCount: number;
|
||||||
onActionCompleted: () => void;
|
onActionCompleted: () => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
rosConnected?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WizardExecutionPanel({
|
export function WizardExecutionPanel({
|
||||||
@@ -131,6 +134,7 @@ export function WizardExecutionPanel({
|
|||||||
completedActionsCount,
|
completedActionsCount,
|
||||||
onActionCompleted,
|
onActionCompleted,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
|
rosConnected,
|
||||||
}: WizardExecutionPanelProps) {
|
}: WizardExecutionPanelProps) {
|
||||||
// Local state removed in favor of parent state to prevent reset on re-render
|
// Local state removed in favor of parent state to prevent reset on re-render
|
||||||
// const [completedCount, setCompletedCount] = React.useState(0);
|
// const [completedCount, setCompletedCount] = React.useState(0);
|
||||||
@@ -207,11 +211,85 @@ export function WizardExecutionPanel({
|
|||||||
// Active trial state
|
// Active trial state
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
<div className="flex-1 min-h-0 relative">
|
{/* Horizontal Step Progress Bar */}
|
||||||
<ScrollArea className="h-full w-full">
|
<div className="flex-none border-b bg-muted/30 p-3">
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto pb-2">
|
||||||
|
{steps.map((step, idx) => {
|
||||||
|
const isCurrent = idx === currentStepIndex;
|
||||||
|
const isCompleted = idx < currentStepIndex;
|
||||||
|
const isUpcoming = idx > currentStepIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
className="flex items-center gap-2 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => onStepSelect(idx)}
|
||||||
|
disabled={readOnly}
|
||||||
|
className={`
|
||||||
|
group relative flex items-center gap-2 rounded-lg border-2 px-3 py-2 transition-all
|
||||||
|
${isCurrent
|
||||||
|
? "border-primary bg-primary/10 shadow-sm"
|
||||||
|
: isCompleted
|
||||||
|
? "border-primary/30 bg-primary/5 hover:bg-primary/10"
|
||||||
|
: "border-muted-foreground/20 bg-background hover:bg-muted/50"
|
||||||
|
}
|
||||||
|
${readOnly ? "cursor-default" : "cursor-pointer"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Step Number/Icon */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold
|
||||||
|
${isCompleted
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: isCurrent
|
||||||
|
? "bg-primary text-primary-foreground ring-2 ring-primary/20"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{isCompleted ? (
|
||||||
|
<CheckCircle className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
idx + 1
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step Name */}
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium max-w-[120px] truncate ${isCurrent
|
||||||
|
? "text-foreground"
|
||||||
|
: isCompleted
|
||||||
|
? "text-muted-foreground"
|
||||||
|
: "text-muted-foreground/60"
|
||||||
|
}`}
|
||||||
|
title={step.name}
|
||||||
|
>
|
||||||
|
{step.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Arrow Connector */}
|
||||||
|
{idx < steps.length - 1 && (
|
||||||
|
<ArrowRight
|
||||||
|
className={`h-4 w-4 flex-shrink-0 ${isCompleted ? "text-primary/40" : "text-muted-foreground/30"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Step Details - NO SCROLL */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
|
<div className="h-full overflow-y-auto">
|
||||||
<div className="pr-4">
|
<div className="pr-4">
|
||||||
{currentStep ? (
|
{currentStep ? (
|
||||||
<div className="flex flex-col gap-4 p-4 max-w-2xl mx-auto">
|
<div className="flex flex-col gap-4 p-4 max-w-5xl mx-auto w-full">
|
||||||
{/* Header Info */}
|
{/* Header Info */}
|
||||||
<div className="space-y-1 pb-4 border-b">
|
<div className="space-y-1 pb-4 border-b">
|
||||||
<h2 className="text-xl font-bold tracking-tight">{currentStep.name}</h2>
|
<h2 className="text-xl font-bold tracking-tight">{currentStep.name}</h2>
|
||||||
@@ -226,7 +304,7 @@ export function WizardExecutionPanel({
|
|||||||
{currentStep.actions.map((action, idx) => {
|
{currentStep.actions.map((action, idx) => {
|
||||||
const isCompleted = idx < activeActionIndex;
|
const isCompleted = idx < activeActionIndex;
|
||||||
const isActive: boolean = idx === activeActionIndex;
|
const isActive: boolean = idx === activeActionIndex;
|
||||||
const isLast = idx === currentStep.actions!.length - 1;
|
const isLast = idx === (currentStep.actions?.length || 0) - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -257,176 +335,25 @@ export function WizardExecutionPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Card */}
|
{/* Action Content */}
|
||||||
<div
|
<WizardActionItem
|
||||||
className={`rounded-lg border transition-all duration-300 ${isActive
|
action={action as any} // Cast to ActionData
|
||||||
? "bg-card border-primary/50 shadow-md p-5 translate-x-1"
|
index={idx}
|
||||||
: "bg-muted/5 border-transparent p-3 opacity-70 hover:opacity-100"
|
isActive={isActive}
|
||||||
}`}
|
isCompleted={isCompleted}
|
||||||
>
|
onExecute={onExecuteAction}
|
||||||
<div className="space-y-2">
|
onExecuteRobot={onExecuteRobotAction}
|
||||||
<div className="flex items-start justify-between gap-4">
|
onSkip={onSkipAction}
|
||||||
<div
|
onCompleted={onActionCompleted}
|
||||||
className={`text-base font-medium leading-none ${isCompleted ? "line-through text-muted-foreground" : ""
|
readOnly={readOnly}
|
||||||
}`}
|
isExecuting={isExecuting}
|
||||||
>
|
isRobotConnected={rosConnected}
|
||||||
{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="text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSkipAction(
|
|
||||||
action.pluginId!,
|
|
||||||
action.type.includes(".")
|
|
||||||
? action.type.split(".").pop()!
|
|
||||||
: action.type,
|
|
||||||
action.parameters || {},
|
|
||||||
{ autoAdvance: false }
|
|
||||||
);
|
|
||||||
onActionCompleted();
|
|
||||||
}}
|
|
||||||
disabled={readOnly}
|
|
||||||
>
|
|
||||||
Skip
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onActionCompleted();
|
|
||||||
}}
|
|
||||||
disabled={readOnly || isExecuting}
|
|
||||||
>
|
|
||||||
Mark Done
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : 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
|
|
||||||
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();
|
|
||||||
onExecuteRobotAction(
|
|
||||||
action.pluginId!,
|
|
||||||
action.type.includes(".") ? action.type.split(".").pop()! : action.type,
|
|
||||||
action.parameters || {},
|
|
||||||
{ autoAdvance: false },
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
disabled={readOnly || isExecuting}
|
|
||||||
>
|
|
||||||
<RotateCcw className="mr-1.5 h-3 w-3" />
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
}
|
|
||||||
|
|
||||||
{/* Manual Advance Button */}
|
{/* Manual Advance Button */}
|
||||||
{activeActionIndex >= (currentStep.actions?.length || 0) && (
|
{activeActionIndex >= (currentStep.actions?.length || 0) && (
|
||||||
@@ -453,7 +380,7 @@ export function WizardExecutionPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import { Separator } from "~/components/ui/separator";
|
|||||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Switch } from "~/components/ui/switch";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
import { WebcamPanel } from "./WebcamPanel";
|
import { WebcamPanel } from "./WebcamPanel";
|
||||||
|
import { RobotActionsPanel } from "../RobotActionsPanel";
|
||||||
|
|
||||||
interface WizardMonitoringPanelProps {
|
interface WizardMonitoringPanelProps {
|
||||||
rosConnected: boolean;
|
rosConnected: boolean;
|
||||||
@@ -33,6 +36,14 @@ interface WizardMonitoringPanelProps {
|
|||||||
actionId: string,
|
actionId: string,
|
||||||
parameters: Record<string, unknown>,
|
parameters: Record<string, unknown>,
|
||||||
) => Promise<unknown>;
|
) => Promise<unknown>;
|
||||||
|
onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>;
|
||||||
|
onExecuteRobotAction?: (
|
||||||
|
pluginName: string,
|
||||||
|
actionId: string,
|
||||||
|
parameters: Record<string, unknown>,
|
||||||
|
) => Promise<void>;
|
||||||
|
studyId?: string;
|
||||||
|
trialId?: string;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,8 +55,28 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
connectRos,
|
connectRos,
|
||||||
disconnectRos,
|
disconnectRos,
|
||||||
executeRosAction,
|
executeRosAction,
|
||||||
|
onSetAutonomousLife,
|
||||||
|
onExecuteRobotAction,
|
||||||
|
studyId,
|
||||||
|
trialId,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
}: WizardMonitoringPanelProps) {
|
}: WizardMonitoringPanelProps) {
|
||||||
|
const [autonomousLife, setAutonomousLife] = React.useState(true);
|
||||||
|
|
||||||
|
const handleAutonomousLifeChange = React.useCallback(async (checked: boolean) => {
|
||||||
|
setAutonomousLife(checked); // Optimistic update
|
||||||
|
if (onSetAutonomousLife) {
|
||||||
|
try {
|
||||||
|
const result = await onSetAutonomousLife(checked);
|
||||||
|
if (result === false) {
|
||||||
|
throw new Error("Service unavailable");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to set autonomous life:", error);
|
||||||
|
setAutonomousLife(!checked); // Revert on failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [onSetAutonomousLife]);
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-2 p-2">
|
<div className="flex h-full flex-col gap-2 p-2">
|
||||||
{/* Camera View - Always Visible */}
|
{/* Camera View - Always Visible */}
|
||||||
@@ -166,6 +197,35 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* Autonomous Life Toggle */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<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={!rosConnected || readOnly}
|
||||||
|
className="scale-75"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Robot Actions Panel */}
|
||||||
|
{studyId && trialId && onExecuteRobotAction ? (
|
||||||
|
<div className={readOnly ? "pointer-events-none opacity-50" : ""}>
|
||||||
|
<RobotActionsPanel
|
||||||
|
studyId={studyId}
|
||||||
|
trialId={trialId}
|
||||||
|
onExecuteAction={onExecuteRobotAction}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
{/* Movement Controls */}
|
{/* Movement Controls */}
|
||||||
{rosConnected && (
|
{rosConnected && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||||
import { HorizontalTimeline } from "~/components/trials/timeline/HorizontalTimeline";
|
|
||||||
|
|
||||||
interface TrialEvent {
|
interface TrialEvent {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -31,7 +31,7 @@ interface WizardObservationPaneProps {
|
|||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
isSubmitting?: boolean;
|
isSubmitting?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
activeTab?: "notes" | "timeline";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WizardObservationPane({
|
export function WizardObservationPane({
|
||||||
@@ -39,7 +39,6 @@ export function WizardObservationPane({
|
|||||||
isSubmitting = false,
|
isSubmitting = false,
|
||||||
trialEvents = [],
|
trialEvents = [],
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
activeTab = "notes",
|
|
||||||
}: WizardObservationPaneProps & { trialEvents?: TrialEvent[] }) {
|
}: WizardObservationPaneProps & { trialEvents?: TrialEvent[] }) {
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const [category, setCategory] = useState("observation");
|
const [category, setCategory] = useState("observation");
|
||||||
@@ -71,7 +70,7 @@ export function WizardObservationPane({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-background">
|
<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-1 flex flex-col p-4 m-0">
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder={readOnly ? "Session is read-only" : "Type your observation here..."}
|
placeholder={readOnly ? "Session is read-only" : "Type your observation here..."}
|
||||||
@@ -142,10 +141,6 @@ export function WizardObservationPane({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`flex-1 m-0 min-h-0 p-4 ${activeTab !== "timeline" ? "hidden" : ""}`}>
|
|
||||||
<HorizontalTimeline events={trialEvents} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
220
src/components/ui/calendar.tsx
Normal file
220
src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import {
|
||||||
|
DayPicker,
|
||||||
|
getDefaultClassNames,
|
||||||
|
type DayButton,
|
||||||
|
} from "react-day-picker"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
import { Button, buttonVariants } from "~/components/ui/button"
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = "label",
|
||||||
|
buttonVariant = "ghost",
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) =>
|
||||||
|
date.toLocaleString("default", { month: "short" }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
|
months: cn(
|
||||||
|
"flex gap-4 flex-col md:flex-row relative",
|
||||||
|
defaultClassNames.months
|
||||||
|
),
|
||||||
|
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||||
|
nav: cn(
|
||||||
|
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||||
|
defaultClassNames.nav
|
||||||
|
),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
|
defaultClassNames.button_previous
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
|
defaultClassNames.button_next
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||||
|
defaultClassNames.month_caption
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||||
|
defaultClassNames.dropdowns
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||||
|
defaultClassNames.dropdown_root
|
||||||
|
),
|
||||||
|
dropdown: cn(
|
||||||
|
"absolute bg-popover inset-0 opacity-0",
|
||||||
|
defaultClassNames.dropdown
|
||||||
|
),
|
||||||
|
caption_label: cn(
|
||||||
|
"select-none font-medium",
|
||||||
|
captionLayout === "label"
|
||||||
|
? "text-sm"
|
||||||
|
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||||
|
defaultClassNames.caption_label
|
||||||
|
),
|
||||||
|
table: "w-full border-collapse",
|
||||||
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||||
|
defaultClassNames.weekday
|
||||||
|
),
|
||||||
|
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||||
|
week_number_header: cn(
|
||||||
|
"select-none w-(--cell-size)",
|
||||||
|
defaultClassNames.week_number_header
|
||||||
|
),
|
||||||
|
week_number: cn(
|
||||||
|
"text-[0.8rem] select-none text-muted-foreground",
|
||||||
|
defaultClassNames.week_number
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||||
|
props.showWeekNumber
|
||||||
|
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
||||||
|
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
|
||||||
|
defaultClassNames.day
|
||||||
|
),
|
||||||
|
range_start: cn(
|
||||||
|
"rounded-l-md bg-accent",
|
||||||
|
defaultClassNames.range_start
|
||||||
|
),
|
||||||
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
|
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||||
|
today: cn(
|
||||||
|
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||||
|
defaultClassNames.today
|
||||||
|
),
|
||||||
|
outside: cn(
|
||||||
|
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
defaultClassNames.outside
|
||||||
|
),
|
||||||
|
disabled: cn(
|
||||||
|
"text-muted-foreground opacity-50",
|
||||||
|
defaultClassNames.disabled
|
||||||
|
),
|
||||||
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="calendar"
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === "left") {
|
||||||
|
return (
|
||||||
|
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === "right") {
|
||||||
|
return (
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={cn("size-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DayButton: CalendarDayButton,
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({
|
||||||
|
className,
|
||||||
|
day,
|
||||||
|
modifiers,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton>) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus()
|
||||||
|
}, [modifiers.focused])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString()}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected &&
|
||||||
|
!modifiers.range_start &&
|
||||||
|
!modifiers.range_end &&
|
||||||
|
!modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
defaultClassNames.day,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton }
|
||||||
@@ -127,9 +127,11 @@ export function EntityForm<T extends FieldValues = FieldValues>({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid gap-8 w-full",
|
"grid gap-8 w-full",
|
||||||
// If sidebar exists, use 2-column layout. If not, use full width (max-w-7xl centered).
|
// If sidebar exists, use 2-column layout. If not, use full width.
|
||||||
sidebar && layout === "default"
|
sidebar && layout === "default"
|
||||||
? "grid-cols-1 lg:grid-cols-3"
|
? "grid-cols-1 lg:grid-cols-3"
|
||||||
|
: layout === "full-width"
|
||||||
|
? "grid-cols-1 w-full"
|
||||||
: "grid-cols-1 max-w-7xl mx-auto",
|
: "grid-cols-1 max-w-7xl mx-auto",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ const iconSizes = {
|
|||||||
lg: "h-8 w-8",
|
lg: "h-8 w-8",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const textSizes = {
|
||||||
|
sm: "text-sm",
|
||||||
|
md: "text-base",
|
||||||
|
lg: "text-3xl",
|
||||||
|
};
|
||||||
|
|
||||||
export function Logo({
|
export function Logo({
|
||||||
className,
|
className,
|
||||||
iconSize = "md",
|
iconSize = "md",
|
||||||
@@ -24,14 +30,11 @@ export function Logo({
|
|||||||
<Bot className={iconSizes[iconSize]} />
|
<Bot className={iconSizes[iconSize]} />
|
||||||
</div>
|
</div>
|
||||||
{showText && (
|
{showText && (
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left leading-none">
|
||||||
<div className="flex items-baseline gap-0">
|
<div className="flex items-baseline gap-0">
|
||||||
<span className="text-base font-extrabold tracking-tight">HRI</span>
|
<span className={cn(textSizes[iconSize], "font-extrabold tracking-tight")}>HRI</span>
|
||||||
<span className="text-base font-normal tracking-tight">Studio</span>
|
<span className={cn(textSizes[iconSize], "font-normal tracking-tight")}>Studio</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-muted-foreground truncate text-xs">
|
|
||||||
Research Platform
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
31
src/components/ui/popover.tsx
Normal file
31
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent }
|
||||||
@@ -237,7 +237,7 @@ export function useWizardRos(
|
|||||||
) {
|
) {
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
connect().catch((error) => {
|
connect().catch((error) => {
|
||||||
console.error("[useWizardRos] Auto-connect failed:", error);
|
console.warn("[useWizardRos] Auto-connect failed (retrying manually):", error instanceof Error ? error.message : error);
|
||||||
// Don't retry automatically - let user manually connect
|
// Don't retry automatically - let user manually connect
|
||||||
});
|
});
|
||||||
}, 100); // Small delay to prevent immediate connection attempts
|
}, 100); // Small delay to prevent immediate connection attempts
|
||||||
|
|||||||
110
src/lib/experiment-designer/__tests__/control-flow.test.ts
Normal file
110
src/lib/experiment-designer/__tests__/control-flow.test.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { convertStepsToDatabase, convertDatabaseToSteps } from "../block-converter";
|
||||||
|
import type { ExperimentStep, ExperimentAction } from "../types";
|
||||||
|
|
||||||
|
// Mock Action
|
||||||
|
const branchAction: ExperimentAction = {
|
||||||
|
id: "act-branch-1",
|
||||||
|
name: "Decision",
|
||||||
|
type: "branch",
|
||||||
|
category: "control",
|
||||||
|
parameters: {},
|
||||||
|
source: { kind: "core", baseActionId: "branch" },
|
||||||
|
execution: { transport: "internal" }
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Control Flow Persistence", () => {
|
||||||
|
it("should persist conditional branching options", () => {
|
||||||
|
const originalSteps: ExperimentStep[] = [
|
||||||
|
{
|
||||||
|
id: "step-1",
|
||||||
|
name: "Question",
|
||||||
|
type: "conditional",
|
||||||
|
order: 0,
|
||||||
|
trigger: {
|
||||||
|
type: "trial_start",
|
||||||
|
conditions: {
|
||||||
|
options: [
|
||||||
|
{ label: "Yes", nextStepIndex: 1, variant: "default" },
|
||||||
|
{ label: "No", nextStepIndex: 2, variant: "destructive" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions: [branchAction],
|
||||||
|
expanded: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "step-2",
|
||||||
|
name: "Path A",
|
||||||
|
type: "sequential",
|
||||||
|
order: 1,
|
||||||
|
trigger: { type: "previous_step", conditions: {} },
|
||||||
|
actions: [],
|
||||||
|
expanded: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "step-3",
|
||||||
|
name: "Path B",
|
||||||
|
type: "sequential",
|
||||||
|
order: 2,
|
||||||
|
trigger: { type: "previous_step", conditions: {} },
|
||||||
|
actions: [],
|
||||||
|
expanded: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Simulate Save
|
||||||
|
const dbRows = convertStepsToDatabase(originalSteps);
|
||||||
|
|
||||||
|
// START DEBUG
|
||||||
|
// console.log("DB Rows Conditions:", JSON.stringify(dbRows[0].conditions, null, 2));
|
||||||
|
// END DEBUG
|
||||||
|
|
||||||
|
expect(dbRows[0].type).toBe("conditional");
|
||||||
|
expect((dbRows[0].conditions as any).options).toHaveLength(2);
|
||||||
|
|
||||||
|
// Simulate Load
|
||||||
|
const hydratedSteps = convertDatabaseToSteps(dbRows);
|
||||||
|
|
||||||
|
expect(hydratedSteps[0].type).toBe("conditional");
|
||||||
|
expect((hydratedSteps[0].trigger.conditions as any).options).toHaveLength(2);
|
||||||
|
expect((hydratedSteps[0].trigger.conditions as any).options[0].label).toBe("Yes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should persist loop configuration", () => {
|
||||||
|
const originalSteps: ExperimentStep[] = [
|
||||||
|
{
|
||||||
|
id: "step-loop-1",
|
||||||
|
name: "Repeat Task",
|
||||||
|
type: "loop",
|
||||||
|
order: 0,
|
||||||
|
trigger: {
|
||||||
|
type: "trial_start",
|
||||||
|
conditions: {
|
||||||
|
loop: {
|
||||||
|
iterations: 5,
|
||||||
|
requireApproval: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions: [],
|
||||||
|
expanded: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Simulate Save
|
||||||
|
const dbRows = convertStepsToDatabase(originalSteps);
|
||||||
|
|
||||||
|
// Note: 'loop' type is mapped to 'conditional' in DB, but detailed conditions should survive
|
||||||
|
expect(dbRows[0].type).toBe("conditional");
|
||||||
|
expect((dbRows[0].conditions as any).loop.iterations).toBe(5);
|
||||||
|
|
||||||
|
// Simulate Load
|
||||||
|
const hydratedSteps = convertDatabaseToSteps(dbRows);
|
||||||
|
|
||||||
|
// Checking data integrity
|
||||||
|
expect((hydratedSteps[0].trigger.conditions as any).loop).toBeDefined();
|
||||||
|
expect((hydratedSteps[0].trigger.conditions as any).loop.iterations).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
106
src/lib/experiment-designer/__tests__/hashing.test.ts
Normal file
106
src/lib/experiment-designer/__tests__/hashing.test.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { Hashing } from "../../../components/experiments/designer/state/hashing";
|
||||||
|
import type { ExperimentStep, ExperimentAction } from "../../experiment-designer/types";
|
||||||
|
|
||||||
|
describe("Hashing Utilities", () => {
|
||||||
|
describe("canonicalize", () => {
|
||||||
|
it("should sort object keys", () => {
|
||||||
|
const obj1 = { b: 2, a: 1 };
|
||||||
|
const obj2 = { a: 1, b: 2 };
|
||||||
|
expect(JSON.stringify(Hashing.canonicalize(obj1)))
|
||||||
|
.toBe(JSON.stringify(Hashing.canonicalize(obj2)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove undefined values", () => {
|
||||||
|
const obj = { a: 1, b: undefined, c: null };
|
||||||
|
const canonical = Hashing.canonicalize(obj) as any;
|
||||||
|
expect(canonical).toHaveProperty("a");
|
||||||
|
expect(canonical).toHaveProperty("c"); // null is preserved
|
||||||
|
expect(canonical).not.toHaveProperty("b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve array order", () => {
|
||||||
|
const arr = [3, 1, 2];
|
||||||
|
const canonical = Hashing.canonicalize(arr);
|
||||||
|
expect(canonical).toEqual([3, 1, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeDesignHash", () => {
|
||||||
|
const step1: ExperimentStep = {
|
||||||
|
id: "step-1",
|
||||||
|
name: "Step 1",
|
||||||
|
type: "sequential",
|
||||||
|
order: 0,
|
||||||
|
trigger: { type: "trial_start", conditions: {} },
|
||||||
|
actions: [],
|
||||||
|
expanded: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const step2: ExperimentStep = {
|
||||||
|
id: "step-2",
|
||||||
|
name: "Step 2",
|
||||||
|
type: "sequential",
|
||||||
|
order: 1,
|
||||||
|
trigger: { type: "previous_step", conditions: {} },
|
||||||
|
actions: [],
|
||||||
|
expanded: true
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should produce deterministic hash regardless of input array order", async () => {
|
||||||
|
const hash1 = await Hashing.computeDesignHash([step1, step2]);
|
||||||
|
const hash2 = await Hashing.computeDesignHash([step2, step1]);
|
||||||
|
expect(hash1).toBe(hash2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should change hash when step content changes", async () => {
|
||||||
|
const hash1 = await Hashing.computeDesignHash([step1]);
|
||||||
|
const modifiedStep = { ...step1, name: "Modified Name" };
|
||||||
|
const hash2 = await Hashing.computeDesignHash([modifiedStep]);
|
||||||
|
expect(hash1).not.toBe(hash2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should change hash when parameters change if included", async () => {
|
||||||
|
const action: ExperimentAction = {
|
||||||
|
id: "act-1",
|
||||||
|
type: "log",
|
||||||
|
name: "Log",
|
||||||
|
parameters: { message: "A" },
|
||||||
|
source: { kind: "core", baseActionId: "log" },
|
||||||
|
execution: { transport: "internal" }
|
||||||
|
};
|
||||||
|
const stepWithAction = { ...step1, actions: [action] };
|
||||||
|
|
||||||
|
const hash1 = await Hashing.computeDesignHash([stepWithAction], { includeParameterValues: true });
|
||||||
|
|
||||||
|
const modifiedAction = { ...action, parameters: { message: "B" } };
|
||||||
|
const stepModified = { ...step1, actions: [modifiedAction] };
|
||||||
|
|
||||||
|
const hash2 = await Hashing.computeDesignHash([stepModified], { includeParameterValues: true });
|
||||||
|
|
||||||
|
expect(hash1).not.toBe(hash2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should NOT change hash when parameters change if excluded", async () => {
|
||||||
|
const action: ExperimentAction = {
|
||||||
|
id: "act-1",
|
||||||
|
type: "log",
|
||||||
|
name: "Log",
|
||||||
|
parameters: { message: "A" },
|
||||||
|
source: { kind: "core", baseActionId: "log" },
|
||||||
|
execution: { transport: "internal" }
|
||||||
|
};
|
||||||
|
const stepWithAction = { ...step1, actions: [action] };
|
||||||
|
|
||||||
|
const hash1 = await Hashing.computeDesignHash([stepWithAction], { includeParameterValues: false });
|
||||||
|
|
||||||
|
const modifiedAction = { ...action, parameters: { message: "B" } };
|
||||||
|
const stepModified = { ...step1, actions: [modifiedAction] };
|
||||||
|
|
||||||
|
const hash2 = await Hashing.computeDesignHash([stepModified], { includeParameterValues: false });
|
||||||
|
|
||||||
|
expect(hash1).toBe(hash2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
138
src/lib/experiment-designer/__tests__/store.test.ts
Normal file
138
src/lib/experiment-designer/__tests__/store.test.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { createDesignerStore } from "../../../components/experiments/designer/state/store";
|
||||||
|
import type { ExperimentStep, ExperimentAction } from "../../experiment-designer/types";
|
||||||
|
|
||||||
|
// Helper to create a store instance
|
||||||
|
// We need to bypass the actual hook usage since we are in a non-React env
|
||||||
|
const createTestStore = () => {
|
||||||
|
// Use the exported creator
|
||||||
|
return createDesignerStore({
|
||||||
|
initialSteps: []
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Designer Store Integration", () => {
|
||||||
|
let store: ReturnType<typeof createTestStore>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = createTestStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should initialize with empty steps", () => {
|
||||||
|
expect(store.getState().steps).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should upsert a new step", () => {
|
||||||
|
const step: ExperimentStep = {
|
||||||
|
id: "step-1",
|
||||||
|
name: "Step 1",
|
||||||
|
type: "sequential",
|
||||||
|
order: 0,
|
||||||
|
trigger: { type: "trial_start", conditions: {} },
|
||||||
|
actions: [],
|
||||||
|
expanded: true
|
||||||
|
};
|
||||||
|
|
||||||
|
store.getState().upsertStep(step);
|
||||||
|
expect(store.getState().steps).toHaveLength(1);
|
||||||
|
expect(store.getState().steps[0].id).toBe("step-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update an existing step", () => {
|
||||||
|
const step: ExperimentStep = {
|
||||||
|
id: "step-1",
|
||||||
|
name: "Step 1",
|
||||||
|
type: "sequential",
|
||||||
|
order: 0,
|
||||||
|
trigger: { type: "trial_start", conditions: {} },
|
||||||
|
actions: [],
|
||||||
|
expanded: true
|
||||||
|
};
|
||||||
|
store.getState().upsertStep(step);
|
||||||
|
|
||||||
|
const updatedStep = { ...step, name: "Updated Step" };
|
||||||
|
store.getState().upsertStep(updatedStep);
|
||||||
|
|
||||||
|
expect(store.getState().steps).toHaveLength(1);
|
||||||
|
expect(store.getState().steps[0].name).toBe("Updated Step");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove a step", () => {
|
||||||
|
const step: ExperimentStep = {
|
||||||
|
id: "step-1",
|
||||||
|
name: "Step 1",
|
||||||
|
type: "sequential",
|
||||||
|
order: 0,
|
||||||
|
trigger: { type: "trial_start", conditions: {} },
|
||||||
|
actions: [],
|
||||||
|
expanded: true
|
||||||
|
};
|
||||||
|
store.getState().upsertStep(step);
|
||||||
|
store.getState().removeStep(step.id);
|
||||||
|
expect(store.getState().steps).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reorder steps", () => {
|
||||||
|
const step1: ExperimentStep = {
|
||||||
|
id: "step-1",
|
||||||
|
name: "Step 1",
|
||||||
|
type: "sequential",
|
||||||
|
order: 0,
|
||||||
|
trigger: { type: "trial_start", conditions: {} },
|
||||||
|
actions: [],
|
||||||
|
expanded: true
|
||||||
|
};
|
||||||
|
const step2: ExperimentStep = {
|
||||||
|
id: "step-2",
|
||||||
|
name: "Step 2",
|
||||||
|
type: "sequential",
|
||||||
|
order: 1,
|
||||||
|
trigger: { type: "previous_step", conditions: {} },
|
||||||
|
actions: [],
|
||||||
|
expanded: true
|
||||||
|
};
|
||||||
|
|
||||||
|
store.getState().upsertStep(step1);
|
||||||
|
store.getState().upsertStep(step2);
|
||||||
|
|
||||||
|
// Move Step 1 to index 1 (swap)
|
||||||
|
store.getState().reorderStep(0, 1);
|
||||||
|
|
||||||
|
const steps = store.getState().steps;
|
||||||
|
expect(steps[0].id).toBe("step-2");
|
||||||
|
expect(steps[1].id).toBe("step-1");
|
||||||
|
|
||||||
|
// Orders should be updated
|
||||||
|
expect(steps[0].order).toBe(0);
|
||||||
|
expect(steps[1].order).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should upsert an action into a step", () => {
|
||||||
|
const step: ExperimentStep = {
|
||||||
|
id: "step-1",
|
||||||
|
name: "Step 1",
|
||||||
|
type: "sequential",
|
||||||
|
order: 0,
|
||||||
|
trigger: { type: "trial_start", conditions: {} },
|
||||||
|
actions: [],
|
||||||
|
expanded: true
|
||||||
|
};
|
||||||
|
store.getState().upsertStep(step);
|
||||||
|
|
||||||
|
const action: ExperimentAction = {
|
||||||
|
id: "act-1",
|
||||||
|
type: "log",
|
||||||
|
name: "Log",
|
||||||
|
parameters: {},
|
||||||
|
source: { kind: "core", baseActionId: "log" },
|
||||||
|
execution: { transport: "internal" }
|
||||||
|
};
|
||||||
|
|
||||||
|
store.getState().upsertAction("step-1", action);
|
||||||
|
|
||||||
|
const storedStep = store.getState().steps[0];
|
||||||
|
expect(storedStep.actions).toHaveLength(1);
|
||||||
|
expect(storedStep.actions[0].id).toBe("act-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
122
src/lib/experiment-designer/__tests__/validators.test.ts
Normal file
122
src/lib/experiment-designer/__tests__/validators.test.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { validateExperimentDesign, validateStructural, validateParameters } from "../../../components/experiments/designer/state/validators";
|
||||||
|
import type { ExperimentStep, ExperimentAction, ActionDefinition } from "../../experiment-designer/types";
|
||||||
|
|
||||||
|
// Mock Data
|
||||||
|
const mockActionDef: ActionDefinition = {
|
||||||
|
id: "core.log",
|
||||||
|
name: "Log Info",
|
||||||
|
type: "log",
|
||||||
|
category: "utility",
|
||||||
|
parameters: [
|
||||||
|
{ id: "message", name: "Message", type: "text", required: true },
|
||||||
|
{ id: "level", name: "Level", type: "select", options: ["info", "warn", "error"], default: "info" }
|
||||||
|
],
|
||||||
|
source: { kind: "core", baseActionId: "log" }
|
||||||
|
};
|
||||||
|
|
||||||
|
const validStep: ExperimentStep = {
|
||||||
|
id: "step-1",
|
||||||
|
name: "Step 1",
|
||||||
|
type: "sequential",
|
||||||
|
order: 0,
|
||||||
|
trigger: { type: "trial_start", conditions: {} },
|
||||||
|
actions: [],
|
||||||
|
expanded: true,
|
||||||
|
description: "A valid step"
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Experiment Validators", () => {
|
||||||
|
|
||||||
|
describe("Structural Validation", () => {
|
||||||
|
it("should fail if experiment has no steps", () => {
|
||||||
|
const result = validateExperimentDesign([], { steps: [], actionDefinitions: [] });
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.issues[0].message).toContain("at least one step");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail if step name is empty", () => {
|
||||||
|
const step = { ...validStep, name: "" };
|
||||||
|
const issues = validateStructural([step], { steps: [step], actionDefinitions: [] });
|
||||||
|
expect(issues.some(i => i.field === "name" && i.severity === "error")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail if step type is invalid", () => {
|
||||||
|
const step = { ...validStep, type: "magic_step" as any };
|
||||||
|
const issues = validateStructural([step], { steps: [step], actionDefinitions: [] });
|
||||||
|
expect(issues.some(i => i.field === "type" && i.severity === "error")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Parameter Validation", () => {
|
||||||
|
it("should fail if required parameter is missing", () => {
|
||||||
|
const action: ExperimentAction = {
|
||||||
|
id: "act-1",
|
||||||
|
type: "log",
|
||||||
|
name: "Log",
|
||||||
|
order: 0,
|
||||||
|
parameters: {}, // Missing 'message'
|
||||||
|
source: { kind: "core", baseActionId: "log" },
|
||||||
|
execution: { transport: "internal" }
|
||||||
|
};
|
||||||
|
const step: ExperimentStep = { ...validStep, actions: [action] };
|
||||||
|
|
||||||
|
const issues = validateParameters([step], {
|
||||||
|
steps: [step],
|
||||||
|
actionDefinitions: [mockActionDef]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(issues.some(i => i.field === "parameters.message" && i.severity === "error")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass if required parameter is present", () => {
|
||||||
|
const action: ExperimentAction = {
|
||||||
|
id: "act-1",
|
||||||
|
type: "log",
|
||||||
|
name: "Log",
|
||||||
|
order: 0,
|
||||||
|
parameters: { message: "Hello" },
|
||||||
|
source: { kind: "core", baseActionId: "log" },
|
||||||
|
execution: { transport: "internal" }
|
||||||
|
};
|
||||||
|
const step: ExperimentStep = { ...validStep, actions: [action] };
|
||||||
|
|
||||||
|
const issues = validateParameters([step], {
|
||||||
|
steps: [step],
|
||||||
|
actionDefinitions: [mockActionDef]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should have 0 errors, maybe warnings but no parameter errors
|
||||||
|
const paramErrors = issues.filter(i => i.category === "parameter" && i.severity === "error");
|
||||||
|
expect(paramErrors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate number ranges", () => {
|
||||||
|
const rangeActionDef: ActionDefinition = {
|
||||||
|
...mockActionDef,
|
||||||
|
id: "math",
|
||||||
|
type: "math",
|
||||||
|
parameters: [{ id: "val", name: "Value", type: "number", min: 0, max: 10 }]
|
||||||
|
};
|
||||||
|
|
||||||
|
const action: ExperimentAction = {
|
||||||
|
id: "act-1",
|
||||||
|
type: "math",
|
||||||
|
name: "Math",
|
||||||
|
order: 0,
|
||||||
|
parameters: { val: 15 }, // Too high
|
||||||
|
source: { kind: "core", baseActionId: "math" },
|
||||||
|
execution: { transport: "internal" }
|
||||||
|
};
|
||||||
|
const step: ExperimentStep = { ...validStep, actions: [action] };
|
||||||
|
|
||||||
|
const issues = validateParameters([step], {
|
||||||
|
steps: [step],
|
||||||
|
actionDefinitions: [rangeActionDef]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(issues[0].message).toContain("must be at most 10");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -138,12 +138,21 @@ export function convertActionToDatabase(
|
|||||||
action: ExperimentAction,
|
action: ExperimentAction,
|
||||||
orderIndex: number,
|
orderIndex: number,
|
||||||
): ConvertedAction {
|
): ConvertedAction {
|
||||||
|
// Serialize nested children into parameters for storage
|
||||||
|
const parameters = { ...action.parameters };
|
||||||
|
|
||||||
|
if (action.children && action.children.length > 0) {
|
||||||
|
// Recursively convert children for container actions (sequence, parallel, loop)
|
||||||
|
// Branch actions don't have children - they control step routing
|
||||||
|
parameters.children = action.children.map((child, idx) => convertActionToDatabase(child, idx));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: action.name,
|
name: action.name,
|
||||||
description: `${action.type} action`,
|
description: `${action.type} action`,
|
||||||
type: action.type,
|
type: action.type,
|
||||||
orderIndex,
|
orderIndex,
|
||||||
parameters: action.parameters,
|
parameters,
|
||||||
timeout: estimateActionTimeout(action),
|
timeout: estimateActionTimeout(action),
|
||||||
pluginId: action.source.pluginId,
|
pluginId: action.source.pluginId,
|
||||||
pluginVersion: action.source.pluginVersion,
|
pluginVersion: action.source.pluginVersion,
|
||||||
@@ -231,15 +240,29 @@ export function convertDatabaseToAction(dbAction: any): ExperimentAction {
|
|||||||
retryable: dbAction.retryable ?? false,
|
retryable: dbAction.retryable ?? false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Convert definitions to runtime action, handling nested children
|
||||||
|
const parameters = (dbAction.parameters as Record<string, unknown>) || {};
|
||||||
|
|
||||||
|
// Hydrate nested children (Sequence, Parallel, Loop only)
|
||||||
|
// Branch actions control step routing, not nested actions
|
||||||
|
let children: ExperimentAction[] | undefined = undefined;
|
||||||
|
|
||||||
|
const paramChildren = parameters.children;
|
||||||
|
|
||||||
|
if (Array.isArray(paramChildren)) {
|
||||||
|
children = paramChildren.map((child: any) => convertDatabaseToAction(child));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: dbAction.id,
|
id: dbAction.id,
|
||||||
name: dbAction.name,
|
name: dbAction.name,
|
||||||
description: dbAction.description ?? undefined,
|
description: dbAction.description ?? undefined,
|
||||||
type: dbAction.type,
|
type: dbAction.type,
|
||||||
category: dbAction.category ?? "general",
|
category: dbAction.category ?? "general",
|
||||||
parameters: (dbAction.parameters as Record<string, unknown>) || {},
|
parameters,
|
||||||
source,
|
source,
|
||||||
execution,
|
execution,
|
||||||
parameterSchemaRaw: dbAction.parameterSchemaRaw,
|
parameterSchemaRaw: dbAction.parameterSchemaRaw,
|
||||||
|
children,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export class WizardRosService extends EventEmitter {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onerror = (error) => {
|
this.ws.onerror = (error) => {
|
||||||
console.error("[WizardROS] WebSocket error:", error);
|
console.warn("[WizardROS] WebSocket error (connection may be retried):", error);
|
||||||
clearTimeout(connectionTimeout);
|
clearTimeout(connectionTimeout);
|
||||||
this.isConnecting = false;
|
this.isConnecting = false;
|
||||||
|
|
||||||
@@ -773,7 +773,7 @@ export class WizardRosService extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
await this.connect();
|
await this.connect();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[WizardROS] Reconnect failed:", error);
|
console.warn("[WizardROS] Reconnect failed:", error);
|
||||||
if (this.connectionAttempts < this.maxReconnectAttempts) {
|
if (this.connectionAttempts < this.maxReconnectAttempts) {
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -42,10 +42,90 @@
|
|||||||
"icon": "GitBranch",
|
"icon": "GitBranch",
|
||||||
"color": "#f97316",
|
"color": "#f97316",
|
||||||
"parameters": {},
|
"parameters": {},
|
||||||
"parameterSchema": {},
|
"parameterSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"options": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"label": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"nextStepId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"variant": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Branching options"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"options"
|
||||||
|
]
|
||||||
|
},
|
||||||
"timeout": 0,
|
"timeout": 0,
|
||||||
"retryable": false,
|
"retryable": false,
|
||||||
"nestable": false
|
"nestable": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "loop",
|
||||||
|
"name": "Loop / Repeat",
|
||||||
|
"description": "Repeat a sequence of steps.",
|
||||||
|
"category": "control",
|
||||||
|
"icon": "Repeat",
|
||||||
|
"color": "#8b5cf6",
|
||||||
|
"parameters": {},
|
||||||
|
"parameterSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"iterations": {
|
||||||
|
"title": "Iterations",
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 100,
|
||||||
|
"default": 1,
|
||||||
|
"description": "Number of times to repeat (standard loop)"
|
||||||
|
},
|
||||||
|
"children": {
|
||||||
|
"title": "Actions",
|
||||||
|
"type": "array",
|
||||||
|
"description": "Child actions to repeat"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"iterations"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timeout": 0,
|
||||||
|
"retryable": false,
|
||||||
|
"nestable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "parallel",
|
||||||
|
"name": "Parallel Execution",
|
||||||
|
"description": "Run nested actions simultaneously.",
|
||||||
|
"category": "control",
|
||||||
|
"icon": "Layers",
|
||||||
|
"color": "#10b981",
|
||||||
|
"parameters": {},
|
||||||
|
"parameterSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"children": {
|
||||||
|
"title": "Actions",
|
||||||
|
"type": "array",
|
||||||
|
"description": "Child actions to execute in parallel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timeout": 0,
|
||||||
|
"retryable": false,
|
||||||
|
"nestable": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -389,9 +389,11 @@ export const experimentsRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const convertedSteps = convertDatabaseToSteps(experiment.steps);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...experiment,
|
...experiment,
|
||||||
steps: convertDatabaseToSteps(experiment.steps),
|
steps: convertedSteps,
|
||||||
integrityHash: experiment.integrityHash,
|
integrityHash: experiment.integrityHash,
|
||||||
executionGraphSummary,
|
executionGraphSummary,
|
||||||
pluginDependencies: experiment.pluginDependencies ?? [],
|
pluginDependencies: experiment.pluginDependencies ?? [],
|
||||||
|
|||||||
@@ -665,4 +665,108 @@ export const studiesRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Plugin configuration management
|
||||||
|
getPluginConfiguration: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
studyId: z.string().uuid(),
|
||||||
|
pluginId: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const { studyId, pluginId } = input;
|
||||||
|
const userId = ctx.session.user.id;
|
||||||
|
|
||||||
|
// Check if user has access to this study
|
||||||
|
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(studyMembers.studyId, studyId),
|
||||||
|
eq(studyMembers.userId, userId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You don't have access to this study",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the study plugin configuration
|
||||||
|
const studyPlugin = await ctx.db.query.studyPlugins.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(studyPlugins.studyId, studyId),
|
||||||
|
eq(studyPlugins.pluginId, pluginId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!studyPlugin) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Plugin not installed in this study",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return studyPlugin.configuration ?? {};
|
||||||
|
}),
|
||||||
|
|
||||||
|
updatePluginConfiguration: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
studyId: z.string().uuid(),
|
||||||
|
pluginId: z.string().uuid(),
|
||||||
|
configuration: z.any(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { studyId, pluginId, configuration } = input;
|
||||||
|
const userId = ctx.session.user.id;
|
||||||
|
|
||||||
|
// Check if user has permission to update plugin configuration
|
||||||
|
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(studyMembers.studyId, studyId),
|
||||||
|
eq(studyMembers.userId, userId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership || !["owner", "researcher"].includes(membership.role)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You don't have permission to update plugin configuration",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the plugin configuration
|
||||||
|
const [updatedPlugin] = await ctx.db
|
||||||
|
.update(studyPlugins)
|
||||||
|
.set({
|
||||||
|
configuration,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(studyPlugins.studyId, studyId),
|
||||||
|
eq(studyPlugins.pluginId, pluginId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updatedPlugin) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Plugin not found in this study",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await ctx.db.insert(activityLogs).values({
|
||||||
|
studyId,
|
||||||
|
userId,
|
||||||
|
action: "plugin_configured",
|
||||||
|
description: `Updated plugin configuration`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedPlugin;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -306,6 +306,32 @@ export const trialsRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getLatestSession: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
participantId: z.string(),
|
||||||
|
experimentId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const { db } = ctx;
|
||||||
|
const { participantId, experimentId } = input;
|
||||||
|
|
||||||
|
const conditions: SQL[] = [eq(trials.participantId, participantId)];
|
||||||
|
if (experimentId) {
|
||||||
|
conditions.push(eq(trials.experimentId, experimentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.select({ sessionNumber: trials.sessionNumber })
|
||||||
|
.from(trials)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(desc(trials.sessionNumber))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return result[0]?.sessionNumber ?? 0;
|
||||||
|
}),
|
||||||
|
|
||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -756,11 +782,10 @@ export const trialsRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return { success: true, url: uploadResult.url };
|
return { success: true, url: uploadResult.url };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to archive trial:", error);
|
console.error("Failed to archive trial (non-fatal):", error);
|
||||||
throw new TRPCError({
|
// Do not throw error to client, as archiving is a background task
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
// and shouldn't block the user flow or show alarming errors
|
||||||
message: "Failed to upload archive to storage",
|
return { success: false, error: "Failed to upload archive to storage" };
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -1248,7 +1273,7 @@ export const trialsRouter = createTRPCRouter({
|
|||||||
await db.insert(trialEvents).values({
|
await db.insert(trialEvents).values({
|
||||||
trialId: input.trialId,
|
trialId: input.trialId,
|
||||||
eventType: "manual_robot_action",
|
eventType: "manual_robot_action",
|
||||||
actionId: actionDefinition.id,
|
actionId: null, // Ad-hoc action, not linked to a protocol action definition
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
pluginName: input.pluginName,
|
pluginName: input.pluginName,
|
||||||
|
|||||||
79
src/server/services/__tests__/trial-execution.test.ts
Normal file
79
src/server/services/__tests__/trial-execution.test.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, mock } from "bun:test";
|
||||||
|
import { TrialExecutionEngine } from "~/server/services/trial-execution";
|
||||||
|
import type { StepDefinition } from "~/server/services/trial-execution";
|
||||||
|
|
||||||
|
// Robust Mock for Drizzle Chaining
|
||||||
|
const mockQueryExecutor = mock(() => Promise.resolve([]));
|
||||||
|
|
||||||
|
const mockBuilder = new Proxy({} as any, {
|
||||||
|
get: (target, prop) => {
|
||||||
|
if (prop === 'then') {
|
||||||
|
return (onfulfilled: any, onrejected: any) => mockQueryExecutor().then(onfulfilled, onrejected);
|
||||||
|
}
|
||||||
|
// Return self for any chainable method
|
||||||
|
return () => mockBuilder;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockDb = {
|
||||||
|
select: mock(() => mockBuilder),
|
||||||
|
update: mock(() => mockBuilder),
|
||||||
|
insert: mock(() => mockBuilder),
|
||||||
|
delete: mock(() => mockBuilder),
|
||||||
|
// Helper to mock return values easily
|
||||||
|
__setNextResult: (value: any) => mockQueryExecutor.mockResolvedValueOnce(value),
|
||||||
|
__reset: () => {
|
||||||
|
mockQueryExecutor.mockClear();
|
||||||
|
mockQueryExecutor.mockResolvedValue([]); // Default empty
|
||||||
|
}
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// Mock Data
|
||||||
|
const mockTrialId = "trial-123";
|
||||||
|
const mockExpId = "exp-123";
|
||||||
|
|
||||||
|
const mockStep: StepDefinition = {
|
||||||
|
id: "step-1",
|
||||||
|
name: "Test Step",
|
||||||
|
type: "sequential",
|
||||||
|
orderIndex: 0,
|
||||||
|
actions: [],
|
||||||
|
condition: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("TrialExecutionEngine", () => {
|
||||||
|
let engine: TrialExecutionEngine;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDb.__reset();
|
||||||
|
engine = new TrialExecutionEngine(mockDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should initialize a trial context", async () => {
|
||||||
|
// 1. Fetch Trial
|
||||||
|
mockDb.__setNextResult([{
|
||||||
|
id: mockTrialId,
|
||||||
|
experimentId: mockExpId,
|
||||||
|
status: "scheduled",
|
||||||
|
participantId: "p1"
|
||||||
|
}]);
|
||||||
|
|
||||||
|
// 2. Fetch Steps
|
||||||
|
mockDb.__setNextResult([]); // Return empty steps for this test
|
||||||
|
|
||||||
|
const context = await engine.initializeTrial(mockTrialId);
|
||||||
|
|
||||||
|
expect(context.trialId).toBe(mockTrialId);
|
||||||
|
expect(context.currentStepIndex).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail to initialize non-existent trial", async () => {
|
||||||
|
mockDb.__setNextResult([]); // No trial found
|
||||||
|
|
||||||
|
const promise = engine.initializeTrial("bad-id");
|
||||||
|
// Since we are mocking, we need to ensure the promise rejects as expected
|
||||||
|
// The engine throws "Trial bad-id not found"
|
||||||
|
expect(promise).rejects.toThrow("not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
type RobotAction,
|
type RobotAction,
|
||||||
type RobotActionResult,
|
type RobotActionResult,
|
||||||
} from "./robot-communication";
|
} from "./robot-communication";
|
||||||
|
import type { ExperimentAction } from "~/lib/experiment-designer/types";
|
||||||
|
|
||||||
export type TrialStatus =
|
export type TrialStatus =
|
||||||
| "scheduled"
|
| "scheduled"
|
||||||
@@ -429,6 +430,23 @@ export class TrialExecutionEngine {
|
|||||||
case "hristudio-woz.observe":
|
case "hristudio-woz.observe":
|
||||||
return await this.executeObservationAction(trialId, action);
|
return await this.executeObservationAction(trialId, action);
|
||||||
|
|
||||||
|
// Control Flow Actions
|
||||||
|
case "sequence":
|
||||||
|
case "hristudio-core.sequence":
|
||||||
|
return await this.executeSequenceAction(trialId, action);
|
||||||
|
|
||||||
|
case "parallel":
|
||||||
|
case "hristudio-core.parallel":
|
||||||
|
return await this.executeParallelAction(trialId, action);
|
||||||
|
|
||||||
|
case "loop":
|
||||||
|
case "hristudio-core.loop":
|
||||||
|
return await this.executeLoopAction(trialId, action);
|
||||||
|
|
||||||
|
case "branch":
|
||||||
|
case "hristudio-core.branch":
|
||||||
|
return await this.executeBranchAction(trialId, action);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Check if it's a robot action (contains plugin prefix)
|
// Check if it's a robot action (contains plugin prefix)
|
||||||
if (action.type.includes(".") && !action.type.startsWith("hristudio-")) {
|
if (action.type.includes(".") && !action.type.startsWith("hristudio-")) {
|
||||||
@@ -455,17 +473,27 @@ export class TrialExecutionEngine {
|
|||||||
private async executeWaitAction(
|
private async executeWaitAction(
|
||||||
action: ActionDefinition,
|
action: ActionDefinition,
|
||||||
): Promise<ActionExecutionResult> {
|
): Promise<ActionExecutionResult> {
|
||||||
const duration = (action.parameters.duration as number) || 1000;
|
const rawDuration = action.parameters.duration;
|
||||||
|
// Duration is in SECONDS per definition, default to 1s
|
||||||
|
const durationSeconds = typeof rawDuration === 'string'
|
||||||
|
? parseFloat(rawDuration)
|
||||||
|
: (typeof rawDuration === 'number' ? rawDuration : 1);
|
||||||
|
|
||||||
|
const durationMs = durationSeconds * 1000;
|
||||||
|
|
||||||
|
console.log(`[TrialExecution] Executing wait action: ${action.id}, rawDuration: ${rawDuration}, parsedSeconds: ${durationSeconds}, ms: ${durationMs}`);
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
console.log(`[TrialExecution] Wait action completed: ${action.id}`);
|
||||||
resolve({
|
resolve({
|
||||||
success: true,
|
success: true,
|
||||||
completed: true,
|
completed: true,
|
||||||
duration,
|
duration: durationMs,
|
||||||
data: { waitDuration: duration },
|
|
||||||
|
data: { waitDuration: durationSeconds },
|
||||||
});
|
});
|
||||||
}, duration);
|
}, durationMs);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -866,9 +894,10 @@ export class TrialExecutionEngine {
|
|||||||
let nextStepIndex = context.currentStepIndex + 1;
|
let nextStepIndex = context.currentStepIndex + 1;
|
||||||
|
|
||||||
// Check for branching conditions
|
// Check for branching conditions
|
||||||
if (currentStep.conditions && currentStep.conditions.options) {
|
if (currentStep.conditions) {
|
||||||
const { variable, options } = currentStep.conditions as any;
|
const { variable, options, nextStepId: unconditionalNextId } = currentStep.conditions as any;
|
||||||
|
|
||||||
|
if (options) {
|
||||||
// Default to "last_wizard_response" if variable not specified, for backward compatibility
|
// Default to "last_wizard_response" if variable not specified, for backward compatibility
|
||||||
const variableName = variable || "last_wizard_response";
|
const variableName = variable || "last_wizard_response";
|
||||||
const variableValue = context.variables[variableName];
|
const variableValue = context.variables[variableName];
|
||||||
@@ -899,6 +928,18 @@ export class TrialExecutionEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for unconditional jump if no branch was taken
|
||||||
|
if (nextStepIndex === context.currentStepIndex + 1 && unconditionalNextId) {
|
||||||
|
const targetStepIndex = steps.findIndex(s => s.id === unconditionalNextId);
|
||||||
|
if (targetStepIndex !== -1) {
|
||||||
|
nextStepIndex = targetStepIndex;
|
||||||
|
console.log(`[TrialExecution] Taking unconditional jump to step ID ${unconditionalNextId} (Index ${nextStepIndex})`);
|
||||||
|
} else {
|
||||||
|
console.warn(`[TrialExecution] Unconditional jump target step ID ${unconditionalNextId} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
context.currentStepIndex = nextStepIndex;
|
context.currentStepIndex = nextStepIndex;
|
||||||
|
|
||||||
await this.logTrialEvent(trialId, "step_transition", {
|
await this.logTrialEvent(trialId, "step_transition", {
|
||||||
@@ -1114,4 +1155,275 @@ export class TrialExecutionEngine {
|
|||||||
return `Execute: ${action.name}`;
|
return `Execute: ${action.name}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a sequence of actions in order
|
||||||
|
*/
|
||||||
|
private async executeSequenceAction(
|
||||||
|
trialId: string,
|
||||||
|
action: ActionDefinition,
|
||||||
|
): Promise<ActionExecutionResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const children = action.parameters.children as ActionDefinition[] | undefined;
|
||||||
|
|
||||||
|
if (!children || !Array.isArray(children) || children.length === 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
completed: true,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
data: { message: "Empty sequence completed", childCount: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: ActionExecutionResult[] = [];
|
||||||
|
|
||||||
|
// Execute children sequentially
|
||||||
|
for (const childAction of children) {
|
||||||
|
try {
|
||||||
|
const result = await this.executeAction(trialId, childAction);
|
||||||
|
results.push(result);
|
||||||
|
|
||||||
|
// If any child fails, stop sequence execution
|
||||||
|
if (!result.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
completed: false,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
data: {
|
||||||
|
message: `Sequence failed at action: ${childAction.name}`,
|
||||||
|
completedActions: results.length,
|
||||||
|
totalActions: children.length,
|
||||||
|
results,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
completed: false,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
data: {
|
||||||
|
message: `Sequence error at action: ${childAction.name}`,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
completedActions: results.length,
|
||||||
|
totalActions: children.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
completed: true,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
data: {
|
||||||
|
message: "Sequence completed successfully",
|
||||||
|
completedActions: results.length,
|
||||||
|
results,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute multiple actions in parallel
|
||||||
|
*/
|
||||||
|
private async executeParallelAction(
|
||||||
|
trialId: string,
|
||||||
|
action: ActionDefinition,
|
||||||
|
): Promise<ActionExecutionResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const children = action.parameters.children as ActionDefinition[] | undefined;
|
||||||
|
|
||||||
|
if (!children || !Array.isArray(children) || children.length === 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
completed: true,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
data: { message: "Empty parallel block completed", childCount: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute all children in parallel
|
||||||
|
const promises = children.map((childAction) =>
|
||||||
|
this.executeAction(trialId, childAction).catch((error) => ({
|
||||||
|
success: false,
|
||||||
|
completed: false,
|
||||||
|
duration: 0,
|
||||||
|
data: {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
actionName: childAction.name,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
const allSuccessful = results.every((r) => r.success);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: allSuccessful,
|
||||||
|
completed: true,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
data: {
|
||||||
|
message: allSuccessful
|
||||||
|
? "All parallel actions completed successfully"
|
||||||
|
: "Some parallel actions failed",
|
||||||
|
completedActions: results.filter((r) => r.success).length,
|
||||||
|
totalActions: children.length,
|
||||||
|
results,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute an action multiple times (loop)
|
||||||
|
*/
|
||||||
|
private async executeLoopAction(
|
||||||
|
trialId: string,
|
||||||
|
action: ActionDefinition,
|
||||||
|
): Promise<ActionExecutionResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const children = action.parameters.children as ActionDefinition[] | undefined;
|
||||||
|
const iterations = (action.parameters.iterations as number) || 1;
|
||||||
|
|
||||||
|
if (!children || !Array.isArray(children) || children.length === 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
completed: true,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
data: { message: "Empty loop completed", iterations: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const allResults: ActionExecutionResult[][] = [];
|
||||||
|
|
||||||
|
// Execute the children sequence for each iteration
|
||||||
|
for (let i = 0; i < iterations; i++) {
|
||||||
|
const iterationResults: ActionExecutionResult[] = [];
|
||||||
|
|
||||||
|
for (const childAction of children) {
|
||||||
|
try {
|
||||||
|
const result = await this.executeAction(trialId, childAction);
|
||||||
|
iterationResults.push(result);
|
||||||
|
|
||||||
|
// If any child fails, stop the loop
|
||||||
|
if (!result.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
completed: false,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
data: {
|
||||||
|
message: `Loop failed at iteration ${i + 1}, action: ${childAction.name}`,
|
||||||
|
completedIterations: i,
|
||||||
|
totalIterations: iterations,
|
||||||
|
results: allResults,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
completed: false,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
data: {
|
||||||
|
message: `Loop error at iteration ${i + 1}, action: ${childAction.name}`,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
completedIterations: i,
|
||||||
|
totalIterations: iterations,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allResults.push(iterationResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
completed: true,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
data: {
|
||||||
|
message: `Loop completed ${iterations} iterations successfully`,
|
||||||
|
completedIterations: iterations,
|
||||||
|
results: allResults,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute branch action - prompts wizard to choose a path
|
||||||
|
* Returns the selected option which determines next step routing
|
||||||
|
*/
|
||||||
|
private async executeBranchAction(
|
||||||
|
trialId: string,
|
||||||
|
action: ActionDefinition,
|
||||||
|
): Promise<ActionExecutionResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
await this.logTrialEvent(trialId, "action_started", {
|
||||||
|
actionId: action.id,
|
||||||
|
actionType: action.type,
|
||||||
|
actionName: action.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = (action.parameters.options as any[]) || [];
|
||||||
|
|
||||||
|
if (options.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
completed: false,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
data: {
|
||||||
|
message: "Branch action has no options configured",
|
||||||
|
error: "No routing options available",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branch actions are wizard-driven - they pause execution
|
||||||
|
// and wait for wizard to make a choice
|
||||||
|
// The wizard UI should display the options and record the selection
|
||||||
|
|
||||||
|
await this.logTrialEvent(trialId, "action_completed", {
|
||||||
|
actionId: action.id,
|
||||||
|
actionType: action.type,
|
||||||
|
actionName: action.name,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
data: {
|
||||||
|
message: "Branch action presented to wizard",
|
||||||
|
optionsCount: options.length,
|
||||||
|
options: options.map(opt => ({ label: opt.label, nextStepId: opt.nextStepId })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
completed: true,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
data: {
|
||||||
|
message: "Branch action completed - wizard choice required",
|
||||||
|
options,
|
||||||
|
// The wizard's selected option will determine the next step
|
||||||
|
// This is handled by the trial runner's step navigation logic
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await this.logTrialEvent(trialId, "action_failed", {
|
||||||
|
actionId: action.id,
|
||||||
|
actionType: action.type,
|
||||||
|
actionName: action.name,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
completed: false,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
data: {
|
||||||
|
message: "Branch action failed",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user