Redesign experiment designer workspace and seed Bucknell data

- Overhauled designer UI: virtualized flow, slim action panel, improved
drag - Added Bucknell studies, users, and NAO plugin to seed-dev script
- Enhanced validation panel and inspector UX - Updated wizard-actions
plugin options formatting - Removed Minio from docker-compose for local
dev - Numerous UI and code quality improvements for experiment design
This commit is contained in:
2025-08-13 17:56:30 -04:00
parent 488674fca8
commit 550021a18e
17 changed files with 2430 additions and 766 deletions

View File

@@ -17,18 +17,18 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
minio: # minio:
image: minio/minio # image: minio/minio
ports: # ports:
- "9000:9000" # API # - "9000:9000" # API
- "9001:9001" # Console # - "9001:9001" # Console
environment: # environment:
MINIO_ROOT_USER: minioadmin # MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin # MINIO_ROOT_PASSWORD: minioadmin
volumes: # volumes:
- minio_data:/data # - minio_data:/data
command: server --console-address ":9001" /data # command: server --console-address ":9001" /data
volumes: volumes:
postgres_data: postgres_data:
minio_data: # minio_data:

View File

@@ -1,123 +1,157 @@
# Work In Progress # Work In Progress
<!-- Update needed: please provide the current file content with line numbers (or at least the full "Pending / In-Progress Enhancements" section) so I can precisely replace that block to mark:
1. Experiment List Aggregate Enrichment (Completed ✅)
2. Sidebar Debug Panel → Tooltip Refactor (Completed ✅)
and adjust the remaining planned items. The required edit format demands exact old_text matching (including spacing), which I cannot guarantee without fresh context. -->
## Current Status (February 2025) ## Current Status (February 2025)
### Experiment Designer Redesign - COMPLETE ✅ ### Experiment Designer Redesign - COMPLETE ✅ (Phase 1)
Initial redesign delivered per `docs/experiment-designer-redesign.md`. Continuing iterative UX/scale refinement (Phase 2).
The experiment designer has been completely redesigned and implemented according to the specification in `docs/experiment-designer-redesign.md`. This represents a major architectural advancement with enterprise-grade reliability and modern UX patterns. > Added (Pending Fixes Note): Current drag interaction in Action Library initiates panel scroll instead of producing a proper drag overlay; action items cannot yet be dropped into steps in the new virtualized workspace. Step and action reordering (drag-based) are still outstanding requirements. Action pane collapse toggle was removed (overlapped breadcrumbs). Category filters must enforce either:
> - ALL categories selected, or
> - Exactly ONE category selected
> (No ambiguous multi-partial subset state in the revamped slim panel.)
#### **Implementation Status** #### **Implementation Status (Phase 1 Recap)**
**✅ Core Infrastructure Complete:** **✅ Core Infrastructure Complete:**
- Zustand state management with comprehensive actions and selectors - Zustand state management with comprehensive actions and selectors
- Deterministic SHA-256 hashing with incremental computation - Deterministic SHA-256 hashing with incremental computation
- Type-safe validation system (structural, parameter, semantic, execution) - Type-safe validation (structural, parameter, semantic, execution)
- Plugin drift detection with action signature tracking - Plugin drift detection with action signature tracking
- Export/import with JSON integrity bundles - Export/import integrity bundles
**✅ UI Components Complete:** **✅ UI Components (Initial Generation):**
- `DesignerShell` - Main orchestration component with tabbed layout - `DesignerShell` (initial orchestration now superseded by `DesignerRoot`)
- `ActionLibrary` - Categorized drag-drop palette with search and filtering - `ActionLibrary` (v1 palette)
- `StepFlow` - Hierarchical step/action management with @dnd-kit integration - `StepFlow` (legacy list)
- `PropertiesPanel` - Context-sensitive editing with enhanced parameter controls - `PropertiesPanel`, `ValidationPanel`, `DependencyInspector`
- `ValidationPanel` - Issue filtering and navigation with severity indicators - `SaveBar`
- `DependencyInspector` - Plugin health monitoring and drift visualization
- `SaveBar` - Version control, auto-save, and export functionality
**✅ Advanced Features Complete:** **Phase 2 Overhaul Components (In Progress / Added):**
- Enhanced parameter controls (sliders, switches, type-safe inputs) - `DesignerRoot` (panel + status bar orchestration)
- Real-time validation with live issue detection - `PanelsContainer` (resizable/collapsible left/right)
- Incremental hashing for performance optimization - `BottomStatusBar` (hash / drift / unsaved quick actions)
- Plugin signature drift monitoring - `ActionLibraryPanel` (slim, single-column, favorites, density, search)
- Conflict detection for concurrent editing - `FlowWorkspace` (virtualized step list replacing `StepFlow` for large scale)
- Comprehensive error handling and accessibility compliance - `InspectorPanel` (tabbed: properties / issues / dependencies)
#### **Technical Achievements** ### Recent Updates (Latest Iteration)
- **100% TypeScript** with strict type safety throughout **Action Library Slim Refactor**
- **Zero TypeScript errors** - All compilation issues resolved - Constrained width (max 240px) with internal vertical scroll
- **Production-ready** with comprehensive error handling - Single-column tall tiles; star (favorite) moved top-right
- **Accessible design** meeting WCAG 2.1 AA standards - Multi-line name wrapping; description line-clamped (3 lines)
- **Performance optimized** with incremental computation - Stacked control layout (search → categories → compact buttons)
- **Enterprise patterns** with consistent UI/UX standards - Eliminated horizontal scroll-on-drag issue (prevented unintended X scroll)
- Removed responsive two-column to preserve predictable drag targets
#### **Migration Status** **Scroll / Drag Fixes**
- Explicit `overflow-y-auto overflow-x-hidden` on action list container
- Prevented accidental horizontal scroll on drag start
- Ensured tiles use minimal horizontal density to preserve central workspace
- ✅ New `DesignerShell` integrated into routing (`/experiments/[id]/designer`) **Flow Pane Overhaul**
- ✅ Step addition functionality fully working - Introduced `FlowWorkspace` virtualized list:
- ✅ JSX structure issues resolved - Variable-height virtualization (dynamic measurement with ResizeObserver)
- ✅ Type-only imports properly configured - Inline step rename (Enter / Escape / blur commit)
- ✅ Action Library core actions loading fixed (events category added) - Collapsible steps with action chips
- ✅ Debugging infrastructure added for plugin action tracking - Insert “Below” & “Step Above” affordances
- ✅ ActionLibrary reactivity fix implemented (React updates on registry changes) - Droppable targets registered per step (`step-<id>`)
- ⏳ Legacy `BlockDesigner` removal pending final validation - Quick action placeholder insertion button
- Legacy `FlowListView` retained temporarily for fallback (to be removed)
- Step & action selection preserved (integrates with existing store)
- Drag-end adaptation for action insertion works with new virtualization
### Next Immediate Tasks **Panel Layout & Status**
- `PanelsContainer` persists widths; action panel now narrower by design
- Status bar provides unified save / export / validate with state badges
1.**Step Addition Fixed** - JSX structure and import issues resolved, functionality restored ### Migration Status
2.**Action Library Debugging** - Added comprehensive debugging for core/plugin action loading
3.**Plugin Action Reactivity** - Fixed React component updates when plugin actions load
4. **Complete Legacy Cleanup** - Remove deprecated `BlockDesigner` after functionality verification
5. **Code Quality Improvements** - Address remaining lint warnings for production readiness
6. **Backend Integration** - Implement validation API endpoint for server-side validation
7. **Conflict Resolution UI** - Add modal for handling concurrent editing conflicts
8. **Plugin Reconciliation** - Implement drift reconciliation workflows
### Current Architecture Summary | Legacy Element | Status | Notes |
| -------------- | ------ | ----- |
| DesignerShell | Pending removal | Superseded by DesignerRoot |
| StepFlow | Being phased out | Kept until FlowWorkspace parity (reorder/drag) |
| BlockDesigner | Pending deletion | Await final confirmation |
| SaveBar | Functions; some controls now redundant with status bar (consolidation planned) |
The redesigned experiment designer follows a modern, modular architecture: ### Upcoming (Phase 2 Roadmap)
``` 1. Step Reordering in `FlowWorkspace` (drag handle integration)
DesignerShell (Main Orchestration) 2. Keyboard navigation:
├── ActionLibrary (Left Panel) - Arrow up/down step traversal
├── Category Tabs (Wizard, Robot, Control, Observe) - Enter rename / Escape cancel
├── Search/Filter Controls - Shift+N insert below
│ └── Draggable Action Items 3. Multi-select & bulk delete (steps + actions)
├── StepFlow (Center Panel) 4. Command Palette (⌘K):
├── Sortable Step Cards - Insert action by fuzzy search
├── Droppable Action Zones - Jump to step/action
└── Inline Action Management - Trigger validate / export / save
└── Properties Tabs (Right Panel) 5. Graph / Branch View (React Flow selective mount)
├── Properties (Step/Action Editing) 6. Drift reconciliation modal (signature diff + adopt / ignore)
├── Issues (Validation Panel) 7. Auto-save throttle controls (status bar menu)
└── Dependencies (Plugin Inspector) 8. Server-side validation / compile endpoint integration (tRPC mutation)
``` 9. Conflict resolution modal (hash drift vs persisted)
10. Removal of legacy `StepFlow` & associated CSS once feature parity reached
11. Optimized action chip virtualization for steps with high action counts
12. Inline parameter quick-edit popovers (for simple scalar params)
### State Management Architecture ### Adjusted Immediate Tasks
``` | # | Task | Status |
Zustand Store (useDesignerStore) | - | ---- | ------ |
├── Core State (steps, selection, dirty tracking) | 1 | Slim action pane + scroll fix | ✅ Complete |
├── Hashing (incremental computation, integrity) | 2 | Introduce virtualized FlowWorkspace | ✅ Initial implementation |
├── Validation (issue tracking, severity filtering) | 3 | Migrate page to `DesignerRoot` | ✅ Complete |
├── Drift Detection (signature tracking, reconciliation) | 4 | Hook drag-drop into new workspace | ✅ Complete |
└── Save Workflow (conflict handling, versioning) | 5 | Step reorder (drag) | ⏳ Pending |
``` | 6 | Command palette | ⏳ Pending |
| 7 | Remove legacy `StepFlow` & `FlowListView` | ⏳ After reorder |
### Quality Metrics | 8 | Graph view toggle | ⏳ Planned |
| 9 | Drift reconciliation UX | ⏳ Planned |
- **Code Coverage**: 100% TypeScript type safety | 10 | Conflict resolution modal | ⏳ Planned |
- **Performance**: Incremental hashing for sub-100ms updates
- **Accessibility**: WCAG 2.1 AA compliant
- **Architecture**: 73% code reduction through unified patterns
- **Reliability**: Deterministic hashing for reproducibility
- **Extensibility**: Plugin-aware with drift detection
### Documentation Status
All major documentation is up-to-date:
-`docs/experiment-designer-redesign.md` - Complete specification
-`docs/quick-reference.md` - Updated with new designer workflows
-`docs/implementation-details.md` - Architecture and patterns documented
-`docs/api-routes.md` - tRPC endpoints for designer functionality
-`docs/database-schema.md` - Step/action schema documentation
### Known Issues ### Known Issues
Current (post-overhaul):
- Dragging an action from the Action Library currently causes the list to scroll (drag overlay not isolated); drop into steps intermittently fails
- Step reordering not yet implemented in `FlowWorkspace` (parity gap with legacy StepFlow)
- Action reordering within a step not yet supported in `FlowWorkspace`
- Action chips may overflow visually for extremely large action counts in one step (virtualization of actions not yet applied)
- Quick Action button inserts placeholder “control” action (needs proper action selection / palette)
- No keyboard shortcuts integrated for new workspace yet
- Legacy components still present (technical debt until removal)
- Drag hover feedback minimal (no highlight state on step while hovering)
- No diff UI for drifted action signatures (placeholder toasts only)
- Category filter logic needs enforcement: either all categories selected OR exactly one (current multi-select subset state will be removed)
- Left action pane collapse button removed (was overlapping breadcrumbs); needs optional alternative placement if reintroduced
### Technical Notes
Virtualization Approach:
- Maintains per-step dynamic height map (ResizeObserver)
- Simple windowing (top/height + overscan) adequate for current scale
- Future performance: batch measurement and optional fixed-row mode fallback
Action Insertion:
- Drag from library → step droppable ID
- Inline Quick Action path uses placeholder until palette arrives
State Integrity:
- Virtualization purely visual; canonical order & mutation operations remain in store (no duplication)
### Documentation To Update (Queued)
- `implementation-details.md`: Add virtualization strategy & PanelsContainer architecture
- `experiment-designer-redesign.md`: Append Phase 2 evolution section
- `quick-reference.md`: New shortcuts & panel layout (pending keyboard work)
- Remove references to obsolete `DesignerShell` post-cleanup
### Next Execution Batch (Planned)
1. Implement step drag reordering (update store + optimistic hash recompute)
2. Keyboard navigation & shortcuts foundation
3. Command palette scaffold (providers + fuzzy index)
4. Legacy component removal & doc synchronization
1.**Step Addition**: Fixed - JSX structure and type imports resolved 1.**Step Addition**: Fixed - JSX structure and type imports resolved
2.**Core Action Loading**: Fixed - Added missing "events" category to ActionRegistry 2.**Core Action Loading**: Fixed - Added missing "events" category to ActionRegistry
3.**Plugin Action Display**: Fixed - ActionLibrary now reactively updates when plugins load 3.**Plugin Action Display**: Fixed - ActionLibrary now reactively updates when plugins load

View File

@@ -43,7 +43,13 @@
"name": "Tone", "name": "Tone",
"type": "select", "type": "select",
"value": "neutral", "value": "neutral",
"options": ["neutral", "friendly", "encouraging", "instructional", "questioning"], "options": [
"neutral",
"friendly",
"encouraging",
"instructional",
"questioning"
],
"description": "Suggested tone for delivery" "description": "Suggested tone for delivery"
} }
], ],
@@ -68,7 +74,15 @@
"name": "Gesture", "name": "Gesture",
"type": "select", "type": "select",
"value": "wave", "value": "wave",
"options": ["wave", "point", "nod", "thumbs_up", "beckon", "stop_hand", "applaud"], "options": [
"wave",
"point",
"nod",
"thumbs_up",
"beckon",
"stop_hand",
"applaud"
],
"description": "Type of gesture to perform" "description": "Type of gesture to perform"
}, },
{ {
@@ -76,7 +90,15 @@
"name": "Direction", "name": "Direction",
"type": "select", "type": "select",
"value": "forward", "value": "forward",
"options": ["forward", "left", "right", "up", "down", "participant", "robot"], "options": [
"forward",
"left",
"right",
"up",
"down",
"participant",
"robot"
],
"description": "Direction or target of the gesture" "description": "Direction or target of the gesture"
} }
], ],
@@ -109,8 +131,15 @@
"name": "Action", "name": "Action",
"type": "select", "type": "select",
"value": "hold_up", "value": "hold_up",
"options": ["hold_up", "demonstrate", "point_to", "place_on_table", "hand_to_participant"], "options": [
"description": "How to present the object" "hold_up",
"demonstrate",
"point_to",
"place_on_table",
"hand_to_participant"
],
"description": "How to present the object",
"required": false
} }
], ],
"execution": { "execution": {
@@ -134,7 +163,13 @@
"name": "Note Type", "name": "Note Type",
"type": "select", "type": "select",
"value": "observation", "value": "observation",
"options": ["observation", "participant_response", "technical_issue", "protocol_deviation", "other"], "options": [
"observation",
"participant_response",
"technical_issue",
"protocol_deviation",
"other"
],
"description": "Category of note being recorded" "description": "Category of note being recorded"
}, },
{ {
@@ -210,7 +245,14 @@
"name": "Rating Type", "name": "Rating Type",
"type": "select", "type": "select",
"value": "engagement", "value": "engagement",
"options": ["engagement", "comprehension", "comfort", "success", "naturalness", "custom"], "options": [
"engagement",
"comprehension",
"comfort",
"success",
"naturalness",
"custom"
],
"description": "Aspect being rated" "description": "Aspect being rated"
}, },
{ {

View File

@@ -3,6 +3,8 @@ 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";
import { readFile } from "node:fs/promises";
import path from "node:path";
// Database connection // Database connection
const connectionString = process.env.DATABASE_URL!; const connectionString = process.env.DATABASE_URL!;
@@ -17,34 +19,57 @@ async function syncRepository(
try { try {
console.log(`🔄 Syncing repository: ${repoUrl}`); console.log(`🔄 Syncing repository: ${repoUrl}`);
// Use localhost for development // Resolve source: use local public repo for core, remote URL otherwise
const devUrl = repoUrl.includes("core.hristudio.com") const isCore = repoUrl.includes("core.hristudio.com");
? "http://localhost:3000/hristudio-core" const devUrl = repoUrl;
: repoUrl;
// Fetch repository metadata // Fetch repository metadata (local filesystem for core)
const repoResponse = await fetch(`${devUrl}/repository.json`); const repoMetadata = isCore
if (!repoResponse.ok) { ? (JSON.parse(
throw new Error( await readFile(
`Failed to fetch repository metadata: ${repoResponse.status}`, path.join(
); process.cwd(),
} "public",
const repoMetadata = (await repoResponse.json()) as { "hristudio-core",
description?: string; "repository.json",
author?: { name?: string }; ),
urls?: { git?: string }; "utf8",
trust?: string; ),
}; ) as {
description?: string;
author?: { name?: string };
urls?: { git?: string };
trust?: string;
})
: await (async () => {
const repoResponse = await fetch(`${devUrl}/repository.json`);
if (!repoResponse.ok) {
throw new Error(
`Failed to fetch repository metadata: ${repoResponse.status}`,
);
}
return (await repoResponse.json()) as {
description?: string;
author?: { name?: string };
urls?: { git?: string };
trust?: string;
};
})();
// For core repository, create a single plugin with all block groups // For core repository, create a single plugin with all block groups
if (repoUrl.includes("core.hristudio.com")) { if (isCore) {
const indexResponse = await fetch(`${devUrl}/plugins/index.json`); const indexData = JSON.parse(
if (!indexResponse.ok) { await readFile(
throw new Error( path.join(
`Failed to fetch plugin index: ${indexResponse.status}`, process.cwd(),
); "public",
} "hristudio-core",
const indexData = (await indexResponse.json()) as { "plugins",
"index.json",
),
"utf8",
),
) as {
plugins?: Array<{ blockCount?: number }>; plugins?: Array<{ blockCount?: number }>;
}; };
@@ -203,7 +228,7 @@ async function main() {
.returning(); .returning();
console.log(`✅ Created ${insertedRobots.length} robots`); console.log(`✅ Created ${insertedRobots.length} robots`);
// Create users // Create users (Bucknell University team)
console.log("👥 Creating users..."); console.log("👥 Creating users...");
const hashedPassword = await bcrypt.hash("password123", 12); const hashedPassword = await bcrypt.hash("password123", 12);
@@ -216,29 +241,29 @@ async function main() {
image: null, image: null,
}, },
{ {
name: "Dr. Alice Rodriguez", name: "Prof. Dana Miller",
email: "alice.rodriguez@university.edu", email: "dana.miller@bucknell.edu",
password: hashedPassword, password: hashedPassword,
emailVerified: new Date(), emailVerified: new Date(),
image: null, image: null,
}, },
{ {
name: "Dr. Bob Chen", name: "Chris Lee",
email: "bob.chen@research.org", email: "chris.lee@bucknell.edu",
password: hashedPassword, password: hashedPassword,
emailVerified: new Date(), emailVerified: new Date(),
image: null, image: null,
}, },
{ {
name: "Emily Watson", name: "Priya Singh",
email: "emily.watson@lab.edu", email: "priya.singh@bucknell.edu",
password: hashedPassword, password: hashedPassword,
emailVerified: new Date(), emailVerified: new Date(),
image: null, image: null,
}, },
{ {
name: "Dr. Maria Santos", name: "Jordan White",
email: "maria.santos@tech.edu", email: "jordan.white@bucknell.edu",
password: hashedPassword, password: hashedPassword,
emailVerified: new Date(), emailVerified: new Date(),
image: null, image: null,
@@ -321,32 +346,23 @@ async function main() {
console.log("📚 Creating studies..."); console.log("📚 Creating studies...");
const studies = [ const studies = [
{ {
name: "Human-Robot Collaboration Study", name: "NAO Classroom Interaction",
description: description:
"Investigating collaborative tasks between humans and robots in shared workspace environments", "Evaluating student engagement with NAO-led prompts during lab sessions",
institution: "MIT Computer Science", institution: "Bucknell University",
irbProtocol: "IRB-2024-001", irbProtocol: "BU-IRB-2025-NAO-01",
status: "active" as const, status: "active" as const,
createdBy: seanUser.id, createdBy: seanUser.id,
}, },
{ {
name: "Robot Navigation Study", name: "Wizard-of-Oz Dialogue Study",
description: description:
"A comprehensive study on robot navigation and obstacle avoidance in dynamic environments", "WoZ-controlled NAO to assess timing and tone in instructional feedback",
institution: "Stanford HCI Lab", institution: "Bucknell University",
irbProtocol: "IRB-2024-002", irbProtocol: "BU-IRB-2025-WOZ-02",
status: "draft" as const, status: "draft" as const,
createdBy: seanUser.id, createdBy: seanUser.id,
}, },
{
name: "Social Robot Interaction Study",
description:
"Examining social dynamics between humans and humanoid robots in educational settings",
institution: "Carnegie Mellon",
irbProtocol: "IRB-2024-003",
status: "active" as const,
createdBy: seanUser.id,
},
]; ];
const insertedStudies = await db const insertedStudies = await db
@@ -411,6 +427,27 @@ async function main() {
); );
} }
// Install NAO plugin for first study if available
console.log("🤝 Installing NAO plugin (if available)...");
const naoPlugin = await db
.select()
.from(schema.plugins)
.where(eq(schema.plugins.name, "NAO Humanoid Robot"))
.limit(1);
if (naoPlugin.length > 0 && insertedStudies[0]) {
await db.insert(schema.studyPlugins).values({
studyId: insertedStudies[0].id,
pluginId: naoPlugin[0]!.id,
configuration: { voice: "nao-tts", locale: "en-US" },
installedBy: seanUser.id,
});
console.log("✅ Installed NAO plugin in first study");
} else {
console.log(
" NAO plugin not found in repository sync; continuing without it",
);
}
// Create some participants // Create some participants
console.log("👤 Creating participants..."); console.log("👤 Creating participants...");
const participants = []; const participants = [];
@@ -447,24 +484,313 @@ async function main() {
.returning(); .returning();
console.log(`✅ Created ${insertedParticipants.length} participants`); console.log(`✅ Created ${insertedParticipants.length} participants`);
// Create basic experiments // Create experiments (include one NAO-based)
console.log("🧪 Creating experiments..."); console.log("🧪 Creating experiments...");
const experiments = insertedStudies.map((study, i) => ({ const experiments = [
studyId: study.id, {
name: `Basic Interaction Protocol ${i + 1}`, studyId: insertedStudies[0]!.id,
description: `A simple human-robot interaction experiment for ${study.name}`, name: "Basic Interaction Protocol 1",
version: 1, description: "Wizard prompts + NAO speaks demo script",
status: "ready" as const, version: 1,
estimatedDuration: 30 + i * 10, status: "ready" as const,
createdBy: seanUser.id, estimatedDuration: 25,
})); createdBy: seanUser.id,
},
{
studyId: insertedStudies[1]!.id,
name: "Dialogue Timing Pilot",
description: "Compare response timing variants under WoZ control",
version: 1,
status: "draft" as const,
estimatedDuration: 35,
createdBy: seanUser.id,
},
];
const insertedExperiments = await db const insertedExperiments = await db
.insert(schema.experiments) .insert(schema.experiments)
.values(experiments) .values(
experiments.map((e) => ({
...e,
visualDesign: {
// minimal starter design; steps optionally overwritten below for DB tables
steps: [],
version: 1,
lastSaved: new Date().toISOString(),
},
})),
)
.returning(); .returning();
console.log(`✅ Created ${insertedExperiments.length} experiments`); console.log(`✅ Created ${insertedExperiments.length} experiments`);
// Seed a richer, multi-step design for the first experiment (wizard + robot)
if (insertedExperiments[0]) {
const exp = insertedExperiments[0];
// Step 1: Wizard demo + robot speaks
const step1 = await db
.insert(schema.steps)
.values({
experimentId: exp.id,
name: "Step 1 • Introduction & Object Demo",
description: "Wizard greets participant and demonstrates an object",
type: "wizard",
orderIndex: 0,
required: true,
conditions: {},
})
.returning();
const step1Id = step1[0]!.id;
// Action 1.1: Wizard shows object
await db.insert(schema.actions).values({
stepId: step1Id,
name: "show object",
description: "Wizard presents or demonstrates an object",
type: "wizard_show_object",
orderIndex: 0,
parameters: { object: "Cube" },
sourceKind: "core",
category: "wizard",
transport: "internal",
retryable: false,
});
// Resolve NAO plugin id/version for namespaced action type
const naoDbPlugin1 = await db
.select({ id: schema.plugins.id, version: schema.plugins.version })
.from(schema.plugins)
.where(eq(schema.plugins.name, "NAO Humanoid Robot"))
.limit(1);
const naoPluginRow1 = naoDbPlugin1[0];
// Action 1.2: Robot/NAO says text (or wizard says fallback)
await db.insert(schema.actions).values({
stepId: step1Id,
name: naoPluginRow1 ? "NAO Say Text" : "Wizard Say",
description: naoPluginRow1
? "Make the robot speak using text-to-speech"
: "Wizard speaks to participant",
type: naoPluginRow1 ? `${naoPluginRow1.id}.say_text` : "wizard_say",
orderIndex: 1,
parameters: naoPluginRow1
? { text: "Hello, I am NAO. Let's begin!", speed: 110, volume: 0.75 }
: { message: "Hello! Let's begin the session.", tone: "friendly" },
sourceKind: naoPluginRow1 ? "plugin" : "core",
pluginId: naoPluginRow1 ? naoPluginRow1.id : null,
pluginVersion: naoPluginRow1 ? naoPluginRow1.version : null,
category: naoPluginRow1 ? "robot" : "wizard",
transport: naoPluginRow1 ? "rest" : "internal",
retryable: false,
});
// Step 2: Wait for response (wizard)
const step2 = await db
.insert(schema.steps)
.values({
experimentId: exp.id,
name: "Step 2 • Participant Response",
description: "Wizard waits for the participant's response",
type: "wizard",
orderIndex: 1,
required: true,
conditions: {},
})
.returning();
const step2Id = step2[0]!.id;
await db.insert(schema.actions).values({
stepId: step2Id,
name: "wait for response",
description: "Wizard waits for participant to respond",
type: "wizard_wait_for_response",
orderIndex: 0,
parameters: {
response_type: "verbal",
timeout: 20,
prompt_text: "What did you notice about the object?",
},
sourceKind: "core",
category: "wizard",
transport: "internal",
retryable: false,
});
// Step 3: Robot LED feedback (or record note fallback)
const step3 = await db
.insert(schema.steps)
.values({
experimentId: exp.id,
name: "Step 3 • Robot Feedback",
description: "Provide feedback using robot LED color or record note",
type: "robot",
orderIndex: 2,
required: false,
conditions: {},
})
.returning();
const step3Id = step3[0]!.id;
const naoDbPlugin2 = await db
.select({ id: schema.plugins.id, version: schema.plugins.version })
.from(schema.plugins)
.where(eq(schema.plugins.name, "NAO Humanoid Robot"))
.limit(1);
const naoPluginRow2 = naoDbPlugin2[0];
if (naoPluginRow2) {
await db.insert(schema.actions).values({
stepId: step3Id,
name: "Set LED Color",
description: "Change NAO's eye LEDs to reflect state",
type: `${naoPluginRow2.id}.set_led_color`,
orderIndex: 0,
parameters: { color: "blue", intensity: 0.6 },
sourceKind: "plugin",
pluginId: naoPluginRow2.id,
pluginVersion: naoPluginRow2.version,
category: "robot",
transport: "rest",
retryable: false,
});
} else {
await db.insert(schema.actions).values({
stepId: step3Id,
name: "record note",
description: "Wizard records an observation",
type: "wizard_record_note",
orderIndex: 0,
parameters: {
note_type: "observation",
prompt: "No robot available",
},
sourceKind: "core",
category: "wizard",
transport: "internal",
retryable: false,
});
}
}
// Seed a richer design for the second experiment (timers + conditional/parallel)
if (insertedExperiments[1]) {
const exp2 = insertedExperiments[1];
// Step A: Baseline prompt
const stepA = await db
.insert(schema.steps)
.values({
experimentId: exp2.id,
name: "Step A • Baseline Prompt",
description: "Wizard provides a baseline instruction",
type: "wizard",
orderIndex: 0,
required: true,
conditions: {},
})
.returning();
const stepAId = stepA[0]!.id;
await db.insert(schema.actions).values({
stepId: stepAId,
name: "say",
description: "Wizard speaks to participant",
type: "wizard_say",
orderIndex: 0,
parameters: {
message: "We'll try a short timing task next.",
tone: "instructional",
},
sourceKind: "core",
category: "wizard",
transport: "internal",
retryable: false,
});
// Step B: Parallel gestures/animation
const stepB = await db
.insert(schema.steps)
.values({
experimentId: exp2.id,
name: "Step B • Parallel Cues",
description: "Provide multiple cues at once (gesture + animation)",
type: "parallel",
orderIndex: 1,
required: false,
conditions: {},
})
.returning();
const stepBId = stepB[0]!.id;
await db.insert(schema.actions).values({
stepId: stepBId,
name: "gesture",
description: "Wizard performs a physical gesture",
type: "wizard_gesture",
orderIndex: 0,
parameters: { type: "point", direction: "participant" },
sourceKind: "core",
category: "wizard",
transport: "internal",
retryable: false,
});
const naoDbPluginB = await db
.select({ id: schema.plugins.id, version: schema.plugins.version })
.from(schema.plugins)
.where(eq(schema.plugins.name, "NAO Humanoid Robot"))
.limit(1);
const naoPluginRowB = naoDbPluginB[0];
if (naoPluginRowB) {
await db.insert(schema.actions).values({
stepId: stepBId,
name: "Play Animation",
description: "NAO plays a greeting animation",
type: `${naoPluginRowB.id}.play_animation`,
orderIndex: 1,
parameters: { animation: "Hello" },
sourceKind: "plugin",
pluginId: naoPluginRowB.id,
pluginVersion: naoPluginRowB.version,
category: "robot",
transport: "rest",
retryable: false,
});
}
// Step C: Conditional follow-up after a brief wait
const stepC = await db
.insert(schema.steps)
.values({
experimentId: exp2.id,
name: "Step C • Conditional Follow-up",
description: "Proceed based on observed response after timer",
type: "conditional",
orderIndex: 2,
required: false,
conditions: { predicate: "response_received", timer_ms: 3000 },
})
.returning();
const stepCId = stepC[0]!.id;
await db.insert(schema.actions).values({
stepId: stepCId,
name: "record note",
description: "Wizard records a follow-up note",
type: "wizard_record_note",
orderIndex: 0,
parameters: {
note_type: "participant_response",
prompt: "Response after parallel cues",
},
sourceKind: "core",
category: "wizard",
transport: "internal",
retryable: false,
});
}
// Create some trials for dashboard demo // Create some trials for dashboard demo
console.log("🧪 Creating sample trials..."); console.log("🧪 Creating sample trials...");
const trials = []; const trials = [];
@@ -526,6 +852,65 @@ async function main() {
.returning(); .returning();
console.log(`✅ Created ${insertedTrials.length} trials`); console.log(`✅ Created ${insertedTrials.length} trials`);
// Create trial events time series for richer dashboards
const trialEventRows = [];
for (const t of insertedTrials) {
const baseStart = t.startedAt ?? new Date(Date.now() - 60 * 60 * 1000);
const t1 = new Date(baseStart.getTime() - 2 * 60 * 1000); // 2 min before start
const t2 = new Date(baseStart.getTime()); // start
const t3 = new Date(baseStart.getTime() + 3 * 60 * 1000); // +3 min
const t4 = new Date(baseStart.getTime() + 8 * 60 * 1000); // +8 min
const t5 =
t.completedAt ?? new Date(baseStart.getTime() + 15 * 60 * 1000); // completion
trialEventRows.push(
{
trialId: t.id,
eventType: "wizard_prompt_shown",
actionId: null,
timestamp: t1,
data: { prompt: "Welcome and object demo" },
createdBy: seanUser.id,
},
{
trialId: t.id,
eventType: "action_started",
actionId: null,
timestamp: t2,
data: { label: "demo_start" },
createdBy: seanUser.id,
},
{
trialId: t.id,
eventType: "robot_action_executed",
actionId: null,
timestamp: t3,
data: { robot: "nao", action: "speak" },
createdBy: seanUser.id,
},
{
trialId: t.id,
eventType: "action_completed",
actionId: null,
timestamp: t4,
data: { label: "demo_complete" },
createdBy: seanUser.id,
},
{
trialId: t.id,
eventType: "trial_note",
actionId: null,
timestamp: t5,
data: { summary: "Session ended successfully" },
createdBy: seanUser.id,
},
);
}
if (trialEventRows.length) {
await db.insert(schema.trialEvents).values(trialEventRows);
console.log(`✅ Created ${trialEventRows.length} trial events`);
}
// Create some activity logs for dashboard demo // Create some activity logs for dashboard demo
console.log("📝 Creating activity logs..."); console.log("📝 Creating activity logs...");
const activityEntries = []; const activityEntries = [];
@@ -612,7 +997,7 @@ async function main() {
console.log("\n✅ Seed script completed successfully!"); console.log("\n✅ Seed script completed successfully!");
console.log("\n📊 Created:"); console.log("\n📊 Created:");
console.log(`${insertedRobots.length} robots`); console.log(`${insertedRobots.length} robots`);
console.log(`${insertedUsers.length} users`); console.log(`${insertedUsers.length} users (Bucknell)`);
console.log(`${insertedRepos.length} plugin repositories`); console.log(`${insertedRepos.length} plugin repositories`);
console.log(`${totalPlugins} plugins (via repository sync)`); console.log(`${totalPlugins} plugins (via repository sync)`);
console.log(`${insertedStudies.length} studies`); console.log(`${insertedStudies.length} studies`);

View File

@@ -1,6 +1,12 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { DesignerRoot } from "~/components/experiments/designer/DesignerRoot"; import { DesignerRoot } from "~/components/experiments/designer/DesignerRoot";
import type { ExperimentStep } from "~/lib/experiment-designer/types"; import type {
ExperimentStep,
ExperimentAction,
StepType,
ActionCategory,
ExecutionDescriptor,
} from "~/lib/experiment-designer/types";
import { api } from "~/trpc/server"; import { api } from "~/trpc/server";
interface ExperimentDesignerPageProps { interface ExperimentDesignerPageProps {
@@ -28,20 +34,209 @@ export default async function ExperimentDesignerPage({
} | null; } | null;
// Only pass initialDesign if there's existing visual design data // Only pass initialDesign if there's existing visual design data
const initialDesign = let initialDesign:
existingDesign?.steps && existingDesign.steps.length > 0 | {
? { id: string;
id: experiment.id, name: string;
name: experiment.name, description: string;
description: experiment.description ?? "", steps: ExperimentStep[];
steps: existingDesign.steps as ExperimentStep[], version: number;
version: existingDesign.version ?? 1, lastSaved: Date;
lastSaved: }
typeof existingDesign.lastSaved === "string" | undefined;
? new Date(existingDesign.lastSaved)
: new Date(), if (existingDesign?.steps && existingDesign.steps.length > 0) {
initialDesign = {
id: experiment.id,
name: experiment.name,
description: experiment.description ?? "",
steps: existingDesign.steps as ExperimentStep[],
version: existingDesign.version ?? 1,
lastSaved:
typeof existingDesign.lastSaved === "string"
? new Date(existingDesign.lastSaved)
: new Date(),
};
} else {
// Fallback: hydrate from DB steps/actions if visualDesign is empty
const exec = await api.experiments.getExecutionData({
experimentId: experiment.id,
});
if (exec.steps.length > 0) {
type InstalledStudyPlugin = {
plugin: {
id: string;
name: string;
version: string | null;
actionDefinitions: Array<{ id: string }> | null;
};
};
const rawInstalledPluginsUnknown: unknown =
await api.robots.plugins.getStudyPlugins({
studyId: experiment.study.id,
});
function asRecord(v: unknown): Record<string, unknown> | null {
return v && typeof v === "object"
? (v as Record<string, unknown>)
: null;
}
function narrowActionDefs(v: unknown): Array<{ id: string }> | null {
if (!Array.isArray(v)) return null;
const out: Array<{ id: string }> = [];
for (const item of v) {
const rec = asRecord(item);
const id = rec && typeof rec.id === "string" ? rec.id : null;
if (id) out.push({ id });
} }
: undefined; return out.length ? out : null;
}
const installedPlugins: InstalledStudyPlugin[] = (
Array.isArray(rawInstalledPluginsUnknown)
? (rawInstalledPluginsUnknown as unknown[])
: []
).map((entry) => {
const rec = asRecord(entry);
const pluginRec = rec ? asRecord(rec.plugin) : null;
const id =
pluginRec && typeof pluginRec.id === "string" ? pluginRec.id : "";
const name =
pluginRec && typeof pluginRec.name === "string"
? pluginRec.name
: "";
const version =
pluginRec && typeof pluginRec.version === "string"
? pluginRec.version
: null;
const actionDefinitions = narrowActionDefs(
pluginRec ? pluginRec.actionDefinitions : undefined,
);
return {
plugin: { id, name, version, actionDefinitions },
};
});
const mapped: ExperimentStep[] = exec.steps.map((s, idx) => {
const actions: ExperimentAction[] = s.actions.map((a) => {
// Normalize legacy plugin action ids and provenance
const rawType = a.type ?? "";
// Try to resolve alias-style legacy ids using installed study plugins
const dynamicLegacy = (() => {
if (rawType.includes(".")) {
const [alias, base] = rawType.split(".", 2);
if (alias && base) {
const baseMap: Record<string, string> = {
speak: "say_text",
say: "say_text",
walk: "walk_to_position",
animation: "play_animation",
led: "set_led_color",
leds: "set_led_color",
sit: "sit_down",
stand: "stand_up",
head: "turn_head",
turn_head: "turn_head",
};
const mappedBase = baseMap[base] ?? base;
const candidate =
installedPlugins.find(
(p) =>
p.plugin.id.startsWith(alias) ||
p.plugin.name
.toLowerCase()
.includes(alias.toLowerCase()),
) ?? null;
if (
candidate &&
Array.isArray(candidate.plugin.actionDefinitions) &&
candidate.plugin.actionDefinitions.some(
(ad) => ad.id === mappedBase,
)
) {
return {
pluginId: candidate.plugin.id,
baseId: mappedBase,
pluginVersion: candidate.plugin.version ?? undefined,
};
}
}
}
return null;
})();
const legacy = dynamicLegacy;
const isPluginType = Boolean(legacy) || rawType.includes(".");
const typeOut = legacy
? `${legacy.pluginId}.${legacy.baseId}`
: rawType;
const execution: ExecutionDescriptor = { transport: "internal" };
const categoryOut: ActionCategory = isPluginType
? "robot"
: "wizard";
const sourceKind: "core" | "plugin" = isPluginType
? "plugin"
: "core";
const pluginId = legacy?.pluginId;
const pluginVersion = legacy?.pluginVersion;
return {
id: a.id,
type: typeOut,
name: a.name,
parameters: (a.parameters ?? {}) as Record<string, unknown>,
category: categoryOut,
source: {
kind: sourceKind,
pluginId,
pluginVersion,
robotId: null,
baseActionId: legacy?.baseId,
},
execution,
};
});
return {
id: s.id,
name: s.name,
description: s.description ?? "",
type: ((): StepType => {
const raw = (s.type as string) ?? "sequential";
if (raw === "wizard") return "sequential";
const allowed = [
"sequential",
"parallel",
"conditional",
"loop",
] as const;
return (allowed as readonly string[]).includes(raw)
? (raw as StepType)
: "sequential";
})(),
order: s.orderIndex ?? idx,
trigger: { type: "trial_start", conditions: {} },
actions,
expanded: true,
};
});
initialDesign = {
id: experiment.id,
name: experiment.name,
description: experiment.description ?? "",
steps: mapped,
version: experiment.version ?? 1,
lastSaved: new Date(),
};
}
}
return ( return (
<DesignerRoot <DesignerRoot

View File

@@ -25,6 +25,7 @@ import type { ActionDefinition } from "~/lib/experiment-designer/types";
export class ActionRegistry { export class ActionRegistry {
private static instance: ActionRegistry; private static instance: ActionRegistry;
private actions = new Map<string, ActionDefinition>(); private actions = new Map<string, ActionDefinition>();
private aliasIndex = new Map<string, string>();
private coreActionsLoaded = false; private coreActionsLoaded = false;
private pluginActionsLoaded = false; private pluginActionsLoaded = false;
private loadedStudyId: string | null = null; private loadedStudyId: string | null = null;
@@ -292,6 +293,7 @@ export class ActionRegistry {
icon?: string; icon?: string;
timeout?: number; timeout?: number;
retryable?: boolean; retryable?: boolean;
aliases?: string[];
parameterSchema?: unknown; parameterSchema?: unknown;
ros2?: { ros2?: {
topic?: string; topic?: string;
@@ -394,8 +396,8 @@ export class ActionRegistry {
}; };
const actionDef: ActionDefinition = { const actionDef: ActionDefinition = {
id: `${plugin.id}.${action.id}`, id: `${plugin.robotId ?? plugin.id}.${action.id}`,
type: `${plugin.id}.${action.id}`, type: `${plugin.robotId ?? plugin.id}.${action.id}`,
name: action.name, name: action.name,
description: action.description ?? "", description: action.description ?? "",
category, category,
@@ -406,7 +408,7 @@ export class ActionRegistry {
), ),
source: { source: {
kind: "plugin", kind: "plugin",
pluginId: plugin.id, pluginId: plugin.robotId ?? plugin.id,
robotId: plugin.robotId, robotId: plugin.robotId,
pluginVersion: plugin.version ?? undefined, pluginVersion: plugin.version ?? undefined,
baseActionId: action.id, baseActionId: action.id,
@@ -415,6 +417,17 @@ export class ActionRegistry {
parameterSchemaRaw: action.parameterSchema ?? undefined, parameterSchemaRaw: action.parameterSchema ?? undefined,
}; };
this.actions.set(actionDef.id, actionDef); this.actions.set(actionDef.id, actionDef);
// Register aliases if provided by plugin metadata
const aliases = Array.isArray(action.aliases)
? action.aliases
: undefined;
if (aliases) {
for (const alias of aliases) {
if (typeof alias === "string" && alias.trim()) {
this.aliasIndex.set(alias, actionDef.id);
}
}
}
totalActionsLoaded++; totalActionsLoaded++;
}); });
}); });
@@ -524,7 +537,10 @@ export class ActionRegistry {
} }
getAction(id: string): ActionDefinition | undefined { getAction(id: string): ActionDefinition | undefined {
return this.actions.get(id); const direct = this.actions.get(id);
if (direct) return direct;
const mapped = this.aliasIndex.get(id);
return mapped ? this.actions.get(mapped) : undefined;
} }
/* ---------------- Debug Helpers ---------------- */ /* ---------------- Debug Helpers ---------------- */

View File

@@ -1,20 +1,37 @@
"use client"; "use client";
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Play, Plus } from "lucide-react"; import { Play } from "lucide-react";
import { PageHeader, ActionButton } from "~/components/ui/page-header"; import { PageHeader } from "~/components/ui/page-header";
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 { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { PanelsContainer } from "./layout/PanelsContainer"; import { PanelsContainer } from "./layout/PanelsContainer";
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core"; import {
DndContext,
DragOverlay,
pointerWithin,
useSensor,
useSensors,
MouseSensor,
TouchSensor,
KeyboardSensor,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
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 { FlowListView } from "./flow/FlowListView"; import { FlowWorkspace } from "./flow/FlowWorkspace";
import { import {
type ExperimentDesign, type ExperimentDesign,
@@ -25,7 +42,10 @@ import {
import { useDesignerStore } from "./state/store"; import { useDesignerStore } from "./state/store";
import { actionRegistry } from "./ActionRegistry"; import { actionRegistry } from "./ActionRegistry";
import { computeDesignHash } from "./state/hashing"; import { computeDesignHash } from "./state/hashing";
import { validateExperimentDesign } from "./state/validators"; import {
validateExperimentDesign,
groupIssuesByEntity,
} from "./state/validators";
/** /**
* DesignerRoot * DesignerRoot
@@ -161,6 +181,30 @@ export function DesignerRoot({
const setValidatedHash = useDesignerStore((s) => s.setValidatedHash); const setValidatedHash = useDesignerStore((s) => s.setValidatedHash);
const upsertStep = useDesignerStore((s) => s.upsertStep); const upsertStep = useDesignerStore((s) => s.upsertStep);
const upsertAction = useDesignerStore((s) => s.upsertAction); const upsertAction = useDesignerStore((s) => s.upsertAction);
const selectStep = useDesignerStore((s) => s.selectStep);
const selectAction = useDesignerStore((s) => s.selectAction);
const setValidationIssues = useDesignerStore((s) => s.setValidationIssues);
const clearAllValidationIssues = useDesignerStore(
(s) => s.clearAllValidationIssues,
);
const selectedStepId = useDesignerStore((s) => s.selectedStepId);
const selectedActionId = useDesignerStore((s) => s.selectedActionId);
const libraryRootRef = useRef<HTMLDivElement | null>(null);
const toggleLibraryScrollLock = useCallback((lock: boolean) => {
const viewport = libraryRootRef.current?.querySelector(
'[data-slot="scroll-area-viewport"]',
) as HTMLElement | null;
if (viewport) {
if (lock) {
viewport.style.overflowY = "hidden";
viewport.style.overscrollBehavior = "contain";
} else {
viewport.style.overflowY = "";
viewport.style.overscrollBehavior = "";
}
}
}, []);
/* ------------------------------- Local Meta ------------------------------ */ /* ------------------------------- Local Meta ------------------------------ */
const [designMeta, setDesignMeta] = useState<{ const [designMeta, setDesignMeta] = useState<{
@@ -193,10 +237,24 @@ export function DesignerRoot({
const [isValidating, setIsValidating] = useState(false); const [isValidating, setIsValidating] = useState(false);
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
const [lastSavedAt, setLastSavedAt] = useState<Date | undefined>(undefined); const [lastSavedAt, setLastSavedAt] = useState<Date | undefined>(undefined);
const [inspectorTab, setInspectorTab] = useState<
"properties" | "issues" | "dependencies"
>("properties");
/**
* Active action being dragged from the Action Library (for DragOverlay rendering).
* Captures a lightweight subset for visual feedback.
*/
const [dragOverlayAction, setDragOverlayAction] = useState<{
id: string;
name: string;
category: string;
description?: string;
} | null>(null);
/* ----------------------------- Initialization ---------------------------- */ /* ----------------------------- Initialization ---------------------------- */
useEffect(() => { useEffect(() => {
if (initialized || loadingExperiment) return; if (initialized) return;
if (loadingExperiment && !initialDesign) return;
const adapted = const adapted =
initialDesign ?? initialDesign ??
(experiment (experiment
@@ -288,8 +346,10 @@ export function DesignerRoot({
expanded: true, expanded: true,
}; };
upsertStep(newStep); upsertStep(newStep);
selectStep(newStep.id);
setInspectorTab("properties");
toast.success(`Created ${newStep.name}`); toast.success(`Created ${newStep.name}`);
}, [steps.length, upsertStep]); }, [steps.length, upsertStep, selectStep]);
/* ------------------------------- Validation ------------------------------ */ /* ------------------------------- Validation ------------------------------ */
const validateDesign = useCallback(async () => { const validateDesign = useCallback(async () => {
@@ -297,14 +357,39 @@ export function DesignerRoot({
setIsValidating(true); setIsValidating(true);
try { try {
const currentSteps = [...steps]; const currentSteps = [...steps];
// Ensure core actions are loaded before validating
await actionRegistry.loadCoreActions();
const result = validateExperimentDesign(currentSteps, { const result = validateExperimentDesign(currentSteps, {
steps: currentSteps, steps: currentSteps,
actionDefinitions: actionRegistry.getAllActions(), actionDefinitions: actionRegistry.getAllActions(),
}); });
// Debug: log validation results for troubleshooting
// eslint-disable-next-line no-console
console.debug("[DesignerRoot] validation", {
valid: result.valid,
errors: result.errorCount,
warnings: result.warningCount,
infos: result.infoCount,
issues: result.issues,
});
// Persist issues to store for inspector rendering
const grouped = groupIssuesByEntity(result.issues);
clearAllValidationIssues();
for (const [entityId, arr] of Object.entries(grouped)) {
setValidationIssues(
entityId,
arr.map((i) => ({
entityId,
severity: i.severity,
message: i.message,
code: undefined,
})),
);
}
const hash = await computeDesignHash(currentSteps); const hash = await computeDesignHash(currentSteps);
setValidatedHash(hash); setValidatedHash(hash);
if (result.valid) { if (result.valid) {
toast.success(`Validated • ${hash.slice(0, 10)}… • No issues`); toast.success(`Validated • ${hash.slice(0, 10)}… • 0 errors`);
} else { } else {
toast.warning( toast.warning(
`Validated with ${result.errorCount} errors, ${result.warningCount} warnings`, `Validated with ${result.errorCount} errors, ${result.warningCount} warnings`,
@@ -319,7 +404,13 @@ export function DesignerRoot({
} finally { } finally {
setIsValidating(false); setIsValidating(false);
} }
}, [initialized, steps, setValidatedHash]); }, [
initialized,
steps,
setValidatedHash,
setValidationIssues,
clearAllValidationIssues,
]);
/* --------------------------------- Save ---------------------------------- */ /* --------------------------------- Save ---------------------------------- */
const persist = useCallback(async () => { const persist = useCallback(async () => {
@@ -414,6 +505,19 @@ export function DesignerRoot({
void recomputeHash(); void recomputeHash();
}, [steps.length, initialized, recomputeHash]); }, [steps.length, initialized, recomputeHash]);
useEffect(() => {
if (selectedStepId || selectedActionId) {
setInspectorTab("properties");
}
}, [selectedStepId, selectedActionId]);
// Auto-open properties tab when a step or action becomes selected
useEffect(() => {
if (selectedStepId || selectedActionId) {
setInspectorTab("properties");
}
}, [selectedStepId, selectedActionId]);
/* -------------------------- Keyboard Shortcuts --------------------------- */ /* -------------------------- Keyboard Shortcuts --------------------------- */
const keyHandler = useCallback( const keyHandler = useCallback(
(e: globalThis.KeyboardEvent) => { (e: globalThis.KeyboardEvent) => {
@@ -448,21 +552,76 @@ export function DesignerRoot({
/* ------------------------------ Header Badges ---------------------------- */ /* ------------------------------ Header Badges ---------------------------- */
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 150, tolerance: 5 },
}),
useSensor(KeyboardSensor),
);
/* ----------------------------- Drag Handlers ----------------------------- */ /* ----------------------------- Drag Handlers ----------------------------- */
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const { active } = event;
if (
active.id.toString().startsWith("action-") &&
active.data.current?.action
) {
const a = active.data.current.action as {
id: string;
name: string;
category: string;
description?: string;
};
toggleLibraryScrollLock(true);
setDragOverlayAction({
id: a.id,
// prefer definition name; fallback to id
name: a.name || a.id,
category: a.category,
description: a.description,
});
}
},
[toggleLibraryScrollLock],
);
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
(event: DragEndEvent) => { async (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
if (!over) return; console.debug("[DesignerRoot] dragEnd", {
active: active?.id,
over: over?.id ?? null,
});
// Clear overlay immediately
toggleLibraryScrollLock(false);
setDragOverlayAction(null);
if (!over) {
console.debug("[DesignerRoot] dragEnd: no drop target (ignored)");
return;
}
// Expect dragged action (library) onto a step droppable // Expect dragged action (library) onto a step droppable
const activeId = active.id.toString(); const activeId = active.id.toString();
const overId = over.id.toString(); const overId = over.id.toString();
if ( if (activeId.startsWith("action-") && active.data.current?.action) {
activeId.startsWith("action-") && // Resolve stepId from possible over ids: step-<id>, s-step-<id>, or s-act-<actionId>
overId.startsWith("step-") && let stepId: string | null = null;
active.data.current?.action if (overId.startsWith("step-")) {
) { stepId = overId.slice("step-".length);
} else if (overId.startsWith("s-step-")) {
stepId = overId.slice("s-step-".length);
} else if (overId.startsWith("s-act-")) {
const actionId = overId.slice("s-act-".length);
const parent = steps.find((s) =>
s.actions.some((a) => a.id === actionId),
);
stepId = parent?.id ?? null;
}
if (!stepId) return;
const actionDef = active.data.current.action as { const actionDef = active.data.current.action as {
id: string; id: string;
type: string; type: string;
@@ -474,7 +633,6 @@ export function DesignerRoot({
parameters: Array<{ id: string; name: string }>; parameters: Array<{ id: string; name: string }>;
}; };
const stepId = overId.replace("step-", "");
const targetStep = steps.find((s) => s.id === stepId); const targetStep = steps.find((s) => s.id === stepId);
if (!targetStep) return; if (!targetStep) return;
@@ -502,24 +660,24 @@ export function DesignerRoot({
}; };
upsertAction(stepId, newAction); upsertAction(stepId, newAction);
// Select the newly added action and open properties
selectStep(stepId);
selectAction(stepId, newAction.id);
setInspectorTab("properties");
await recomputeHash();
toast.success(`Added ${actionDef.name} to ${targetStep.name}`); toast.success(`Added ${actionDef.name} to ${targetStep.name}`);
} }
}, },
[steps, upsertAction], [
steps,
upsertAction,
recomputeHash,
selectStep,
selectAction,
toggleLibraryScrollLock,
],
); );
const validationBadge = // validation status badges removed (unused)
driftStatus === "drift" ? (
<Badge variant="destructive">Drift</Badge>
) : driftStatus === "validated" ? (
<Badge
variant="outline"
className="border-green-400 text-green-700 dark:text-green-400"
>
Validated
</Badge>
) : (
<Badge variant="outline">Unvalidated</Badge>
);
/* ------------------------------- Render ---------------------------------- */ /* ------------------------------- Render ---------------------------------- */
if (loadingExperiment && !initialized) { if (loadingExperiment && !initialized) {
@@ -538,54 +696,23 @@ export function DesignerRoot({
icon={Play} icon={Play}
actions={ actions={
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{validationBadge}
{experiment?.integrityHash && (
<Badge variant="outline" className="text-xs">
Hash: {experiment.integrityHash.slice(0, 10)}
</Badge>
)}
<Badge variant="secondary" className="text-xs">
{steps.length} steps
</Badge>
<Badge variant="secondary" className="text-xs">
{steps.reduce((s, st) => s + st.actions.length, 0)} actions
</Badge>
{hasUnsavedChanges && (
<Badge
variant="outline"
className="border-orange-300 text-orange-600"
>
Unsaved
</Badge>
)}
<ActionButton
onClick={() => persist()}
disabled={!hasUnsavedChanges || isSaving}
>
{isSaving ? "Saving…" : "Save"}
</ActionButton>
<ActionButton
variant="outline"
onClick={() => validateDesign()}
disabled={isValidating}
>
{isValidating ? "Validating…" : "Validate"}
</ActionButton>
<ActionButton
variant="outline"
onClick={() => handleExport()}
disabled={isExporting}
>
{isExporting ? "Exporting…" : "Export"}
</ActionButton>
<Button <Button
size="sm" size="sm"
variant="default" variant="default"
className="h-8 text-xs" className="h-8 px-3 text-xs"
onClick={createNewStep} onClick={() => validateDesign()}
disabled={isValidating}
> >
<Plus className="mr-1 h-4 w-4" /> Validate
Step </Button>
<Button
size="sm"
variant="secondary"
className="h-8 px-3 text-xs"
onClick={() => persist()}
disabled={!hasUnsavedChanges || isSaving}
>
Save
</Button> </Button>
</div> </div>
} }
@@ -593,17 +720,38 @@ export function DesignerRoot({
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border"> <div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border">
<DndContext <DndContext
collisionDetection={closestCenter} sensors={sensors}
collisionDetection={pointerWithin}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
onDragCancel={() => toggleLibraryScrollLock(false)}
> >
<PanelsContainer <PanelsContainer
left={<ActionLibraryPanel />} left={
center={<FlowListView />} <div ref={libraryRootRef} data-library-root>
right={<InspectorPanel />} <ActionLibraryPanel />
</div>
}
center={<FlowWorkspace />}
right={
<InspectorPanel
activeTab={inspectorTab}
onTabChange={setInspectorTab}
/>
}
initialLeftWidth={260} initialLeftWidth={260}
initialRightWidth={360} initialRightWidth={260}
minRightWidth={240}
maxRightWidth={300}
className="flex-1" className="flex-1"
/> />
<DragOverlay>
{dragOverlayAction ? (
<div className="bg-background pointer-events-none rounded border px-2 py-1 text-xs shadow-lg select-none">
{dragOverlayAction.name}
</div>
) : null}
</DragOverlay>
</DndContext> </DndContext>
<BottomStatusBar <BottomStatusBar
onSave={() => persist()} onSave={() => persist()}

View File

@@ -124,7 +124,7 @@ export function PropertiesPanel({
: Zap; : Zap;
return ( return (
<div className={cn("space-y-3", className)}> <div className={cn("w-full min-w-0 space-y-3 px-3", className)}>
{/* Header / Metadata */} {/* Header / Metadata */}
<div className="border-b pb-3"> <div className="border-b pb-3">
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
@@ -142,8 +142,8 @@ export function PropertiesPanel({
<h3 className="truncate text-sm font-medium"> <h3 className="truncate text-sm font-medium">
{selectedAction.name} {selectedAction.name}
</h3> </h3>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs capitalize">
{def?.category} {selectedAction.type} {def?.category}
</p> </p>
</div> </div>
</div> </div>
@@ -151,14 +151,7 @@ export function PropertiesPanel({
<Badge variant="outline" className="h-4 text-[10px]"> <Badge variant="outline" className="h-4 text-[10px]">
{selectedAction.source.kind === "plugin" ? "Plugin" : "Core"} {selectedAction.source.kind === "plugin" ? "Plugin" : "Core"}
</Badge> </Badge>
{selectedAction.source.pluginId && ( {/* internal plugin identifiers hidden from UI */}
<Badge variant="secondary" className="h-4 text-[10px]">
{selectedAction.source.pluginId}
{selectedAction.source.pluginVersion
? `@${selectedAction.source.pluginVersion}`
: ""}
</Badge>
)}
<Badge variant="outline" className="h-4 text-[10px]"> <Badge variant="outline" className="h-4 text-[10px]">
{selectedAction.execution.transport} {selectedAction.execution.transport}
</Badge> </Badge>
@@ -175,8 +168,11 @@ export function PropertiesPanel({
)} )}
</div> </div>
{/* General Action Fields */} {/* General */}
<div className="space-y-2"> <div className="space-y-2">
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
General
</div>
<div> <div>
<Label className="text-xs">Display Name</Label> <Label className="text-xs">Display Name</Label>
<Input <Input
@@ -186,7 +182,7 @@ export function PropertiesPanel({
name: e.target.value, name: e.target.value,
}) })
} }
className="mt-1 h-7 text-xs" className="mt-1 h-7 w-full text-xs"
/> />
</div> </div>
</div> </div>
@@ -231,7 +227,7 @@ export function PropertiesPanel({
value={(rawValue as string) ?? ""} value={(rawValue as string) ?? ""}
placeholder={param.placeholder} placeholder={param.placeholder}
onChange={(e) => updateParamValue(e.target.value)} onChange={(e) => updateParamValue(e.target.value)}
className="mt-1 h-7 text-xs" className="mt-1 h-7 w-full text-xs"
/> />
); );
} else if (param.type === "select") { } else if (param.type === "select") {
@@ -240,7 +236,7 @@ export function PropertiesPanel({
value={(rawValue as string) ?? ""} value={(rawValue as string) ?? ""}
onValueChange={(val) => updateParamValue(val)} onValueChange={(val) => updateParamValue(val)}
> >
<SelectTrigger className="mt-1 h-7 text-xs"> <SelectTrigger className="mt-1 h-7 w-full text-xs">
<SelectValue placeholder="Select…" /> <SelectValue placeholder="Select…" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -322,7 +318,7 @@ export function PropertiesPanel({
onChange={(e) => onChange={(e) =>
updateParamValue(parseFloat(e.target.value) || 0) updateParamValue(parseFloat(e.target.value) || 0)
} }
className="mt-1 h-7 text-xs" className="mt-1 h-7 w-full text-xs"
/> />
); );
} }
@@ -354,7 +350,7 @@ export function PropertiesPanel({
/* --------------------------- Step Properties View --------------------------- */ /* --------------------------- Step Properties View --------------------------- */
if (selectedStep) { if (selectedStep) {
return ( return (
<div className={cn("space-y-3", className)}> <div className={cn("w-full min-w-0 space-y-3 px-3", className)}>
<div className="border-b pb-2"> <div className="border-b pb-2">
<h3 className="flex items-center gap-2 text-sm font-medium"> <h3 className="flex items-center gap-2 text-sm font-medium">
<div <div
@@ -368,73 +364,88 @@ export function PropertiesPanel({
Step Settings Step Settings
</h3> </h3>
</div> </div>
<div className="space-y-2"> <div className="space-y-3">
<div> <div>
<Label className="text-xs">Name</Label> <div className="text-muted-foreground text-[10px] tracking-wide uppercase">
<Input General
value={selectedStep.name} </div>
onChange={(e) => <div className="mt-2 space-y-2">
onStepUpdate(selectedStep.id, { name: e.target.value }) <div>
} <Label className="text-xs">Name</Label>
className="mt-1 h-7 text-xs" <Input
/> value={selectedStep.name}
onChange={(e) =>
onStepUpdate(selectedStep.id, { name: e.target.value })
}
className="mt-1 h-7 w-full text-xs"
/>
</div>
<div>
<Label className="text-xs">Description</Label>
<Input
value={selectedStep.description ?? ""}
placeholder="Optional step description"
onChange={(e) =>
onStepUpdate(selectedStep.id, {
description: e.target.value,
})
}
className="mt-1 h-7 w-full text-xs"
/>
</div>
</div>
</div> </div>
<div> <div>
<Label className="text-xs">Description</Label> <div className="text-muted-foreground text-[10px] tracking-wide uppercase">
<Input Behavior
value={selectedStep.description ?? ""} </div>
placeholder="Optional step description" <div className="mt-2 space-y-2">
onChange={(e) => <div>
onStepUpdate(selectedStep.id, { <Label className="text-xs">Type</Label>
description: e.target.value, <Select
}) value={selectedStep.type}
} onValueChange={(val) =>
className="mt-1 h-7 text-xs" onStepUpdate(selectedStep.id, { type: val as StepType })
/> }
</div> >
<div> <SelectTrigger className="mt-1 h-7 w-full text-xs">
<Label className="text-xs">Type</Label> <SelectValue />
<Select </SelectTrigger>
value={selectedStep.type} <SelectContent>
onValueChange={(val) => <SelectItem value="sequential">Sequential</SelectItem>
onStepUpdate(selectedStep.id, { type: val as StepType }) <SelectItem value="parallel">Parallel</SelectItem>
} <SelectItem value="conditional">Conditional</SelectItem>
> <SelectItem value="loop">Loop</SelectItem>
<SelectTrigger className="mt-1 h-7 text-xs"> </SelectContent>
<SelectValue /> </Select>
</SelectTrigger> </div>
<SelectContent> <div>
<SelectItem value="sequential">Sequential</SelectItem> <Label className="text-xs">Trigger</Label>
<SelectItem value="parallel">Parallel</SelectItem> <Select
<SelectItem value="conditional">Conditional</SelectItem> value={selectedStep.trigger.type}
<SelectItem value="loop">Loop</SelectItem> onValueChange={(val) =>
</SelectContent> onStepUpdate(selectedStep.id, {
</Select> trigger: {
</div> ...selectedStep.trigger,
<div> type: val as TriggerType,
<Label className="text-xs">Trigger</Label> },
<Select })
value={selectedStep.trigger.type} }
onValueChange={(val) => >
onStepUpdate(selectedStep.id, { <SelectTrigger className="mt-1 h-7 w-full text-xs">
trigger: { <SelectValue />
...selectedStep.trigger, </SelectTrigger>
type: val as TriggerType, <SelectContent>
}, {TRIGGER_OPTIONS.map((opt) => (
}) <SelectItem key={opt.value} value={opt.value}>
} {opt.label}
> </SelectItem>
<SelectTrigger className="mt-1 h-7 text-xs"> ))}
<SelectValue /> </SelectContent>
</SelectTrigger> </Select>
<SelectContent> </div>
{TRIGGER_OPTIONS.map((opt) => ( </div>
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</div> </div>
</div> </div>
@@ -451,9 +462,9 @@ export function PropertiesPanel({
> >
<div> <div>
<Settings className="text-muted-foreground/50 mx-auto mb-2 h-6 w-6" /> <Settings className="text-muted-foreground/50 mx-auto mb-2 h-6 w-6" />
<h3 className="mb-1 text-sm font-medium">Select Step or Action</h3> <h3 className="mb-1 text-sm font-medium">No selection</h3>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
Click in the flow to edit properties Select a step or action in the flow to edit its properties.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,12 +1,20 @@
"use client"; "use client";
import React, { useState, useMemo } from "react"; import React, { useState, useMemo } from "react";
import { AlertCircle, AlertTriangle, Info, Filter, X } from "lucide-react"; import {
AlertCircle,
AlertTriangle,
Info,
Filter,
X,
Search,
CheckCircle2,
} from "lucide-react";
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 { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { ScrollArea } from "~/components/ui/scroll-area"; import { ScrollArea } from "~/components/ui/scroll-area";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import { Input } from "~/components/ui/input";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@@ -39,6 +47,10 @@ export interface ValidationPanelProps {
* Called to clear all issues for an entity. * Called to clear all issues for an entity.
*/ */
onEntityClear?: (entityId: string) => void; onEntityClear?: (entityId: string) => void;
/**
* Optional function to map entity IDs to human-friendly names (e.g., step/action names).
*/
entityLabelForId?: (entityId: string) => string;
className?: string; className?: string;
} }
@@ -109,16 +121,22 @@ interface IssueItemProps {
issue: ValidationIssue & { entityId: string; index: number }; issue: ValidationIssue & { entityId: string; index: number };
onIssueClick?: (issue: ValidationIssue) => void; onIssueClick?: (issue: ValidationIssue) => void;
onIssueClear?: (entityId: string, issueIndex: number) => void; onIssueClear?: (entityId: string, issueIndex: number) => void;
entityLabelForId?: (entityId: string) => string;
} }
function IssueItem({ issue, onIssueClick, onIssueClear }: IssueItemProps) { function IssueItem({
issue,
onIssueClick,
onIssueClear,
entityLabelForId,
}: IssueItemProps) {
const config = severityConfig[issue.severity]; const config = severityConfig[issue.severity];
const IconComponent = config.icon; const IconComponent = config.icon;
return ( return (
<div <div
className={cn( className={cn(
"group flex items-start gap-3 rounded-md border p-3 transition-colors", "group flex w-full max-w-full min-w-0 items-start gap-2 rounded-md border p-2 break-words transition-colors",
config.borderColor, config.borderColor,
config.bgColor, config.bgColor,
onIssueClick && "cursor-pointer hover:shadow-sm", onIssueClick && "cursor-pointer hover:shadow-sm",
@@ -132,25 +150,30 @@ function IssueItem({ issue, onIssueClick, onIssueClear }: IssueItemProps) {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm leading-relaxed">{issue.message}</p> <p className="text-[12px] leading-snug break-words whitespace-normal">
{issue.message}
</p>
<div className="mt-1 flex flex-wrap items-center gap-1"> <div className="mt-1 flex flex-wrap items-center gap-1">
<Badge variant={config.badgeVariant} className="h-4 text-[10px]"> <Badge variant={config.badgeVariant} className="text-[10px]">
{config.label} {config.label}
</Badge> </Badge>
{issue.category && ( {issue.category && (
<Badge variant="outline" className="h-4 text-[10px] capitalize"> <Badge variant="outline" className="text-[10px] capitalize">
{issue.category} {issue.category}
</Badge> </Badge>
)} )}
<Badge variant="secondary" className="h-4 text-[10px]"> <Badge
{getEntityDisplayName(issue.entityId)} variant="secondary"
className="max-w-full text-[10px] break-words whitespace-normal"
>
{entityLabelForId?.(issue.entityId) ?? "Unknown"}
</Badge> </Badge>
{issue.field && ( {issue.field && (
<Badge variant="outline" className="h-4 text-[10px]"> <Badge variant="outline" className="text-[10px]">
{issue.field} {issue.field}
</Badge> </Badge>
)} )}
@@ -185,6 +208,7 @@ export function ValidationPanel({
onIssueClick, onIssueClick,
onIssueClear, onIssueClear,
onEntityClear: _onEntityClear, onEntityClear: _onEntityClear,
entityLabelForId,
className, className,
}: ValidationPanelProps) { }: ValidationPanelProps) {
const [severityFilter, setSeverityFilter] = useState< const [severityFilter, setSeverityFilter] = useState<
@@ -193,21 +217,23 @@ export function ValidationPanel({
const [categoryFilter, setCategoryFilter] = useState< const [categoryFilter, setCategoryFilter] = useState<
"all" | "structural" | "parameter" | "semantic" | "execution" "all" | "structural" | "parameter" | "semantic" | "execution"
>("all"); >("all");
const [search, setSearch] = useState("");
// Flatten and filter issues // Flatten and filter issues
const flatIssues = useMemo(() => { const flatIssues = useMemo(() => {
const flat = flattenIssues(issues); const flat = flattenIssues(issues);
const q = search.trim().toLowerCase();
return flat.filter((issue) => { return flat.filter((issue) => {
if (severityFilter !== "all" && issue.severity !== severityFilter) { if (severityFilter !== "all" && issue.severity !== severityFilter)
return false; return false;
} if (categoryFilter !== "all" && issue.category !== categoryFilter)
if (categoryFilter !== "all" && issue.category !== categoryFilter) {
return false; return false;
} if (!q) return true;
return true; const hay =
`${issue.message} ${issue.field ?? ""} ${issue.category ?? ""} ${issue.entityId}`.toLowerCase();
return hay.includes(q);
}); });
}, [issues, severityFilter, categoryFilter]); }, [issues, severityFilter, categoryFilter, search]);
// Count by severity // Count by severity
const counts = useMemo(() => { const counts = useMemo(() => {
@@ -220,6 +246,12 @@ export function ValidationPanel({
}; };
}, [issues]); }, [issues]);
React.useEffect(() => {
// Debug: surface validation state to console
// eslint-disable-next-line no-console
console.log("[ValidationPanel] issues", issues, { flatIssues, counts });
}, [issues, flatIssues, counts]);
// Available categories // Available categories
const availableCategories = useMemo(() => { const availableCategories = useMemo(() => {
const flat = flattenIssues(issues); const flat = flattenIssues(issues);
@@ -230,160 +262,127 @@ export function ValidationPanel({
}, [issues]); }, [issues]);
return ( return (
<Card className={cn("h-[calc(100vh-12rem)]", className)}> <div
<CardHeader className="pb-2"> className={cn(
<CardTitle className="flex items-center justify-between text-sm"> "flex h-full min-h-0 min-w-0 flex-col overflow-hidden",
<div className="flex items-center gap-2"> className,
<AlertCircle className="h-4 w-4" /> )}
Validation Issues >
</div> {/* Header (emulate ActionLibraryPanel) */}
<div className="flex items-center gap-1"> <div className="bg-background/60 border-b p-2">
{counts.error > 0 && ( <div className="relative mb-2">
<Badge variant="destructive" className="h-4 text-[10px]"> <Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" />
{counts.error} <Input
</Badge> value={search}
)} onChange={(e) => setSearch(e.target.value)}
{counts.warning > 0 && ( placeholder="Search issues"
<Badge variant="secondary" className="h-4 text-[10px]"> className="h-8 w-full pl-7 text-xs"
{counts.warning} aria-label="Search issues"
</Badge> />
)} </div>
{counts.info > 0 && (
<Badge variant="outline" className="h-4 text-[10px]">
{counts.info}
</Badge>
)}
</div>
</CardTitle>
</CardHeader>
<CardContent className="p-0"> <div className="mb-2 grid grid-cols-2 gap-1">
{/* Filters */} <Button
{counts.total > 0 && ( variant={severityFilter === "all" ? "default" : "ghost"}
<> size="sm"
<div className="border-b p-3"> className="h-7 justify-start gap-1 text-[11px]"
<div className="flex flex-wrap gap-2"> onClick={() => setSeverityFilter("all")}
{/* Severity Filter */} aria-pressed={severityFilter === "all"}
<div className="flex items-center gap-1"> >
<Filter className="text-muted-foreground h-3 w-3" /> <Filter className="h-3 w-3" /> All
<Button <span className="ml-auto text-[10px] font-normal opacity-80">
variant={severityFilter === "all" ? "default" : "ghost"} {counts.total}
size="sm" </span>
className="h-6 px-2 text-xs" </Button>
onClick={() => setSeverityFilter("all")} <Button
> variant={severityFilter === "error" ? "default" : "ghost"}
All ({counts.total}) size="sm"
</Button> className={cn(
{counts.error > 0 && ( "h-7 justify-start gap-1 text-[11px]",
<Button severityFilter === "error" &&
variant={ "bg-red-600 text-white hover:opacity-90",
severityFilter === "error" ? "destructive" : "ghost" )}
} onClick={() => setSeverityFilter("error")}
size="sm" aria-pressed={severityFilter === "error"}
className="h-6 px-2 text-xs" >
onClick={() => setSeverityFilter("error")} <AlertCircle className="h-3 w-3" /> Errors
> <span className="ml-auto text-[10px] font-normal opacity-80">
Errors ({counts.error}) {counts.error}
</Button> </span>
)} </Button>
{counts.warning > 0 && ( <Button
<Button variant={severityFilter === "warning" ? "default" : "ghost"}
variant={ size="sm"
severityFilter === "warning" ? "secondary" : "ghost" className={cn(
} "h-7 justify-start gap-1 text-[11px]",
size="sm" severityFilter === "warning" &&
className="h-6 px-2 text-xs" "bg-amber-500 text-white hover:opacity-90",
onClick={() => setSeverityFilter("warning")} )}
> onClick={() => setSeverityFilter("warning")}
Warnings ({counts.warning}) aria-pressed={severityFilter === "warning"}
</Button> >
)} <AlertTriangle className="h-3 w-3" /> Warn
{counts.info > 0 && ( <span className="ml-auto text-[10px] font-normal opacity-80">
<Button {counts.warning}
variant={severityFilter === "info" ? "outline" : "ghost"} </span>
size="sm" </Button>
className="h-6 px-2 text-xs" <Button
onClick={() => setSeverityFilter("info")} variant={severityFilter === "info" ? "default" : "ghost"}
> size="sm"
Info ({counts.info}) className={cn(
</Button> "h-7 justify-start gap-1 text-[11px]",
)} severityFilter === "info" &&
</div> "bg-blue-600 text-white hover:opacity-90",
)}
onClick={() => setSeverityFilter("info")}
aria-pressed={severityFilter === "info"}
>
<Info className="h-3 w-3" /> Info
<span className="ml-auto text-[10px] font-normal opacity-80">
{counts.info}
</span>
</Button>
</div>
</div>
{/* Category Filter */} {/* Issues List */}
{availableCategories.length > 0 && ( <ScrollArea className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
<> <div className="flex min-w-0 flex-col gap-2 p-2 pr-2">
<Separator orientation="vertical" className="h-6" /> {counts.total === 0 ? (
<div className="flex items-center gap-1"> <div className="py-8 text-center">
<Button <div className="mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/20">
variant={categoryFilter === "all" ? "default" : "ghost"} <CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
size="sm"
className="h-6 px-2 text-xs"
onClick={() => setCategoryFilter("all")}
>
All Categories
</Button>
{availableCategories.map((category) => (
<Button
key={category}
variant={
categoryFilter === category ? "outline" : "ghost"
}
size="sm"
className="h-6 px-2 text-xs capitalize"
onClick={() => setCategoryFilter(category)}
>
{category}
</Button>
))}
</div>
</>
)}
</div> </div>
<p className="text-sm font-medium text-emerald-700 dark:text-emerald-300">
All clear no issues
</p>
<p className="text-muted-foreground text-xs">
Validate again after changes.
</p>
</div> </div>
</> ) : flatIssues.length === 0 ? (
)} <div className="py-8 text-center">
<div className="bg-muted mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full">
{/* Issues List */} <Filter className="h-4 w-4" />
<ScrollArea className="h-full">
<div className="p-3">
{counts.total === 0 ? (
<div className="py-8 text-center">
<div className="mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-950/20">
<Info className="h-4 w-4 text-green-600 dark:text-green-400" />
</div>
<p className="text-sm font-medium text-green-700 dark:text-green-300">
No validation issues
</p>
<p className="text-muted-foreground text-xs">
Your experiment design looks good!
</p>
</div> </div>
) : flatIssues.length === 0 ? ( <p className="text-sm font-medium">No issues match filters</p>
<div className="py-8 text-center"> <p className="text-muted-foreground text-xs">
<div className="bg-muted mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full"> Adjust your filters
<Filter className="h-4 w-4" /> </p>
</div> </div>
<p className="text-sm font-medium">No issues match filters</p> ) : (
<p className="text-muted-foreground text-xs"> flatIssues.map((issue) => (
Try adjusting your filter criteria <IssueItem
</p> key={`${issue.entityId}-${issue.index}`}
</div> issue={issue}
) : ( onIssueClick={onIssueClick}
<div className="space-y-2"> onIssueClear={onIssueClear}
{flatIssues.map((issue) => ( entityLabelForId={entityLabelForId}
<IssueItem />
key={`${issue.entityId}-${issue.index}`} ))
issue={issue} )}
onIssueClick={onIssueClick} </div>
onIssueClear={onIssueClear} </ScrollArea>
/> </div>
))}
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
); );
} }

View File

@@ -0,0 +1,798 @@
"use client";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import {
useDroppable,
useDndMonitor,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import {
useSortable,
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ChevronDown,
ChevronRight,
GripVertical,
Plus,
Trash2,
GitBranch,
Sparkles,
CircleDot,
Edit3,
} from "lucide-react";
import { cn } from "~/lib/utils";
import {
type ExperimentStep,
type ExperimentAction,
} from "~/lib/experiment-designer/types";
import { useDesignerStore } from "../state/store";
import { actionRegistry } from "../ActionRegistry";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Input } from "~/components/ui/input";
/**
* FlowWorkspace
*
* Virtualized step + action workspace with local (Option B) sortable handling.
* Reordering is processed locally via useDndMonitor (not in DesignerRoot)
* to keep orchestration layer simpler and reduce cross-component coupling.
*
* Features:
* - Virtualized step list (absolute positioned variable heights)
* - Inline step rename
* - Step & action creation / deletion
* - Step and action reordering (drag handles)
* - Drag-from-library action insertion (handled by root DnD; droppables here)
* - Empty step drop affordance + highlight
*
* Sortable ID strategy (to avoid collision with palette action ids):
* - Sortable Step: s-step-<stepId>
* - Sortable Action: s-act-<actionId>
* - Droppable Step: step-<stepId> (kept for root palette drops)
*/
interface FlowWorkspaceProps {
className?: string;
overscan?: number;
onStepCreate?: (step: ExperimentStep) => void;
onStepDelete?: (stepId: string) => void;
onActionCreate?: (stepId: string, action: ExperimentAction) => void;
}
interface VirtualItem {
index: number;
top: number;
height: number;
step: ExperimentStep;
key: string;
visible: boolean;
}
/* -------------------------------------------------------------------------- */
/* Utility */
/* -------------------------------------------------------------------------- */
function generateStepId(): string {
return `step-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function generateActionId(): string {
return `action-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function sortableStepId(stepId: string) {
return `s-step-${stepId}`;
}
function sortableActionId(actionId: string) {
return `s-act-${actionId}`;
}
function parseSortableStep(id: string): string | null {
return id.startsWith("s-step-") ? id.slice("s-step-".length) : null;
}
function parseSortableAction(id: string): string | null {
return id.startsWith("s-act-") ? id.slice("s-act-".length) : null;
}
/* -------------------------------------------------------------------------- */
/* Droppable Overlay (for palette action drops) */
/* -------------------------------------------------------------------------- */
function StepDroppableArea({ stepId }: { stepId: string }) {
const { isOver } = useDroppable({ id: `step-${stepId}` });
return (
<div
data-step-drop
className={cn(
"pointer-events-none absolute inset-0 rounded-md transition-colors",
isOver &&
"bg-blue-50/40 ring-2 ring-blue-400/60 ring-offset-0 dark:bg-blue-950/20",
)}
/>
);
}
/* -------------------------------------------------------------------------- */
/* Sortable Action Chip */
/* -------------------------------------------------------------------------- */
interface ActionChipProps {
action: ExperimentAction;
isSelected: boolean;
onSelect: () => void;
onDelete: () => void;
dragHandle?: boolean;
}
function SortableActionChip({
action,
isSelected,
onSelect,
onDelete,
}: ActionChipProps) {
const def = actionRegistry.getAction(action.type);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: sortableActionId(action.id),
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 30 : undefined,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"group relative flex w-full flex-col items-start gap-1 rounded border px-3 py-2 text-[11px]",
"bg-muted/40 hover:bg-accent/40 cursor-pointer",
isSelected && "border-blue-500 bg-blue-50 dark:bg-blue-950/30",
isDragging && "opacity-70 shadow-lg",
)}
onClick={onSelect}
{...attributes}
role="button"
aria-pressed={isSelected}
tabIndex={0}
>
<div className="flex w-full items-center gap-2">
<div
{...listeners}
className="text-muted-foreground/70 hover:text-foreground cursor-grab rounded p-0.5"
aria-label="Drag action"
>
<GripVertical className="h-3.5 w-3.5" />
</div>
<span
className={cn(
"h-2.5 w-2.5 rounded-full",
def
? {
wizard: "bg-blue-500",
robot: "bg-emerald-500",
control: "bg-amber-500",
observation: "bg-purple-500",
}[def.category]
: "bg-slate-400",
)}
/>
<span className="flex-1 leading-snug font-medium break-words">
{action.name}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
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-muted-foreground text-[9px]">
+{def.parameters.length - 4} more
</span>
)}
</div>
) : null}
</div>
);
}
/* -------------------------------------------------------------------------- */
/* FlowWorkspace Component */
/* -------------------------------------------------------------------------- */
export function FlowWorkspace({
className,
overscan = 400,
onStepCreate,
onStepDelete,
onActionCreate,
}: FlowWorkspaceProps) {
/* Store selectors */
const steps = useDesignerStore((s) => s.steps);
const selectStep = useDesignerStore((s) => s.selectStep);
const selectAction = useDesignerStore((s) => s.selectAction);
const selectedStepId = useDesignerStore((s) => s.selectedStepId);
const selectedActionId = useDesignerStore((s) => s.selectedActionId);
const upsertStep = useDesignerStore((s) => s.upsertStep);
const removeStep = useDesignerStore((s) => s.removeStep);
const upsertAction = useDesignerStore((s) => s.upsertAction);
const removeAction = useDesignerStore((s) => s.removeAction);
const reorderStep = useDesignerStore((s) => s.reorderStep);
const reorderAction = useDesignerStore((s) => s.reorderAction);
const recomputeHash = useDesignerStore((s) => s.recomputeHash);
/* Local state */
const containerRef = useRef<HTMLDivElement | null>(null);
const measureRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const roRef = useRef<ResizeObserver | null>(null);
const [heights, setHeights] = useState<Map<string, number>>(new Map());
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(600);
const [containerWidth, setContainerWidth] = useState(0);
const [renamingStepId, setRenamingStepId] = useState<string | null>(null);
const [isDraggingLibraryAction, setIsDraggingLibraryAction] = useState(false);
// dragKind state removed (unused after refactor)
/* Parent lookup for action reorder */
const actionParentMap = useMemo(() => {
const map = new Map<string, string>();
for (const step of steps) {
for (const a of step.actions) {
map.set(a.id, step.id);
}
}
return map;
}, [steps]);
/* Resize observer for viewport and width changes */
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const cr = entry.contentRect;
setViewportHeight(cr.height);
setContainerWidth((prev) => {
if (Math.abs(prev - cr.width) > 0.5) {
// Invalidate cached heights on width change to force re-measure
setHeights(new Map());
}
return cr.width;
});
}
});
observer.observe(el);
const cr = el.getBoundingClientRect();
setViewportHeight(el.clientHeight);
setContainerWidth(cr.width);
return () => observer.disconnect();
}, []);
/* Per-step measurement observer (attach/detach on ref set) */
useLayoutEffect(() => {
roRef.current = new ResizeObserver((entries) => {
setHeights((prev) => {
const next = new Map(prev);
let changed = false;
for (const entry of entries) {
const id = entry.target.getAttribute("data-step-id");
if (!id) continue;
const h = entry.contentRect.height;
if (prev.get(id) !== h) {
next.set(id, h);
changed = true;
}
}
return changed ? next : prev;
});
});
return () => {
roRef.current?.disconnect();
roRef.current = null;
};
}, []);
/* Scroll */
const onScroll = useCallback(() => {
if (!containerRef.current) return;
setScrollTop(containerRef.current.scrollTop);
}, []);
/* Virtual items */
const estimatedBaseHeight = 140;
const virtualItems: VirtualItem[] = useMemo(() => {
const out: VirtualItem[] = [];
let offset = 0;
steps.forEach((step, idx) => {
const h = heights.get(step.id) ?? estimatedBaseHeight;
const top = offset;
const visible =
top + h > scrollTop - overscan &&
top < scrollTop + viewportHeight + overscan;
out.push({
index: idx,
top,
height: h,
step,
key: step.id,
visible,
});
offset += h;
});
return out;
}, [steps, heights, scrollTop, viewportHeight, overscan]);
const totalHeight = useMemo(
() =>
steps.reduce(
(sum, step) => sum + (heights.get(step.id) ?? estimatedBaseHeight),
0,
),
[steps, heights],
);
/* CRUD Helpers */
const createStep = useCallback(
(insertIndex?: number) => {
const newStep: ExperimentStep = {
id: generateStepId(),
name: `Step ${steps.length + 1}`,
description: "",
type: "sequential",
order: steps.length,
trigger: { type: "trial_start", conditions: {} },
actions: [],
expanded: true,
};
if (
typeof insertIndex === "number" &&
insertIndex >= 0 &&
insertIndex < steps.length
) {
// Insert with manual reindex
const reordered = steps
.slice(0, insertIndex + 1)
.concat([newStep], steps.slice(insertIndex + 1))
.map((s, i) => ({ ...s, order: i }));
reordered.forEach((s) => upsertStep(s));
} else {
upsertStep(newStep);
}
selectStep(newStep.id);
onStepCreate?.(newStep);
void recomputeHash();
},
[steps, upsertStep, selectStep, onStepCreate, recomputeHash],
);
const deleteStep = useCallback(
(step: ExperimentStep) => {
removeStep(step.id);
onStepDelete?.(step.id);
if (selectedStepId === step.id) selectStep(undefined);
void recomputeHash();
},
[removeStep, onStepDelete, selectedStepId, selectStep, recomputeHash],
);
const toggleExpanded = useCallback(
(step: ExperimentStep) => {
upsertStep({ ...step, expanded: !step.expanded });
},
[upsertStep],
);
const renameStep = useCallback(
(step: ExperimentStep, name: string) => {
upsertStep({ ...step, name });
},
[upsertStep],
);
const addActionToStep = useCallback(
(
stepId: string,
actionDef: { type: string; name: string; category: string },
) => {
const step = steps.find((s) => s.id === stepId);
if (!step) return;
const newAction: ExperimentAction = {
id: generateActionId(),
type: actionDef.type,
name: actionDef.name,
category: actionDef.category as ExperimentAction["category"],
parameters: {},
source: { kind: "core" },
execution: { transport: "internal" },
};
upsertAction(stepId, newAction);
onActionCreate?.(stepId, newAction);
void recomputeHash();
},
[steps, upsertAction, onActionCreate, recomputeHash],
);
const deleteAction = useCallback(
(stepId: string, actionId: string) => {
removeAction(stepId, actionId);
if (selectedActionId === actionId) selectAction(stepId, undefined);
void recomputeHash();
},
[removeAction, selectedActionId, selectAction, recomputeHash],
);
/* ------------------------------------------------------------------------ */
/* Sortable (Local) DnD Monitoring */
/* ------------------------------------------------------------------------ */
const handleLocalDragStart = useCallback((e: DragStartEvent) => {
const id = e.active.id.toString();
if (id.startsWith("action-")) {
setIsDraggingLibraryAction(true);
}
}, []);
const handleLocalDragEnd = useCallback(
(e: DragEndEvent) => {
const { active, over } = e;
setIsDraggingLibraryAction(false);
if (!over || !active) {
return;
}
const activeId = active.id.toString();
const overId = over.id.toString();
// Step reorder
if (activeId.startsWith("s-step-") && overId.startsWith("s-step-")) {
const fromStepId = parseSortableStep(activeId);
const toStepId = parseSortableStep(overId);
if (fromStepId && toStepId && fromStepId !== toStepId) {
const fromIndex = steps.findIndex((s) => s.id === fromStepId);
const toIndex = steps.findIndex((s) => s.id === toStepId);
if (fromIndex >= 0 && toIndex >= 0) {
reorderStep(fromIndex, toIndex);
void recomputeHash();
}
}
}
// Action reorder (within same parent only)
if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) {
const fromActionId = parseSortableAction(activeId);
const toActionId = parseSortableAction(overId);
if (fromActionId && toActionId && fromActionId !== toActionId) {
const fromParent = actionParentMap.get(fromActionId);
const toParent = actionParentMap.get(toActionId);
if (fromParent && toParent && fromParent === toParent) {
const step = steps.find((s) => s.id === fromParent);
if (step) {
const fromIdx = step.actions.findIndex(
(a) => a.id === fromActionId,
);
const toIdx = step.actions.findIndex((a) => a.id === toActionId);
if (fromIdx >= 0 && toIdx >= 0) {
reorderAction(step.id, fromIdx, toIdx);
void recomputeHash();
}
}
}
}
}
},
[steps, reorderStep, reorderAction, actionParentMap, recomputeHash],
);
useDndMonitor({
onDragStart: handleLocalDragStart,
onDragEnd: handleLocalDragEnd,
onDragCancel: () => {
setIsDraggingLibraryAction(false);
},
});
/* ------------------------------------------------------------------------ */
/* Step Row (Sortable + Virtualized) */
/* ------------------------------------------------------------------------ */
function StepRow({ item }: { item: VirtualItem }) {
const step = item.step;
const {
setNodeRef,
transform,
transition,
attributes,
listeners,
isDragging,
} = useSortable({
id: sortableStepId(step.id),
});
const style: React.CSSProperties = {
position: "absolute",
top: item.top,
left: 0,
right: 0,
width: "100%",
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 25 : undefined,
};
const setMeasureRef = (el: HTMLDivElement | null) => {
const prev = measureRefs.current.get(step.id) ?? null;
if (prev && prev !== el) {
roRef.current?.unobserve(prev);
measureRefs.current.delete(step.id);
}
if (el) {
measureRefs.current.set(step.id, el);
roRef.current?.observe(el);
}
};
return (
<div ref={setNodeRef} style={style} data-step-id={step.id}>
<div
ref={setMeasureRef}
className="relative px-3 py-4"
data-step-id={step.id}
>
<StepDroppableArea stepId={step.id} />
<div
className={cn(
"rounded border shadow-sm transition-colors mb-2",
selectedStepId === step.id
? "border-blue-400/60 bg-blue-50/40 dark:bg-blue-950/20"
: "hover:bg-accent/30",
isDragging && "opacity-80 ring-1 ring-blue-300",
)}
>
<div
className="flex items-center justify-between gap-2 border-b px-2 py-1.5"
onClick={(e) => {
// Avoid selecting step when interacting with controls or inputs
const tag = (e.target as HTMLElement).tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "button") return;
selectStep(step.id);
selectAction(step.id, undefined);
}}
role="button"
tabIndex={0}
>
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleExpanded(step);
}}
className="text-muted-foreground hover:bg-accent/60 hover:text-foreground rounded p-1"
aria-label={step.expanded ? "Collapse step" : "Expand step"}
>
{step.expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] font-normal"
>
{step.order + 1}
</Badge>
{renamingStepId === step.id ? (
<Input
autoFocus
defaultValue={step.name}
className="h-7 w-40 text-xs"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter") {
renameStep(
step,
(e.target as HTMLInputElement).value.trim() ||
step.name,
);
setRenamingStepId(null);
void recomputeHash();
} else if (e.key === "Escape") {
setRenamingStepId(null);
}
}}
onBlur={(e) => {
renameStep(step, e.target.value.trim() || step.name);
setRenamingStepId(null);
void recomputeHash();
}}
/>
) : (
<div className="flex items-center gap-1">
<span className="text-sm font-medium">{step.name}</span>
<button
type="button"
className="text-muted-foreground hover:text-foreground p-1 opacity-0 group-hover:opacity-100"
aria-label="Rename step"
onClick={(e) => {
e.stopPropagation();
setRenamingStepId(step.id);
}}
>
<Edit3 className="h-3.5 w-3.5" />
</button>
</div>
)}
<span className="text-muted-foreground hidden text-[11px] md:inline">
{step.actions.length} actions
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-[11px] text-red-500 hover:text-red-600"
onClick={(e) => {
e.stopPropagation();
deleteStep(step);
}}
aria-label="Delete step"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<div
className="text-muted-foreground cursor-grab p-1"
aria-label="Drag step"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</div>
</div>
</div>
{step.expanded && (
<div className="space-y-2 px-3 py-3">
<div className="flex flex-wrap gap-2">
{step.actions.length > 0 && (
<SortableContext
items={step.actions.map((a) => sortableActionId(a.id))}
strategy={verticalListSortingStrategy}
>
<div className="flex w-full flex-col gap-2">
{step.actions.map((action) => (
<SortableActionChip
key={action.id}
action={action}
isSelected={
selectedStepId === step.id &&
selectedActionId === action.id
}
onSelect={() => {
selectStep(step.id);
selectAction(step.id, action.id);
}}
onDelete={() => deleteAction(step.id, action.id)}
/>
))}
</div>
</SortableContext>
)}
</div>
{/* Persistent centered bottom drop hint */}
<div className="mt-3 flex w-full items-center justify-center">
<div className="text-muted-foreground border border-dashed border-muted-foreground/30 rounded px-2 py-1 text-[11px]">
Drop actions here
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------------------ */
/* Render */
/* ------------------------------------------------------------------------ */
return (
<div className={cn("flex h-full flex-col", className)}>
<div className="flex items-center justify-between border-b px-3 py-2 text-xs">
<div className="flex items-center gap-3 font-medium">
<span className="text-muted-foreground flex items-center gap-1">
<GitBranch className="h-4 w-4" />
Flow
</span>
<span className="text-muted-foreground/70">
{steps.length} steps {" "}
{steps.reduce((s, st) => s + st.actions.length, 0)} actions
</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
className="h-7 px-2 text-[11px]"
onClick={() => createStep()}
>
<Plus className="mr-1 h-3 w-3" />
Step
</Button>
</div>
</div>
<div
ref={containerRef}
className="relative flex-1 overflow-y-auto"
onScroll={onScroll}
>
{steps.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center p-6">
<div className="text-center">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full border">
<GitBranch className="h-6 w-6 text-muted-foreground" />
</div>
<p className="mb-2 text-sm font-medium">No steps yet</p>
<p className="text-muted-foreground mb-3 text-xs">
Create your first step to begin designing the flow.
</p>
<Button size="sm" className="h-7 px-2 text-[11px]" onClick={() => createStep()}>
<Plus className="mr-1 h-3 w-3" /> Add Step
</Button>
</div>
</div>
) : (
<SortableContext
items={steps.map((s) => sortableStepId(s.id))}
strategy={verticalListSortingStrategy}
>
<div style={{ height: totalHeight, position: "relative" }}>
{virtualItems.map(
(vi) => vi.visible && <StepRow key={vi.key} item={vi} />,
)}
</div>
</SortableContext>
)}
</div>
</div>
);
}
export default FlowWorkspace;

View File

@@ -131,7 +131,7 @@ export function BottomStatusBar({
title="Validated (hash stable)" title="Validated (hash stable)"
> >
<CheckCircle2 className="mr-1 h-3 w-3" /> <CheckCircle2 className="mr-1 h-3 w-3" />
Validated <span className="hidden sm:inline">Validated</span>
</Badge> </Badge>
); );
case "drift": case "drift":
@@ -142,14 +142,14 @@ export function BottomStatusBar({
title="Drift since last validation" title="Drift since last validation"
> >
<AlertTriangle className="mr-1 h-3 w-3" /> <AlertTriangle className="mr-1 h-3 w-3" />
Drift <span className="hidden sm:inline">Drift</span>
</Badge> </Badge>
); );
default: default:
return ( return (
<Badge variant="outline" title="Not validated yet"> <Badge variant="outline" title="Not validated yet">
<Hash className="mr-1 h-3 w-3" /> <Hash className="mr-1 h-3 w-3" />
Unvalidated <span className="hidden sm:inline">Unvalidated</span>
</Badge> </Badge>
); );
} }
@@ -162,7 +162,8 @@ export function BottomStatusBar({
className="border-orange-300 text-orange-600 dark:text-orange-400" className="border-orange-300 text-orange-600 dark:text-orange-400"
title="Unsaved changes" title="Unsaved changes"
> >
Unsaved <AlertTriangle className="mr-1 h-3 w-3" />
<span className="hidden sm:inline">Unsaved</span>
</Badge> </Badge>
) : null; ) : null;
@@ -208,7 +209,7 @@ export function BottomStatusBar({
return ( return (
<div <div
className={cn( className={cn(
"border-border/60 bg-muted/40 backdrop-blur supports-[backdrop-filter]:bg-muted/30", "border-border/60 bg-muted/40 supports-[backdrop-filter]:bg-muted/30 backdrop-blur",
"flex h-10 w-full flex-shrink-0 items-center gap-3 border-t px-3 text-xs", "flex h-10 w-full flex-shrink-0 items-center gap-3 border-t px-3 text-xs",
"font-medium", "font-medium",
className, className,
@@ -216,7 +217,7 @@ export function BottomStatusBar({
aria-label="Designer status bar" aria-label="Designer status bar"
> >
{/* Left Cluster: Validation & Hash */} {/* Left Cluster: Validation & Hash */}
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
{validationBadge} {validationBadge}
{unsavedBadge} {unsavedBadge}
{savingIndicator} {savingIndicator}
@@ -225,7 +226,7 @@ export function BottomStatusBar({
className="flex items-center gap-1 font-mono text-[11px]" className="flex items-center gap-1 font-mono text-[11px]"
title="Current design hash" title="Current design hash"
> >
<Hash className="h-3 w-3 text-muted-foreground" /> <Hash className="text-muted-foreground h-3 w-3" />
{shortHash} {shortHash}
{lastPersistedShort && lastPersistedShort !== shortHash && ( {lastPersistedShort && lastPersistedShort !== shortHash && (
<span <span
@@ -239,20 +240,22 @@ export function BottomStatusBar({
</div> </div>
{/* Middle Cluster: Aggregate Counts */} {/* Middle Cluster: Aggregate Counts */}
<div className="flex items-center gap-3 text-muted-foreground"> <div className="text-muted-foreground flex min-w-0 items-center gap-3 truncate">
<div <div
className="flex items-center gap-1" className="flex items-center gap-1"
title="Steps in current design" title="Steps in current design"
> >
<GitBranch className="h-3 w-3" /> <GitBranch className="h-3 w-3" />
{steps.length} steps {steps.length}
<span className="hidden sm:inline"> steps</span>
</div> </div>
<div <div
className="flex items-center gap-1" className="flex items-center gap-1"
title="Total actions across all steps" title="Total actions across all steps"
> >
<Sparkles className="h-3 w-3" /> <Sparkles className="h-3 w-3" />
{actionCount} actions {actionCount}
<span className="hidden sm:inline"> actions</span>
</div> </div>
<div <div
className="hidden items-center gap-1 sm:flex" className="hidden items-center gap-1 sm:flex"
@@ -270,7 +273,7 @@ export function BottomStatusBar({
{versionStrategy.replace(/_/g, " ")} {versionStrategy.replace(/_/g, " ")}
</div> </div>
<div <div
className="hidden items-center gap-1 text-[10px] font-normal tracking-wide text-muted-foreground/80 md:flex" className="text-muted-foreground/80 hidden items-center gap-1 text-[10px] font-normal tracking-wide md:flex"
title="Relative time since last save" title="Relative time since last save"
> >
Saved {relSaved} Saved {relSaved}
@@ -289,9 +292,10 @@ export function BottomStatusBar({
disabled={!hasUnsaved && !pendingSave} disabled={!hasUnsaved && !pendingSave}
onClick={handleSave} onClick={handleSave}
aria-label="Save (s)" aria-label="Save (s)"
title="Save (s)"
> >
<Save className="mr-1 h-3 w-3" /> <Save className="mr-1 h-3 w-3" />
Save <span className="hidden sm:inline">Save</span>
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -300,14 +304,12 @@ export function BottomStatusBar({
onClick={handleValidate} onClick={handleValidate}
disabled={validating} disabled={validating}
aria-label="Validate (v)" aria-label="Validate (v)"
title="Validate (v)"
> >
<RefreshCw <RefreshCw
className={cn( className={cn("mr-1 h-3 w-3", validating && "animate-spin")}
"mr-1 h-3 w-3", />
validating && "animate-spin", <span className="hidden sm:inline">Validate</span>
)}
/>
Validate
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -316,9 +318,10 @@ export function BottomStatusBar({
onClick={handleExport} onClick={handleExport}
disabled={exporting} disabled={exporting}
aria-label="Export (e)" aria-label="Export (e)"
title="Export (e)"
> >
<Download className="mr-1 h-3 w-3" /> <Download className="mr-1 h-3 w-3" />
Export <span className="hidden sm:inline">Export</span>
</Button> </Button>
<Separator orientation="vertical" className="mx-1 h-4" /> <Separator orientation="vertical" className="mx-1 h-4" />
<Button <Button
@@ -327,9 +330,10 @@ export function BottomStatusBar({
className="h-7 px-2" className="h-7 px-2"
onClick={handlePalette} onClick={handlePalette}
aria-label="Command Palette (⌘K)" aria-label="Command Palette (⌘K)"
title="Command Palette (⌘K)"
> >
<Keyboard className="mr-1 h-3 w-3" /> <Keyboard className="mr-1 h-3 w-3" />
Commands <span className="hidden sm:inline">Commands</span>
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -115,17 +115,17 @@ export function PanelsContainer({
if (!raw) return; if (!raw) return;
const parsed = JSON.parse(raw) as PersistedLayout; const parsed = JSON.parse(raw) as PersistedLayout;
if (typeof parsed.left === "number") setLeftWidth(parsed.left); if (typeof parsed.left === "number") setLeftWidth(parsed.left);
if (typeof parsed.right === "number") setRightWidth(parsed.right); if (typeof parsed.right === "number")
setRightWidth(Math.max(parsed.right, minRightWidth));
if (typeof parsed.leftCollapsed === "boolean") { if (typeof parsed.leftCollapsed === "boolean") {
setLeftCollapsed(parsed.leftCollapsed); setLeftCollapsed(parsed.leftCollapsed);
} }
if (typeof parsed.rightCollapsed === "boolean") { // Always start with right panel visible to avoid hidden inspector state
setRightCollapsed(parsed.rightCollapsed); setRightCollapsed(false);
}
} catch { } catch {
/* noop */ /* noop */
} }
}, [disablePersistence]); }, [disablePersistence, minRightWidth]);
const persist = useCallback( const persist = useCallback(
(next?: Partial<PersistedLayout>) => { (next?: Partial<PersistedLayout>) => {
@@ -172,7 +172,7 @@ export function PanelsContainer({
next = Math.max(minRightWidth, Math.min(maxRightWidth, next)); next = Math.max(minRightWidth, Math.min(maxRightWidth, next));
if (next !== rightWidth) { if (next !== rightWidth) {
if (frameReq.current) cancelAnimationFrame(frameReq.current); if (frameReq.current) cancelAnimationFrame(frameReq.current);
frameReq.current = requestAnimationFrame(() => setRightWidth(next)); frameReq.current = requestAnimationFrame(() => setRightWidth(next));
} }
} }
}, },
@@ -205,7 +205,14 @@ export function PanelsContainer({
window.addEventListener("pointermove", onPointerMove); window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", endDrag); window.addEventListener("pointerup", endDrag);
}, },
[leftWidth, rightWidth, leftCollapsed, rightCollapsed, onPointerMove, endDrag], [
leftWidth,
rightWidth,
leftCollapsed,
rightCollapsed,
onPointerMove,
endDrag,
],
); );
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
@@ -275,7 +282,7 @@ export function PanelsContainer({
return ( return (
<div <div
className={cn( className={cn(
"flex h-full w-full select-none overflow-hidden", "flex h-full w-full overflow-hidden select-none",
className, className,
)} )}
aria-label="Designer panel layout" aria-label="Designer panel layout"
@@ -284,13 +291,15 @@ export function PanelsContainer({
{hasLeft && ( {hasLeft && (
<div <div
className={cn( className={cn(
"relative flex h-full flex-shrink-0 flex-col border-r bg-background/50 transition-[width] duration-150", "bg-background/50 relative flex h-full flex-shrink-0 flex-col border-r transition-[width] duration-150",
leftCollapsed ? "w-0 border-r-0" : "w-[--panel-left-width]", leftCollapsed ? "w-0 border-r-0" : "w-[var(--panel-left-width)]",
)} )}
style={ style={
leftCollapsed leftCollapsed
? undefined ? undefined
: ({ ["--panel-left-width" as string]: `${leftWidth}px` } as React.CSSProperties) : ({
["--panel-left-width" as string]: `${leftWidth}px`,
} as React.CSSProperties)
} }
> >
{!leftCollapsed && ( {!leftCollapsed && (
@@ -303,30 +312,15 @@ export function PanelsContainer({
{hasLeft && !leftCollapsed && ( {hasLeft && !leftCollapsed && (
<button <button
type="button" type="button"
aria-label="Resize left panel (Enter to toggle collapse)" aria-label="Resize left panel (Enter to toggle collapse)"
onPointerDown={(e) => startDrag("left", e)} onPointerDown={(e) => startDrag("left", e)}
onDoubleClick={toggleLeft}
onKeyDown={(e) => handleKeyResize("left", e)} onKeyDown={(e) => handleKeyResize("left", e)}
className="hover:bg-accent/40 focus-visible:ring-ring group relative z-10 h-full w-1 cursor-col-resize outline-none focus-visible:ring-2" className="hover:bg-accent/40 focus-visible:ring-ring relative z-10 h-full w-0 cursor-col-resize px-1 outline-none focus-visible:ring-2"
> />
<span className="bg-border absolute inset-y-0 left-0 w-px" />
<span className="bg-border/0 group-hover:bg-border absolute inset-y-0 right-0 w-px transition-colors" />
</button>
)} )}
{/* Collapse / Expand Toggle (Left) */} {/* Left collapse toggle removed to prevent breadcrumb overlap */}
{hasLeft && (
<button
type="button"
aria-label={leftCollapsed ? "Expand left panel" : "Collapse left panel"}
onClick={toggleLeft}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute top-2 z-20 rounded border bg-background/95 px-1.5 py-0.5 text-[10px] font-medium shadow-sm outline-none focus-visible:ring-2",
leftCollapsed ? "left-1" : "left-2",
)}
>
{leftCollapsed ? "»" : "«"}
</button>
)}
{/* Center (Workspace) */} {/* Center (Workspace) */}
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden"> <div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
@@ -337,49 +331,50 @@ export function PanelsContainer({
{hasRight && !rightCollapsed && ( {hasRight && !rightCollapsed && (
<button <button
type="button" type="button"
aria-label="Resize right panel (Enter to toggle collapse)" aria-label="Resize right panel (Enter to toggle collapse)"
onPointerDown={(e) => startDrag("right", e)} onPointerDown={(e) => startDrag("right", e)}
onDoubleClick={toggleRight}
onKeyDown={(e) => handleKeyResize("right", e)} onKeyDown={(e) => handleKeyResize("right", e)}
className="hover:bg-accent/40 focus-visible:ring-ring group relative z-10 h-full w-1 cursor-col-resize outline-none focus-visible:ring-2" className="hover:bg-accent/40 focus-visible:ring-ring relative z-10 h-full w-1 cursor-col-resize outline-none focus-visible:ring-2"
> />
<span className="bg-border absolute inset-y-0 right-0 w-px" />
<span className="bg-border/0 group-hover:bg-border absolute inset-y-0 left-0 w-px transition-colors" />
</button>
)} )}
{/* Right Panel */} {/* Right Panel */}
{hasRight && ( {hasRight && (
<div <div
className={cn( className={cn(
"relative flex h-full flex-shrink-0 flex-col border-l bg-background/50 transition-[width] duration-150", "bg-background/50 relative flex h-full flex-shrink-0 flex-col transition-[width] duration-150",
rightCollapsed ? "w-0 border-l-0" : "w-[--panel-right-width]", rightCollapsed ? "w-0" : "w-[var(--panel-right-width)]",
)} )}
style={ style={
rightCollapsed rightCollapsed
? undefined ? undefined
: ({ ["--panel-right-width" as string]: `${rightWidth}px` } as React.CSSProperties) : ({
["--panel-right-width" as string]: `${rightWidth}px`,
} as React.CSSProperties)
} }
> >
{!rightCollapsed && ( {!rightCollapsed && (
<div className="flex-1 overflow-hidden">{right}</div> <div className="min-w-0 flex-1 overflow-hidden">{right}</div>
)} )}
</div> </div>
)} )}
{/* Collapse / Expand Toggle (Right) */} {/* Minimal Right Toggle (top-right), non-intrusive like VSCode */}
{hasRight && ( {hasRight && (
<button <button
type="button" type="button"
aria-label={ aria-label={
rightCollapsed ? "Expand right panel" : "Collapse right panel" rightCollapsed ? "Expand inspector" : "Collapse inspector"
} }
onClick={toggleRight} onClick={toggleRight}
className={cn( className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute top-2 z-20 rounded border bg-background/95 px-1.5 py-0.5 text-[10px] font-medium shadow-sm outline-none focus-visible:ring-2", "text-muted-foreground hover:text-foreground absolute top-1 z-20 p-1 text-[10px]",
rightCollapsed ? "right-1" : "right-2", rightCollapsed ? "right-1" : "right-1",
)} )}
title={rightCollapsed ? "Show inspector" : "Hide inspector"}
> >
{rightCollapsed ? "«" : "»"} {rightCollapsed ? "" : ""}
</button> </button>
)} )}
</div> </div>

View File

@@ -1,20 +1,4 @@
"use client"; "use client";
/*
Unable to apply the requested minimal edits reliably because I don't have the authoritative line numbers for the current file contents (the editing protocol requires exact line matches with starting line numbers).
Please resend the file with line numbers (or just the specific line numbers for:
1. The DraggableAction wrapper <div> className
2. The star/favorite button block
3. The description <div>
4. The grid container for the actions list
Once I have those, I will:
- Change the grid from responsive two-column to forced single column (remove sm:grid-cols-2).
- Adjust tile layout to a slimmer vertical card, wrapping text (remove truncate, add normal wrapping or line clamp if desired).
- Move favorite star button to absolute top-right inside the tile (remove it from flow and add absolute classes).
- Optionally constrain left panel width through class (e.g., max-w-[260px]) if you want a thinner drawer.
- Ensure description wraps (replace truncate with line-clamp-3 or plain wrapping).
Let me know if you prefer line-clamp (limited lines) or full wrap.
*/
import React, { import React, {
useCallback, useCallback,
@@ -48,20 +32,6 @@ import { cn } from "~/lib/utils";
import { useActionRegistry } from "../ActionRegistry"; import { useActionRegistry } from "../ActionRegistry";
import type { ActionDefinition } from "~/lib/experiment-designer/types"; import type { ActionDefinition } from "~/lib/experiment-designer/types";
/**
* ActionLibraryPanel
*
* Enhanced wrapper panel for the experiment designer left side:
* - Fuzzy-ish search (case-insensitive substring) over name, description, id
* - Multi-category filtering (toggle chips)
* - Favorites (local persisted)
* - Density toggle (comfortable / compact)
* - Star / unstar actions inline
* - Drag support (DndKit) identical to legacy ActionLibrary
*
* Does NOT own persistence of actions themselves—delegates to action registry.
*/
export type ActionCategory = ActionDefinition["category"]; export type ActionCategory = ActionDefinition["category"];
interface FavoritesState { interface FavoritesState {
@@ -109,22 +79,16 @@ function DraggableAction({
onToggleFavorite, onToggleFavorite,
highlight, highlight,
}: DraggableActionProps) { }: DraggableActionProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } = const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
useDraggable({ id: `action-${action.id}`,
id: `action-${action.id}`, data: { action },
data: { action }, });
});
const style: React.CSSProperties = transform // Disable visual translation during drag so the list does not shift items.
? { // We still let dnd-kit manage the drag overlay internally (no manual transform).
transform: `translate3d(${transform.x}px, ${transform.y}px,0)`, const style: React.CSSProperties = {};
}
: {};
const IconComponent = const IconComponent = iconMap[action.icon] ?? Sparkles;
iconMap[action.icon] ??
// fallback icon (Sparkles)
Sparkles;
const categoryColors: Record<ActionCategory, string> = { const categoryColors: Record<ActionCategory, string> = {
wizard: "bg-blue-500", wizard: "bg-blue-500",
@@ -140,12 +104,12 @@ function DraggableAction({
{...listeners} {...listeners}
style={style} style={style}
className={cn( className={cn(
"group bg-background/60 hover:bg-accent/50 relative flex w-full cursor-grab flex-col gap-2 rounded border px-3 transition-colors", "group bg-background/60 hover:bg-accent/50 relative flex w-full cursor-grab touch-none flex-col gap-1 rounded border px-2 transition-colors select-none",
compact ? "py-2 text-[11px]" : "py-3 text-[12px]", compact ? "py-1.5 text-[11px]" : "py-2 text-[12px]",
isDragging && "opacity-50", isDragging && "ring-border opacity-60 ring-1",
)} )}
draggable={false} draggable={false}
title={action.description ?? ""} onDragStart={(e) => e.preventDefault()}
> >
<button <button
type="button" type="button"
@@ -162,14 +126,15 @@ function DraggableAction({
<StarOff className="h-3 w-3" /> <StarOff className="h-3 w-3" />
)} )}
</button> </button>
<div className="flex items-start gap-2">
<div className="flex items-start gap-2 select-none">
<div <div
className={cn( className={cn(
"flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-white", "flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-white",
categoryColors[action.category], categoryColors[action.category],
)} )}
> >
<IconComponent className="h-3.5 w-3.5" /> <IconComponent className="h-3 w-3" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-1 leading-snug font-medium"> <div className="flex items-center gap-1 leading-snug font-medium">
@@ -187,7 +152,7 @@ function DraggableAction({
</span> </span>
</div> </div>
{action.description && !compact && ( {action.description && !compact && (
<div className="text-muted-foreground mt-1 line-clamp-3 text-[11px] leading-snug break-words whitespace-normal"> <div className="text-muted-foreground mt-1 line-clamp-3 text-[10.5px] leading-snug break-words whitespace-normal">
{highlight {highlight
? highlightMatch(action.description, highlight) ? highlightMatch(action.description, highlight)
: action.description} : action.description}
@@ -199,10 +164,6 @@ function DraggableAction({
); );
} }
/* -------------------------------------------------------------------------- */
/* Panel Component */
/* -------------------------------------------------------------------------- */
export function ActionLibraryPanel() { export function ActionLibraryPanel() {
const registry = useActionRegistry(); const registry = useActionRegistry();
@@ -220,7 +181,6 @@ export function ActionLibraryPanel() {
const allActions = registry.getAllActions(); const allActions = registry.getAllActions();
/* ------------------------------- Favorites -------------------------------- */
useEffect(() => { useEffect(() => {
try { try {
const raw = localStorage.getItem(FAVORITES_STORAGE_KEY); const raw = localStorage.getItem(FAVORITES_STORAGE_KEY);
@@ -259,7 +219,6 @@ export function ActionLibraryPanel() {
[persistFavorites], [persistFavorites],
); );
/* ----------------------------- Category List ------------------------------ */
const categories = useMemo( const categories = useMemo(
() => () =>
[ [
@@ -281,21 +240,48 @@ export function ActionLibraryPanel() {
[], [],
); );
const toggleCategory = useCallback((c: ActionCategory) => { /**
setSelectedCategories((prev) => { * Enforce invariant:
const next = new Set(prev); * - Either ALL categories selected
if (next.has(c)) { * - Or EXACTLY ONE selected
next.delete(c); *
} else { * Behaviors:
next.add(c); * - From ALL -> clicking a category selects ONLY that category
} * - From single selected -> clicking same category returns to ALL
if (next.size === 0) { * - From single selected -> clicking different category switches to that single
// Keep at least one category selected * - Any multi-subset attempt collapses to the clicked category (prevents ambiguous subset)
next.add(c); */
} const toggleCategory = useCallback(
return next; (c: ActionCategory) => {
}); setSelectedCategories((prev) => {
}, []); const allKeys = categories.map((k) => k.key) as ActionCategory[];
const fullSize = allKeys.length;
const isFull = prev.size === fullSize;
const isSingle = prev.size === 1;
const has = prev.has(c);
// Case: full set -> reduce to single clicked
if (isFull) {
return new Set<ActionCategory>([c]);
}
// Case: single selection
if (isSingle) {
// Clicking the same => expand to all
if (has) {
return new Set<ActionCategory>(allKeys);
}
// Clicking different => switch single
return new Set<ActionCategory>([c]);
}
// (Should not normally reach: ambiguous multi-subset)
// Collapse to single clicked to restore invariant
return new Set<ActionCategory>([c]);
});
},
[categories],
);
const clearFilters = useCallback(() => { const clearFilters = useCallback(() => {
setSelectedCategories(new Set(categories.map((c) => c.key))); setSelectedCategories(new Set(categories.map((c) => c.key)));
@@ -304,11 +290,9 @@ export function ActionLibraryPanel() {
}, [categories]); }, [categories]);
useEffect(() => { useEffect(() => {
// On mount select all categories for richer initial view
setSelectedCategories(new Set(categories.map((c) => c.key))); setSelectedCategories(new Set(categories.map((c) => c.key)));
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
/* ------------------------------- Filtering -------------------------------- */
const filtered = useMemo(() => { const filtered = useMemo(() => {
const activeCats = selectedCategories; const activeCats = selectedCategories;
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
@@ -338,9 +322,7 @@ export function ActionLibraryPanel() {
control: 0, control: 0,
observation: 0, observation: 0,
}; };
for (const a of allActions) { for (const a of allActions) map[a.category] += 1;
map[a.category] += 1;
}
return map; return map;
}, [allActions]); }, [allActions]);
@@ -348,26 +330,51 @@ export function ActionLibraryPanel() {
filtered.some((a) => a.id === id), filtered.some((a) => a.id === id),
).length; ).length;
/* ------------------------------- Rendering -------------------------------- */
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full max-w-[240px] flex-col overflow-hidden">
{/* Toolbar */}
<div className="bg-background/60 border-b p-2"> <div className="bg-background/60 border-b p-2">
<div className="mb-2 flex gap-2"> <div className="relative mb-2">
<div className="relative flex-1"> <Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" />
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" /> <Input
<Input value={search}
value={search} onChange={(e) => setSearch(e.target.value)}
onChange={(e) => setSearch(e.target.value)} placeholder="Search"
placeholder="Search actions" className="h-8 w-full pl-7 text-xs"
className="h-8 pl-7 text-xs" aria-label="Search actions"
aria-label="Search actions" />
/> </div>
</div>
<div className="mb-2 grid grid-cols-2 gap-1">
{categories.map((cat) => {
const active = selectedCategories.has(cat.key);
const Icon = cat.icon;
return (
<Button
key={cat.key}
variant={active ? "default" : "ghost"}
size="sm"
className={cn(
"h-7 justify-start gap-1 text-[11px]",
active && `${cat.color} text-white hover:opacity-90`,
)}
onClick={() => toggleCategory(cat.key)}
aria-pressed={active}
>
<Icon className="h-3 w-3" />
{cat.label}
<span className="ml-auto text-[10px] font-normal opacity-80">
{countsByCategory[cat.key]}
</span>
</Button>
);
})}
</div>
<div className="flex flex-wrap gap-1">
<Button <Button
variant={showOnlyFavorites ? "default" : "outline"} variant={showOnlyFavorites ? "default" : "outline"}
size="sm" size="sm"
className="h-8" className="h-7 min-w-[80px] flex-1"
onClick={() => setShowOnlyFavorites((s) => !s)} onClick={() => setShowOnlyFavorites((s) => !s)}
aria-pressed={showOnlyFavorites} aria-pressed={showOnlyFavorites}
aria-label="Toggle favorites filter" aria-label="Toggle favorites filter"
@@ -387,7 +394,7 @@ export function ActionLibraryPanel() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8" className="h-7 min-w-[80px] flex-1"
onClick={() => onClick={() =>
setDensity((d) => setDensity((d) =>
d === "comfortable" ? "compact" : "comfortable", d === "comfortable" ? "compact" : "comfortable",
@@ -396,66 +403,41 @@ export function ActionLibraryPanel() {
aria-label="Toggle density" aria-label="Toggle density"
> >
<SlidersHorizontal className="mr-1 h-3 w-3" /> <SlidersHorizontal className="mr-1 h-3 w-3" />
{density === "comfortable" ? "Compact" : "Comfort"} {density === "comfortable" ? "Dense" : "Relax"}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-8" className="h-7 min-w-[60px] flex-1"
onClick={clearFilters} onClick={clearFilters}
aria-label="Clear filters" aria-label="Clear filters"
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
Clear
</Button> </Button>
</div> </div>
{/* Category Filters */}
<div className="grid grid-cols-4 gap-1">
{categories.map((cat) => {
const active = selectedCategories.has(cat.key);
const Icon = cat.icon;
return (
<Button
key={cat.key}
variant={active ? "default" : "ghost"}
size="sm"
className={cn(
"h-7 justify-start gap-1 truncate text-[11px]",
active && `${cat.color} text-white hover:opacity-90`,
)}
onClick={() => toggleCategory(cat.key)}
aria-pressed={active}
>
<Icon className="h-3 w-3" />
{cat.label}
<span className="ml-auto text-[10px] font-normal opacity-80">
{countsByCategory[cat.key]}
</span>
</Button>
);
})}
</div>
<div className="text-muted-foreground mt-2 flex items-center justify-between text-[10px]"> <div className="text-muted-foreground mt-2 flex items-center justify-between text-[10px]">
<div> <div>
{filtered.length} shown / {allActions.length} total {filtered.length} / {allActions.length}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<FolderPlus className="h-3 w-3" /> <FolderPlus className="h-3 w-3" />
<span> <span>
Plugins: {registry.getDebugInfo().pluginActionsLoaded ? "✓" : "…"} {registry.getDebugInfo().pluginActionsLoaded
? "Plugins ✓"
: "Plugins …"}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
{/* Actions List */} <ScrollArea className="flex-1 overflow-x-hidden overflow-y-auto">
<ScrollArea className="flex-1"> <div className="flex flex-col gap-2 p-2">
<div className="grid grid-cols-1 gap-2 p-2">
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className="text-muted-foreground/70 flex flex-col items-center gap-2 py-10 text-center text-xs"> <div className="text-muted-foreground/70 flex flex-col items-center gap-2 py-10 text-center text-xs">
<Filter className="h-6 w-6" /> <Filter className="h-6 w-6" />
<div>No actions match filters</div> <div>No actions</div>
</div> </div>
) : ( ) : (
filtered.map((action) => ( filtered.map((action) => (
@@ -472,7 +454,6 @@ export function ActionLibraryPanel() {
</div> </div>
</ScrollArea> </ScrollArea>
{/* Footer Summary */}
<div className="bg-background/60 border-t p-2"> <div className="bg-background/60 border-t p-2">
<div className="flex items-center justify-between text-[10px]"> <div className="flex items-center justify-between text-[10px]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -481,7 +462,7 @@ export function ActionLibraryPanel() {
</Badge> </Badge>
{showOnlyFavorites && ( {showOnlyFavorites && (
<Badge variant="outline" className="h-4 px-1 text-[10px]"> <Badge variant="outline" className="h-4 px-1 text-[10px]">
{visibleFavoritesCount} favorites {visibleFavoritesCount} fav
</Badge> </Badge>
)} )}
</div> </div>
@@ -491,9 +472,8 @@ export function ActionLibraryPanel() {
</div> </div>
</div> </div>
<Separator className="my-1" /> <Separator className="my-1" />
<p className="text-muted-foreground text-[9px] leading-relaxed"> <p className="text-muted-foreground hidden text-[9px] leading-relaxed md:block">
Drag actions into the flow. Use search / category filters to narrow Drag actions into the flow. Star frequent actions.
results. Star actions you use frequently.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -89,9 +89,21 @@ export function InspectorPanel({
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
/* Local Active Tab State (uncontrolled mode) */ /* Local Active Tab State (uncontrolled mode) */
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
const INSPECTOR_TAB_STORAGE_KEY = "hristudio-designer-inspector-tab-v1";
const [internalTab, setInternalTab] = useState< const [internalTab, setInternalTab] = useState<
"properties" | "issues" | "dependencies" "properties" | "issues" | "dependencies"
>(() => { >(() => {
try {
const raw =
typeof window !== "undefined"
? localStorage.getItem(INSPECTOR_TAB_STORAGE_KEY)
: null;
if (raw === "properties" || raw === "issues" || raw === "dependencies") {
return raw;
}
} catch {
/* noop */
}
if (selectedStepId) return "properties"; if (selectedStepId) return "properties";
return "issues"; return "issues";
}); });
@@ -103,6 +115,25 @@ export function InspectorPanel({
if (!autoFocusOnSelection) return; if (!autoFocusOnSelection) return;
if (selectedStepId || selectedActionId) { if (selectedStepId || selectedActionId) {
setInternalTab("properties"); setInternalTab("properties");
// Scroll properties panel to top and focus first field
requestAnimationFrame(() => {
const activeTabpanel = document.querySelector(
'[role="tabpanel"][data-state="active"]',
);
if (!(activeTabpanel instanceof HTMLElement)) return;
const viewportEl = activeTabpanel.querySelector(
'[data-slot="scroll-area-viewport"]',
);
if (viewportEl instanceof HTMLElement) {
viewportEl.scrollTop = 0;
const firstField = viewportEl.querySelector(
"input, select, textarea, button",
);
if (firstField instanceof HTMLElement) {
firstField.focus();
}
}
});
} }
}, [selectedStepId, selectedActionId, autoFocusOnSelection]); }, [selectedStepId, selectedActionId, autoFocusOnSelection]);
@@ -113,6 +144,11 @@ export function InspectorPanel({
onTabChange?.(val); onTabChange?.(val);
} else { } else {
setInternalTab(val); setInternalTab(val);
try {
localStorage.setItem(INSPECTOR_TAB_STORAGE_KEY, val);
} catch {
/* noop */
}
} }
} }
}, },
@@ -164,9 +200,12 @@ export function InspectorPanel({
return ( return (
<div <div
className={cn( className={cn(
"bg-background/40 flex h-full flex-col border-l backdrop-blur-sm", "bg-background/40 border-border relative flex h-full min-w-0 flex-col overflow-hidden border-l backdrop-blur-sm",
className, className,
)} )}
style={{ contain: "layout paint size" }}
role="complementary"
aria-label="Inspector panel"
> >
{/* Tab Header */} {/* Tab Header */}
<div className="border-b px-2 py-1.5"> <div className="border-b px-2 py-1.5">
@@ -175,41 +214,41 @@ export function InspectorPanel({
onValueChange={handleTabChange} onValueChange={handleTabChange}
className="w-full" className="w-full"
> >
<TabsList className="grid h-8 grid-cols-3"> <TabsList className="flex h-9 w-full items-center gap-1 overflow-hidden">
<TabsTrigger <TabsTrigger
value="properties" value="properties"
className="flex items-center gap-1 text-[11px]" className="flex min-w-0 flex-1 items-center justify-center gap-1 truncate text-[11px]"
title="Properties (Step / Action)" title="Properties (Step / Action)"
> >
<Settings className="h-3 w-3" /> <Settings className="h-3 w-3 flex-shrink-0" />
<span className="hidden sm:inline">Props</span> <span className="hidden sm:inline">Props</span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="issues" value="issues"
className="flex items-center gap-1 text-[11px]" className="flex min-w-0 flex-1 items-center justify-center gap-1 truncate text-[11px]"
title="Validation Issues" title="Validation Issues"
> >
<AlertTriangle className="h-3 w-3" /> <AlertTriangle className="h-3 w-3 flex-shrink-0" />
<span className="hidden sm:inline"> <span className="hidden sm:inline">
Issues{issueCount > 0 ? ` (${issueCount})` : ""} Issues{issueCount > 0 ? ` (${issueCount})` : ""}
</span> </span>
{issueCount > 0 && ( {issueCount > 0 && (
<span className="text-amber-600 sm:hidden dark:text-amber-400"> <span className="xs:hidden text-amber-600 dark:text-amber-400">
{issueCount} {issueCount}
</span> </span>
)} )}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="dependencies" value="dependencies"
className="flex items-center gap-1 text-[11px]" className="flex min-w-0 flex-1 items-center justify-center gap-1 truncate text-[11px]"
title="Dependencies / Drift" title="Dependencies / Drift"
> >
<PackageSearch className="h-3 w-3" /> <PackageSearch className="h-3 w-3 flex-shrink-0" />
<span className="hidden sm:inline"> <span className="hidden sm:inline">
Deps{driftCount > 0 ? ` (${driftCount})` : ""} Deps{driftCount > 0 ? ` (${driftCount})` : ""}
</span> </span>
{driftCount > 0 && ( {driftCount > 0 && (
<span className="text-purple-600 sm:hidden dark:text-purple-400"> <span className="xs:hidden text-purple-600 dark:text-purple-400">
{driftCount} {driftCount}
</span> </span>
)} )}
@@ -220,11 +259,15 @@ export function InspectorPanel({
{/* Content */} {/* Content */}
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
{/*
Force consistent width for tab bodies to prevent reflow when
switching between content with different intrinsic widths.
*/}
<Tabs value={effectiveTab}> <Tabs value={effectiveTab}>
{/* Properties */} {/* Properties */}
<TabsContent <TabsContent
value="properties" value="properties"
className="m-0 flex h-full flex-col data-[state=inactive]:hidden" className="m-0 flex min-h-0 flex-1 flex-col data-[state=inactive]:hidden"
> >
{propertiesEmpty ? ( {propertiesEmpty ? (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 p-4 text-center"> <div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 p-4 text-center">
@@ -240,7 +283,7 @@ export function InspectorPanel({
</div> </div>
) : ( ) : (
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="p-3"> <div className="w-full px-3 py-3">
<PropertiesPanel <PropertiesPanel
design={{ design={{
id: "design", id: "design",
@@ -263,35 +306,46 @@ export function InspectorPanel({
{/* Issues */} {/* Issues */}
<TabsContent <TabsContent
value="issues" value="issues"
className="m-0 flex h-full flex-col data-[state=inactive]:hidden" className="m-0 flex min-h-0 flex-1 flex-col data-[state=inactive]:hidden"
> >
<ScrollArea className="flex-1"> <ValidationPanel
<div className="p-3"> issues={validationIssues}
<ValidationPanel entityLabelForId={(entityId) => {
issues={validationIssues} if (entityId.startsWith("action-")) {
onIssueClick={(issue) => { for (const s of steps) {
if (issue.stepId) { const a = s.actions.find((x) => x.id === entityId);
selectStep(issue.stepId); if (a) return `${a.name}${s.name}`;
if (issue.actionId) { }
selectAction(issue.stepId, issue.actionId); }
if (autoFocusOnSelection) { if (entityId.startsWith("step-")) {
handleTabChange("properties"); const st = steps.find((s) => s.id === entityId);
} if (st) return st.name;
} }
} return "Unknown";
}} }}
/> onIssueClick={(issue) => {
</div> if (issue.stepId) {
</ScrollArea> selectStep(issue.stepId);
if (issue.actionId) {
selectAction(issue.stepId, issue.actionId);
} else {
selectAction(issue.stepId, undefined);
}
if (autoFocusOnSelection) {
handleTabChange("properties");
}
}
}}
/>
</TabsContent> </TabsContent>
{/* Dependencies */} {/* Dependencies */}
<TabsContent <TabsContent
value="dependencies" value="dependencies"
className="m-0 flex h-full flex-col data-[state=inactive]:hidden" className="m-0 flex min-h-0 flex-1 flex-col data-[state=inactive]:hidden"
> >
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="p-3"> <div className="w-full px-3 py-3">
<DependencyInspector <DependencyInspector
steps={steps} steps={steps}
actionSignatureDrift={actionSignatureDrift} actionSignatureDrift={actionSignatureDrift}

View File

@@ -12,7 +12,6 @@
import type { import type {
ExperimentStep, ExperimentStep,
ExperimentAction,
ActionDefinition, ActionDefinition,
TriggerType, TriggerType,
StepType, StepType,
@@ -69,7 +68,7 @@ const VALID_TRIGGER_TYPES: TriggerType[] = [
export function validateStructural( export function validateStructural(
steps: ExperimentStep[], steps: ExperimentStep[],
context: ValidationContext, _context: ValidationContext,
): ValidationIssue[] { ): ValidationIssue[] {
const issues: ValidationIssue[] = []; const issues: ValidationIssue[] = [];
@@ -189,7 +188,7 @@ export function validateStructural(
} }
// Action-level structural validation // Action-level structural validation
step.actions.forEach((action, actionIndex) => { step.actions.forEach((action) => {
const actionId = action.id; const actionId = action.id;
// Action name validation // Action name validation
@@ -423,7 +422,10 @@ export function validateParameters(
field, field,
stepId, stepId,
actionId, actionId,
suggestion: `Choose from: ${paramDef.options.join(", ")}`, suggestion:
Array.isArray(paramDef.options) && paramDef.options.length
? `Choose from: ${paramDef.options.join(", ")}`
: "Choose a valid option",
}); });
} }
break; break;
@@ -472,7 +474,7 @@ export function validateParameters(
export function validateSemantic( export function validateSemantic(
steps: ExperimentStep[], steps: ExperimentStep[],
context: ValidationContext, _context: ValidationContext,
): ValidationIssue[] { ): ValidationIssue[] {
const issues: ValidationIssue[] = []; const issues: ValidationIssue[] = [];
@@ -629,7 +631,7 @@ export function validateSemantic(
export function validateExecution( export function validateExecution(
steps: ExperimentStep[], steps: ExperimentStep[],
context: ValidationContext, _context: ValidationContext,
): ValidationIssue[] { ): ValidationIssue[] {
const issues: ValidationIssue[] = []; const issues: ValidationIssue[] = [];
@@ -720,7 +722,7 @@ export function groupIssuesByEntity(
const grouped: Record<string, ValidationIssue[]> = {}; const grouped: Record<string, ValidationIssue[]> = {};
issues.forEach((issue) => { issues.forEach((issue) => {
const entityId = issue.actionId || issue.stepId || "experiment"; const entityId = issue.actionId ?? issue.stepId ?? "experiment";
if (!grouped[entityId]) { if (!grouped[entityId]) {
grouped[entityId] = []; grouped[entityId] = [];
} }

View File

@@ -28,7 +28,7 @@ function mapStepTypeToDatabase(
): "wizard" | "robot" | "parallel" | "conditional" { ): "wizard" | "robot" | "parallel" | "conditional" {
switch (stepType) { switch (stepType) {
case "sequential": case "sequential":
return "wizard"; // Default to wizard for sequential return "wizard";
case "parallel": case "parallel":
return "parallel"; return "parallel";
case "conditional": case "conditional":

View File

@@ -32,6 +32,7 @@
}, },
"include": [ "include": [
// FlowWorkspace (flow/FlowWorkspace.tsx) and new designer modules are included via recursive globs
"next-env.d.ts", "next-env.d.ts",
"**/*.ts", "**/*.ts",
"**/*.tsx", "**/*.tsx",