From 550021a18e57f96c9266a0b722be972f3da3b759 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Wed, 13 Aug 2025 17:56:30 -0400 Subject: [PATCH] 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 --- docker-compose.yml | 24 +- docs/work_in_progress.md | 220 +++-- .../plugins/wizard-actions.json | 56 +- scripts/seed-dev.ts | 511 +++++++++-- .../experiments/[id]/designer/page.tsx | 223 ++++- .../experiments/designer/ActionRegistry.ts | 24 +- .../experiments/designer/DesignerRoot.tsx | 310 +++++-- .../experiments/designer/PropertiesPanel.tsx | 177 ++-- .../experiments/designer/ValidationPanel.tsx | 333 ++++---- .../designer/flow/FlowWorkspace.tsx | 798 ++++++++++++++++++ .../designer/layout/BottomStatusBar.tsx | 46 +- .../designer/layout/PanelsContainer.tsx | 87 +- .../designer/panels/ActionLibraryPanel.tsx | 248 +++--- .../designer/panels/InspectorPanel.tsx | 120 ++- .../experiments/designer/state/validators.ts | 16 +- .../experiment-designer/block-converter.ts | 2 +- tsconfig.json | 1 + 17 files changed, 2430 insertions(+), 766 deletions(-) create mode 100644 src/components/experiments/designer/flow/FlowWorkspace.tsx diff --git a/docker-compose.yml b/docker-compose.yml index c57b270..e063a86 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,18 +17,18 @@ services: timeout: 5s retries: 5 - minio: - image: minio/minio - ports: - - "9000:9000" # API - - "9001:9001" # Console - environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin - volumes: - - minio_data:/data - command: server --console-address ":9001" /data + # minio: + # image: minio/minio + # ports: + # - "9000:9000" # API + # - "9001:9001" # Console + # environment: + # MINIO_ROOT_USER: minioadmin + # MINIO_ROOT_PASSWORD: minioadmin + # volumes: + # - minio_data:/data + # command: server --console-address ":9001" /data volumes: postgres_data: - minio_data: + # minio_data: diff --git a/docs/work_in_progress.md b/docs/work_in_progress.md index 7777d2e..987591a 100644 --- a/docs/work_in_progress.md +++ b/docs/work_in_progress.md @@ -1,123 +1,157 @@ # Work In Progress - ## 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:** - Zustand state management with comprehensive actions and selectors - 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 -- Export/import with JSON integrity bundles +- Export/import integrity bundles -**✅ UI Components Complete:** -- `DesignerShell` - Main orchestration component with tabbed layout -- `ActionLibrary` - Categorized drag-drop palette with search and filtering -- `StepFlow` - Hierarchical step/action management with @dnd-kit integration -- `PropertiesPanel` - Context-sensitive editing with enhanced parameter controls -- `ValidationPanel` - Issue filtering and navigation with severity indicators -- `DependencyInspector` - Plugin health monitoring and drift visualization -- `SaveBar` - Version control, auto-save, and export functionality +**✅ UI Components (Initial Generation):** +- `DesignerShell` (initial orchestration – now superseded by `DesignerRoot`) +- `ActionLibrary` (v1 palette) +- `StepFlow` (legacy list) +- `PropertiesPanel`, `ValidationPanel`, `DependencyInspector` +- `SaveBar` -**✅ Advanced Features Complete:** -- Enhanced parameter controls (sliders, switches, type-safe inputs) -- Real-time validation with live issue detection -- Incremental hashing for performance optimization -- Plugin signature drift monitoring -- Conflict detection for concurrent editing -- Comprehensive error handling and accessibility compliance +**Phase 2 Overhaul Components (In Progress / Added):** +- `DesignerRoot` (panel + status bar orchestration) +- `PanelsContainer` (resizable/collapsible left/right) +- `BottomStatusBar` (hash / drift / unsaved quick actions) +- `ActionLibraryPanel` (slim, single-column, favorites, density, search) +- `FlowWorkspace` (virtualized step list replacing `StepFlow` for large scale) +- `InspectorPanel` (tabbed: properties / issues / dependencies) -#### **Technical Achievements** +### Recent Updates (Latest Iteration) -- **100% TypeScript** with strict type safety throughout -- **Zero TypeScript errors** - All compilation issues resolved -- **Production-ready** with comprehensive error handling -- **Accessible design** meeting WCAG 2.1 AA standards -- **Performance optimized** with incremental computation -- **Enterprise patterns** with consistent UI/UX standards +**Action Library Slim Refactor** +- Constrained width (max 240px) with internal vertical scroll +- Single-column tall tiles; star (favorite) moved top-right +- Multi-line name wrapping; description line-clamped (3 lines) +- Stacked control layout (search → categories → compact buttons) +- 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`) -- ✅ Step addition functionality fully working -- ✅ JSX structure issues resolved -- ✅ Type-only imports properly configured -- ✅ Action Library core actions loading fixed (events category added) -- ✅ Debugging infrastructure added for plugin action tracking -- ✅ ActionLibrary reactivity fix implemented (React updates on registry changes) -- ⏳ Legacy `BlockDesigner` removal pending final validation +**Flow Pane Overhaul** +- Introduced `FlowWorkspace` virtualized list: + - Variable-height virtualization (dynamic measurement with ResizeObserver) + - Inline step rename (Enter / Escape / blur commit) + - Collapsible steps with action chips + - Insert “Below” & “Step Above” affordances + - Droppable targets registered per step (`step-`) + - 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 -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 +### Migration Status -### 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) -``` -DesignerShell (Main Orchestration) -├── ActionLibrary (Left Panel) -│ ├── Category Tabs (Wizard, Robot, Control, Observe) -│ ├── Search/Filter Controls -│ └── Draggable Action Items -├── StepFlow (Center Panel) -│ ├── Sortable Step Cards -│ ├── Droppable Action Zones -│ └── Inline Action Management -└── Properties Tabs (Right Panel) - ├── Properties (Step/Action Editing) - ├── Issues (Validation Panel) - └── Dependencies (Plugin Inspector) -``` +1. Step Reordering in `FlowWorkspace` (drag handle integration) +2. Keyboard navigation: + - Arrow up/down step traversal + - Enter rename / Escape cancel + - Shift+N insert below +3. Multi-select & bulk delete (steps + actions) +4. Command Palette (⌘K): + - Insert action by fuzzy search + - Jump to step/action + - Trigger validate / export / save +5. Graph / Branch View (React Flow selective mount) +6. Drift reconciliation modal (signature diff + adopt / ignore) +7. Auto-save throttle controls (status bar menu) +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 -``` -Zustand Store (useDesignerStore) -├── Core State (steps, selection, dirty tracking) -├── Hashing (incremental computation, integrity) -├── Validation (issue tracking, severity filtering) -├── Drift Detection (signature tracking, reconciliation) -└── Save Workflow (conflict handling, versioning) -``` - -### Quality Metrics - -- **Code Coverage**: 100% TypeScript type safety -- **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 +| # | Task | Status | +| - | ---- | ------ | +| 1 | Slim action pane + scroll fix | ✅ Complete | +| 2 | Introduce virtualized FlowWorkspace | ✅ Initial implementation | +| 3 | Migrate page to `DesignerRoot` | ✅ Complete | +| 4 | Hook drag-drop into new workspace | ✅ Complete | +| 5 | Step reorder (drag) | ⏳ Pending | +| 6 | Command palette | ⏳ Pending | +| 7 | Remove legacy `StepFlow` & `FlowListView` | ⏳ After reorder | +| 8 | Graph view toggle | ⏳ Planned | +| 9 | Drift reconciliation UX | ⏳ Planned | +| 10 | Conflict resolution modal | ⏳ Planned | ### 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 2. ✅ **Core Action Loading**: Fixed - Added missing "events" category to ActionRegistry 3. ✅ **Plugin Action Display**: Fixed - ActionLibrary now reactively updates when plugins load diff --git a/public/hristudio-core/plugins/wizard-actions.json b/public/hristudio-core/plugins/wizard-actions.json index 338665d..23196b1 100644 --- a/public/hristudio-core/plugins/wizard-actions.json +++ b/public/hristudio-core/plugins/wizard-actions.json @@ -43,7 +43,13 @@ "name": "Tone", "type": "select", "value": "neutral", - "options": ["neutral", "friendly", "encouraging", "instructional", "questioning"], + "options": [ + "neutral", + "friendly", + "encouraging", + "instructional", + "questioning" + ], "description": "Suggested tone for delivery" } ], @@ -68,7 +74,15 @@ "name": "Gesture", "type": "select", "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" }, { @@ -76,7 +90,15 @@ "name": "Direction", "type": "select", "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" } ], @@ -109,8 +131,15 @@ "name": "Action", "type": "select", "value": "hold_up", - "options": ["hold_up", "demonstrate", "point_to", "place_on_table", "hand_to_participant"], - "description": "How to present the object" + "options": [ + "hold_up", + "demonstrate", + "point_to", + "place_on_table", + "hand_to_participant" + ], + "description": "How to present the object", + "required": false } ], "execution": { @@ -134,7 +163,13 @@ "name": "Note Type", "type": "select", "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" }, { @@ -210,7 +245,14 @@ "name": "Rating Type", "type": "select", "value": "engagement", - "options": ["engagement", "comprehension", "comfort", "success", "naturalness", "custom"], + "options": [ + "engagement", + "comprehension", + "comfort", + "success", + "naturalness", + "custom" + ], "description": "Aspect being rated" }, { diff --git a/scripts/seed-dev.ts b/scripts/seed-dev.ts index fa357c6..19be898 100644 --- a/scripts/seed-dev.ts +++ b/scripts/seed-dev.ts @@ -3,6 +3,8 @@ import { drizzle } from "drizzle-orm/postgres-js"; import { eq, sql } from "drizzle-orm"; import postgres from "postgres"; import * as schema from "../src/server/db/schema"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; // Database connection const connectionString = process.env.DATABASE_URL!; @@ -17,34 +19,57 @@ async function syncRepository( try { console.log(`🔄 Syncing repository: ${repoUrl}`); - // Use localhost for development - const devUrl = repoUrl.includes("core.hristudio.com") - ? "http://localhost:3000/hristudio-core" - : repoUrl; + // Resolve source: use local public repo for core, remote URL otherwise + const isCore = repoUrl.includes("core.hristudio.com"); + const devUrl = repoUrl; - // Fetch repository metadata - const repoResponse = await fetch(`${devUrl}/repository.json`); - if (!repoResponse.ok) { - throw new Error( - `Failed to fetch repository metadata: ${repoResponse.status}`, - ); - } - const repoMetadata = (await repoResponse.json()) as { - description?: string; - author?: { name?: string }; - urls?: { git?: string }; - trust?: string; - }; + // Fetch repository metadata (local filesystem for core) + const repoMetadata = isCore + ? (JSON.parse( + await readFile( + path.join( + process.cwd(), + "public", + "hristudio-core", + "repository.json", + ), + "utf8", + ), + ) 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 - if (repoUrl.includes("core.hristudio.com")) { - const indexResponse = await fetch(`${devUrl}/plugins/index.json`); - if (!indexResponse.ok) { - throw new Error( - `Failed to fetch plugin index: ${indexResponse.status}`, - ); - } - const indexData = (await indexResponse.json()) as { + if (isCore) { + const indexData = JSON.parse( + await readFile( + path.join( + process.cwd(), + "public", + "hristudio-core", + "plugins", + "index.json", + ), + "utf8", + ), + ) as { plugins?: Array<{ blockCount?: number }>; }; @@ -203,7 +228,7 @@ async function main() { .returning(); console.log(`✅ Created ${insertedRobots.length} robots`); - // Create users + // Create users (Bucknell University team) console.log("👥 Creating users..."); const hashedPassword = await bcrypt.hash("password123", 12); @@ -216,29 +241,29 @@ async function main() { image: null, }, { - name: "Dr. Alice Rodriguez", - email: "alice.rodriguez@university.edu", + name: "Prof. Dana Miller", + email: "dana.miller@bucknell.edu", password: hashedPassword, emailVerified: new Date(), image: null, }, { - name: "Dr. Bob Chen", - email: "bob.chen@research.org", + name: "Chris Lee", + email: "chris.lee@bucknell.edu", password: hashedPassword, emailVerified: new Date(), image: null, }, { - name: "Emily Watson", - email: "emily.watson@lab.edu", + name: "Priya Singh", + email: "priya.singh@bucknell.edu", password: hashedPassword, emailVerified: new Date(), image: null, }, { - name: "Dr. Maria Santos", - email: "maria.santos@tech.edu", + name: "Jordan White", + email: "jordan.white@bucknell.edu", password: hashedPassword, emailVerified: new Date(), image: null, @@ -321,32 +346,23 @@ async function main() { console.log("📚 Creating studies..."); const studies = [ { - name: "Human-Robot Collaboration Study", + name: "NAO Classroom Interaction", description: - "Investigating collaborative tasks between humans and robots in shared workspace environments", - institution: "MIT Computer Science", - irbProtocol: "IRB-2024-001", + "Evaluating student engagement with NAO-led prompts during lab sessions", + institution: "Bucknell University", + irbProtocol: "BU-IRB-2025-NAO-01", status: "active" as const, createdBy: seanUser.id, }, { - name: "Robot Navigation Study", + name: "Wizard-of-Oz Dialogue Study", description: - "A comprehensive study on robot navigation and obstacle avoidance in dynamic environments", - institution: "Stanford HCI Lab", - irbProtocol: "IRB-2024-002", + "WoZ-controlled NAO to assess timing and tone in instructional feedback", + institution: "Bucknell University", + irbProtocol: "BU-IRB-2025-WOZ-02", status: "draft" as const, 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 @@ -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 console.log("👤 Creating participants..."); const participants = []; @@ -447,24 +484,313 @@ async function main() { .returning(); console.log(`✅ Created ${insertedParticipants.length} participants`); - // Create basic experiments + // Create experiments (include one NAO-based) console.log("🧪 Creating experiments..."); - const experiments = insertedStudies.map((study, i) => ({ - studyId: study.id, - name: `Basic Interaction Protocol ${i + 1}`, - description: `A simple human-robot interaction experiment for ${study.name}`, - version: 1, - status: "ready" as const, - estimatedDuration: 30 + i * 10, - createdBy: seanUser.id, - })); + const experiments = [ + { + studyId: insertedStudies[0]!.id, + name: "Basic Interaction Protocol 1", + description: "Wizard prompts + NAO speaks demo script", + version: 1, + status: "ready" as const, + 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 .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(); 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 console.log("🧪 Creating sample trials..."); const trials = []; @@ -526,6 +852,65 @@ async function main() { .returning(); 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 console.log("📝 Creating activity logs..."); const activityEntries = []; @@ -612,7 +997,7 @@ async function main() { console.log("\n✅ Seed script completed successfully!"); console.log("\n📊 Created:"); console.log(` • ${insertedRobots.length} robots`); - console.log(` • ${insertedUsers.length} users`); + console.log(` • ${insertedUsers.length} users (Bucknell)`); console.log(` • ${insertedRepos.length} plugin repositories`); console.log(` • ${totalPlugins} plugins (via repository sync)`); console.log(` • ${insertedStudies.length} studies`); diff --git a/src/app/(dashboard)/experiments/[id]/designer/page.tsx b/src/app/(dashboard)/experiments/[id]/designer/page.tsx index 310b600..fb61297 100644 --- a/src/app/(dashboard)/experiments/[id]/designer/page.tsx +++ b/src/app/(dashboard)/experiments/[id]/designer/page.tsx @@ -1,6 +1,12 @@ import { notFound } from "next/navigation"; 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"; interface ExperimentDesignerPageProps { @@ -28,20 +34,209 @@ export default async function ExperimentDesignerPage({ } | null; // Only pass initialDesign if there's existing visual design data - const initialDesign = - existingDesign?.steps && existingDesign.steps.length > 0 - ? { - 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(), + let initialDesign: + | { + id: string; + name: string; + description: string; + steps: ExperimentStep[]; + version: number; + lastSaved: Date; + } + | undefined; + + 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 | null { + return v && typeof v === "object" + ? (v as Record) + : 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 = { + 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, + 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 ( (); + private aliasIndex = new Map(); private coreActionsLoaded = false; private pluginActionsLoaded = false; private loadedStudyId: string | null = null; @@ -292,6 +293,7 @@ export class ActionRegistry { icon?: string; timeout?: number; retryable?: boolean; + aliases?: string[]; parameterSchema?: unknown; ros2?: { topic?: string; @@ -394,8 +396,8 @@ export class ActionRegistry { }; const actionDef: ActionDefinition = { - id: `${plugin.id}.${action.id}`, - type: `${plugin.id}.${action.id}`, + id: `${plugin.robotId ?? plugin.id}.${action.id}`, + type: `${plugin.robotId ?? plugin.id}.${action.id}`, name: action.name, description: action.description ?? "", category, @@ -406,7 +408,7 @@ export class ActionRegistry { ), source: { kind: "plugin", - pluginId: plugin.id, + pluginId: plugin.robotId ?? plugin.id, robotId: plugin.robotId, pluginVersion: plugin.version ?? undefined, baseActionId: action.id, @@ -415,6 +417,17 @@ export class ActionRegistry { parameterSchemaRaw: action.parameterSchema ?? undefined, }; 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++; }); }); @@ -524,7 +537,10 @@ export class ActionRegistry { } 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 ---------------- */ diff --git a/src/components/experiments/designer/DesignerRoot.tsx b/src/components/experiments/designer/DesignerRoot.tsx index 073d370..2a434ac 100644 --- a/src/components/experiments/designer/DesignerRoot.tsx +++ b/src/components/experiments/designer/DesignerRoot.tsx @@ -1,20 +1,37 @@ "use client"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; 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 { Button } from "~/components/ui/button"; import { api } from "~/trpc/react"; 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 { ActionLibraryPanel } from "./panels/ActionLibraryPanel"; import { InspectorPanel } from "./panels/InspectorPanel"; -import { FlowListView } from "./flow/FlowListView"; +import { FlowWorkspace } from "./flow/FlowWorkspace"; import { type ExperimentDesign, @@ -25,7 +42,10 @@ import { import { useDesignerStore } from "./state/store"; import { actionRegistry } from "./ActionRegistry"; import { computeDesignHash } from "./state/hashing"; -import { validateExperimentDesign } from "./state/validators"; +import { + validateExperimentDesign, + groupIssuesByEntity, +} from "./state/validators"; /** * DesignerRoot @@ -161,6 +181,30 @@ export function DesignerRoot({ const setValidatedHash = useDesignerStore((s) => s.setValidatedHash); const upsertStep = useDesignerStore((s) => s.upsertStep); 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(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 ------------------------------ */ const [designMeta, setDesignMeta] = useState<{ @@ -193,10 +237,24 @@ export function DesignerRoot({ const [isValidating, setIsValidating] = useState(false); const [isExporting, setIsExporting] = useState(false); const [lastSavedAt, setLastSavedAt] = useState(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 ---------------------------- */ useEffect(() => { - if (initialized || loadingExperiment) return; + if (initialized) return; + if (loadingExperiment && !initialDesign) return; const adapted = initialDesign ?? (experiment @@ -288,8 +346,10 @@ export function DesignerRoot({ expanded: true, }; upsertStep(newStep); + selectStep(newStep.id); + setInspectorTab("properties"); toast.success(`Created ${newStep.name}`); - }, [steps.length, upsertStep]); + }, [steps.length, upsertStep, selectStep]); /* ------------------------------- Validation ------------------------------ */ const validateDesign = useCallback(async () => { @@ -297,14 +357,39 @@ export function DesignerRoot({ setIsValidating(true); try { const currentSteps = [...steps]; + // Ensure core actions are loaded before validating + await actionRegistry.loadCoreActions(); const result = validateExperimentDesign(currentSteps, { steps: currentSteps, 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); setValidatedHash(hash); if (result.valid) { - toast.success(`Validated • ${hash.slice(0, 10)}… • No issues`); + toast.success(`Validated • ${hash.slice(0, 10)}… • 0 errors`); } else { toast.warning( `Validated with ${result.errorCount} errors, ${result.warningCount} warnings`, @@ -319,7 +404,13 @@ export function DesignerRoot({ } finally { setIsValidating(false); } - }, [initialized, steps, setValidatedHash]); + }, [ + initialized, + steps, + setValidatedHash, + setValidationIssues, + clearAllValidationIssues, + ]); /* --------------------------------- Save ---------------------------------- */ const persist = useCallback(async () => { @@ -414,6 +505,19 @@ export function DesignerRoot({ void 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 --------------------------- */ const keyHandler = useCallback( (e: globalThis.KeyboardEvent) => { @@ -448,21 +552,76 @@ export function DesignerRoot({ /* ------------------------------ Header Badges ---------------------------- */ + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 6 } }), + useSensor(TouchSensor, { + activationConstraint: { delay: 150, tolerance: 5 }, + }), + useSensor(KeyboardSensor), + ); + /* ----------------------------- 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( - (event: DragEndEvent) => { + async (event: DragEndEvent) => { 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 const activeId = active.id.toString(); const overId = over.id.toString(); - if ( - activeId.startsWith("action-") && - overId.startsWith("step-") && - active.data.current?.action - ) { + if (activeId.startsWith("action-") && active.data.current?.action) { + // Resolve stepId from possible over ids: step-, s-step-, or s-act- + let stepId: string | null = null; + 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 { id: string; type: string; @@ -474,7 +633,6 @@ export function DesignerRoot({ parameters: Array<{ id: string; name: string }>; }; - const stepId = overId.replace("step-", ""); const targetStep = steps.find((s) => s.id === stepId); if (!targetStep) return; @@ -502,24 +660,24 @@ export function DesignerRoot({ }; 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}`); } }, - [steps, upsertAction], + [ + steps, + upsertAction, + recomputeHash, + selectStep, + selectAction, + toggleLibraryScrollLock, + ], ); - const validationBadge = - driftStatus === "drift" ? ( - Drift - ) : driftStatus === "validated" ? ( - - Validated - - ) : ( - Unvalidated - ); + // validation status badges removed (unused) /* ------------------------------- Render ---------------------------------- */ if (loadingExperiment && !initialized) { @@ -538,54 +696,23 @@ export function DesignerRoot({ icon={Play} actions={
- {validationBadge} - {experiment?.integrityHash && ( - - Hash: {experiment.integrityHash.slice(0, 10)}… - - )} - - {steps.length} steps - - - {steps.reduce((s, st) => s + st.actions.length, 0)} actions - - {hasUnsavedChanges && ( - - Unsaved - - )} - persist()} - disabled={!hasUnsavedChanges || isSaving} - > - {isSaving ? "Saving…" : "Save"} - - validateDesign()} - disabled={isValidating} - > - {isValidating ? "Validating…" : "Validate"} - - handleExport()} - disabled={isExporting} - > - {isExporting ? "Exporting…" : "Export"} - +
} @@ -593,17 +720,38 @@ export function DesignerRoot({
toggleLibraryScrollLock(false)} > } - center={} - right={} + left={ +
+ +
+ } + center={} + right={ + + } initialLeftWidth={260} - initialRightWidth={360} + initialRightWidth={260} + minRightWidth={240} + maxRightWidth={300} className="flex-1" /> + + {dragOverlayAction ? ( +
+ {dragOverlayAction.name} +
+ ) : null} +
persist()} diff --git a/src/components/experiments/designer/PropertiesPanel.tsx b/src/components/experiments/designer/PropertiesPanel.tsx index c1720da..20ad8fa 100644 --- a/src/components/experiments/designer/PropertiesPanel.tsx +++ b/src/components/experiments/designer/PropertiesPanel.tsx @@ -124,7 +124,7 @@ export function PropertiesPanel({ : Zap; return ( -
+
{/* Header / Metadata */}
@@ -142,8 +142,8 @@ export function PropertiesPanel({

{selectedAction.name}

-

- {def?.category} • {selectedAction.type} +

+ {def?.category}

@@ -151,14 +151,7 @@ export function PropertiesPanel({ {selectedAction.source.kind === "plugin" ? "Plugin" : "Core"} - {selectedAction.source.pluginId && ( - - {selectedAction.source.pluginId} - {selectedAction.source.pluginVersion - ? `@${selectedAction.source.pluginVersion}` - : ""} - - )} + {/* internal plugin identifiers hidden from UI */} {selectedAction.execution.transport} @@ -175,8 +168,11 @@ export function PropertiesPanel({ )}
- {/* General Action Fields */} + {/* General */}
+
+ General +
@@ -231,7 +227,7 @@ export function PropertiesPanel({ value={(rawValue as string) ?? ""} placeholder={param.placeholder} 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") { @@ -240,7 +236,7 @@ export function PropertiesPanel({ value={(rawValue as string) ?? ""} onValueChange={(val) => updateParamValue(val)} > - + @@ -322,7 +318,7 @@ export function PropertiesPanel({ onChange={(e) => 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 --------------------------- */ if (selectedStep) { return ( -
+

-
+
- - - onStepUpdate(selectedStep.id, { name: e.target.value }) - } - className="mt-1 h-7 text-xs" - /> +
+ General +
+
+
+ + + onStepUpdate(selectedStep.id, { name: e.target.value }) + } + className="mt-1 h-7 w-full text-xs" + /> +
+
+ + + onStepUpdate(selectedStep.id, { + description: e.target.value, + }) + } + className="mt-1 h-7 w-full text-xs" + /> +
+
+
- - - onStepUpdate(selectedStep.id, { - description: e.target.value, - }) - } - className="mt-1 h-7 text-xs" - /> -
-
- - -
-
- - +
+ Behavior +
+
+
+ + +
+
+ + +
+
@@ -451,9 +462,9 @@ export function PropertiesPanel({ >
-

Select Step or Action

+

No selection

- Click in the flow to edit properties + Select a step or action in the flow to edit its properties.

diff --git a/src/components/experiments/designer/ValidationPanel.tsx b/src/components/experiments/designer/ValidationPanel.tsx index 49d5003..0467151 100644 --- a/src/components/experiments/designer/ValidationPanel.tsx +++ b/src/components/experiments/designer/ValidationPanel.tsx @@ -1,12 +1,20 @@ "use client"; 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 { Button } from "~/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Separator } from "~/components/ui/separator"; +import { Input } from "~/components/ui/input"; import { cn } from "~/lib/utils"; /* -------------------------------------------------------------------------- */ @@ -39,6 +47,10 @@ export interface ValidationPanelProps { * Called to clear all issues for an entity. */ 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; } @@ -109,16 +121,22 @@ interface IssueItemProps { issue: ValidationIssue & { entityId: string; index: number }; onIssueClick?: (issue: ValidationIssue) => 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 IconComponent = config.icon; return (
-

{issue.message}

+

+ {issue.message} +

- + {config.label} {issue.category && ( - + {issue.category} )} - - {getEntityDisplayName(issue.entityId)} + + {entityLabelForId?.(issue.entityId) ?? "Unknown"} {issue.field && ( - + {issue.field} )} @@ -185,6 +208,7 @@ export function ValidationPanel({ onIssueClick, onIssueClear, onEntityClear: _onEntityClear, + entityLabelForId, className, }: ValidationPanelProps) { const [severityFilter, setSeverityFilter] = useState< @@ -193,21 +217,23 @@ export function ValidationPanel({ const [categoryFilter, setCategoryFilter] = useState< "all" | "structural" | "parameter" | "semantic" | "execution" >("all"); + const [search, setSearch] = useState(""); // Flatten and filter issues const flatIssues = useMemo(() => { const flat = flattenIssues(issues); - + const q = search.trim().toLowerCase(); return flat.filter((issue) => { - if (severityFilter !== "all" && issue.severity !== severityFilter) { + if (severityFilter !== "all" && issue.severity !== severityFilter) return false; - } - if (categoryFilter !== "all" && issue.category !== categoryFilter) { + if (categoryFilter !== "all" && issue.category !== categoryFilter) return false; - } - return true; + if (!q) 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 const counts = useMemo(() => { @@ -220,6 +246,12 @@ export function ValidationPanel({ }; }, [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 const availableCategories = useMemo(() => { const flat = flattenIssues(issues); @@ -230,160 +262,127 @@ export function ValidationPanel({ }, [issues]); return ( - - - -
- - Validation Issues -
-
- {counts.error > 0 && ( - - {counts.error} - - )} - {counts.warning > 0 && ( - - {counts.warning} - - )} - {counts.info > 0 && ( - - {counts.info} - - )} -
-
-
+
+ {/* Header (emulate ActionLibraryPanel) */} +
+
+ + setSearch(e.target.value)} + placeholder="Search issues" + className="h-8 w-full pl-7 text-xs" + aria-label="Search issues" + /> +
- - {/* Filters */} - {counts.total > 0 && ( - <> -
-
- {/* Severity Filter */} -
- - - {counts.error > 0 && ( - - )} - {counts.warning > 0 && ( - - )} - {counts.info > 0 && ( - - )} -
+
+ + + + +
+
- {/* Category Filter */} - {availableCategories.length > 0 && ( - <> - -
- - {availableCategories.map((category) => ( - - ))} -
- - )} + {/* Issues List */} + +
+ {counts.total === 0 ? ( +
+
+
+

+ All clear — no issues +

+

+ Validate again after changes. +

- - )} - - {/* Issues List */} - -
- {counts.total === 0 ? ( -
-
- -
-

- No validation issues -

-

- Your experiment design looks good! -

+ ) : flatIssues.length === 0 ? ( +
+
+
- ) : flatIssues.length === 0 ? ( -
-
- -
-

No issues match filters

-

- Try adjusting your filter criteria -

-
- ) : ( -
- {flatIssues.map((issue) => ( - - ))} -
- )} -
- - - +

No issues match filters

+

+ Adjust your filters +

+
+ ) : ( + flatIssues.map((issue) => ( + + )) + )} +
+
+
); } diff --git a/src/components/experiments/designer/flow/FlowWorkspace.tsx b/src/components/experiments/designer/flow/FlowWorkspace.tsx new file mode 100644 index 0000000..f0d3b18 --- /dev/null +++ b/src/components/experiments/designer/flow/FlowWorkspace.tsx @@ -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- + * - Sortable Action: s-act- + * - Droppable Step: step- (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 ( +
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* 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 ( +
+
+
+ +
+ + + {action.name} + + +
+ {def?.description && ( +
+ {def.description} +
+ )} + {def?.parameters.length ? ( +
+ {def.parameters.slice(0, 4).map((p) => ( + + {p.name} + + ))} + {def.parameters.length > 4 && ( + + +{def.parameters.length - 4} more + + )} +
+ ) : null} +
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* 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(null); + const measureRefs = useRef>(new Map()); + const roRef = useRef(null); + const [heights, setHeights] = useState>(new Map()); + const [scrollTop, setScrollTop] = useState(0); + const [viewportHeight, setViewportHeight] = useState(600); + const [containerWidth, setContainerWidth] = useState(0); + const [renamingStepId, setRenamingStepId] = useState(null); + const [isDraggingLibraryAction, setIsDraggingLibraryAction] = useState(false); + // dragKind state removed (unused after refactor) + + /* Parent lookup for action reorder */ + const actionParentMap = useMemo(() => { + const map = new Map(); + 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 ( +
+
+ +
+
{ + // 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} + > +
+ + + {step.order + 1} + + {renamingStepId === step.id ? ( + 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(); + }} + /> + ) : ( +
+ {step.name} + +
+ )} + + {step.actions.length} actions + +
+
+ +
+ +
+
+
+ + {step.expanded && ( +
+
+ {step.actions.length > 0 && ( + sortableActionId(a.id))} + strategy={verticalListSortingStrategy} + > +
+ {step.actions.map((action) => ( + { + selectStep(step.id); + selectAction(step.id, action.id); + }} + onDelete={() => deleteAction(step.id, action.id)} + /> + ))} +
+
+ )} +
+ {/* Persistent centered bottom drop hint */} +
+
+ Drop actions here +
+
+
+ )} +
+
+
+ ); + } + + /* ------------------------------------------------------------------------ */ + /* Render */ + /* ------------------------------------------------------------------------ */ + return ( +
+
+
+ + + Flow + + + {steps.length} steps •{" "} + {steps.reduce((s, st) => s + st.actions.length, 0)} actions + +
+
+ +
+
+ +
+ {steps.length === 0 ? ( +
+
+
+ +
+

No steps yet

+

+ Create your first step to begin designing the flow. +

+ +
+
+ ) : ( + sortableStepId(s.id))} + strategy={verticalListSortingStrategy} + > +
+ {virtualItems.map( + (vi) => vi.visible && , + )} +
+
+ )} +
+
+ ); +} + +export default FlowWorkspace; diff --git a/src/components/experiments/designer/layout/BottomStatusBar.tsx b/src/components/experiments/designer/layout/BottomStatusBar.tsx index 86bd199..1d517d4 100644 --- a/src/components/experiments/designer/layout/BottomStatusBar.tsx +++ b/src/components/experiments/designer/layout/BottomStatusBar.tsx @@ -131,7 +131,7 @@ export function BottomStatusBar({ title="Validated (hash stable)" > - Validated + Validated ); case "drift": @@ -142,14 +142,14 @@ export function BottomStatusBar({ title="Drift since last validation" > - Drift + Drift ); default: return ( - Unvalidated + Unvalidated ); } @@ -162,7 +162,8 @@ export function BottomStatusBar({ className="border-orange-300 text-orange-600 dark:text-orange-400" title="Unsaved changes" > - ● Unsaved + + Unsaved ) : null; @@ -208,7 +209,7 @@ export function BottomStatusBar({ return (
{/* Left Cluster: Validation & Hash */} -
+
{validationBadge} {unsavedBadge} {savingIndicator} @@ -225,7 +226,7 @@ export function BottomStatusBar({ className="flex items-center gap-1 font-mono text-[11px]" title="Current design hash" > - + {shortHash} {lastPersistedShort && lastPersistedShort !== shortHash && ( {/* Middle Cluster: Aggregate Counts */} -
+
- {steps.length} steps + {steps.length} + steps
- {actionCount} actions + {actionCount} + actions
Saved {relSaved} @@ -289,9 +292,10 @@ export function BottomStatusBar({ disabled={!hasUnsaved && !pendingSave} onClick={handleSave} aria-label="Save (s)" + title="Save (s)" > - Save + Save
diff --git a/src/components/experiments/designer/layout/PanelsContainer.tsx b/src/components/experiments/designer/layout/PanelsContainer.tsx index 0364dd0..84e408e 100644 --- a/src/components/experiments/designer/layout/PanelsContainer.tsx +++ b/src/components/experiments/designer/layout/PanelsContainer.tsx @@ -115,17 +115,17 @@ export function PanelsContainer({ if (!raw) return; const parsed = JSON.parse(raw) as PersistedLayout; 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") { setLeftCollapsed(parsed.leftCollapsed); } - if (typeof parsed.rightCollapsed === "boolean") { - setRightCollapsed(parsed.rightCollapsed); - } + // Always start with right panel visible to avoid hidden inspector state + setRightCollapsed(false); } catch { /* noop */ } - }, [disablePersistence]); + }, [disablePersistence, minRightWidth]); const persist = useCallback( (next?: Partial) => { @@ -172,7 +172,7 @@ export function PanelsContainer({ next = Math.max(minRightWidth, Math.min(maxRightWidth, next)); if (next !== rightWidth) { 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("pointerup", endDrag); }, - [leftWidth, rightWidth, leftCollapsed, rightCollapsed, onPointerMove, endDrag], + [ + leftWidth, + rightWidth, + leftCollapsed, + rightCollapsed, + onPointerMove, + endDrag, + ], ); /* ------------------------------------------------------------------------ */ @@ -275,7 +282,7 @@ export function PanelsContainer({ return (
{!leftCollapsed && ( @@ -303,30 +312,15 @@ export function PanelsContainer({ {hasLeft && !leftCollapsed && ( + 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" + /> )} - {/* Collapse / Expand Toggle (Left) */} - {hasLeft && ( - - )} + {/* Left collapse toggle removed to prevent breadcrumb overlap */} {/* Center (Workspace) */}
@@ -337,49 +331,50 @@ export function PanelsContainer({ {hasRight && !rightCollapsed && ( + 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" + /> )} {/* Right Panel */} {hasRight && (
{!rightCollapsed && ( -
{right}
+
{right}
)}
)} - {/* Collapse / Expand Toggle (Right) */} + {/* Minimal Right Toggle (top-right), non-intrusive like VSCode */} {hasRight && ( )}
diff --git a/src/components/experiments/designer/panels/ActionLibraryPanel.tsx b/src/components/experiments/designer/panels/ActionLibraryPanel.tsx index acf352e..92a6bb6 100644 --- a/src/components/experiments/designer/panels/ActionLibraryPanel.tsx +++ b/src/components/experiments/designer/panels/ActionLibraryPanel.tsx @@ -1,20 +1,4 @@ "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
className -2. The star/favorite button block -3. The description
-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, { useCallback, @@ -48,20 +32,6 @@ import { cn } from "~/lib/utils"; import { useActionRegistry } from "../ActionRegistry"; 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"]; interface FavoritesState { @@ -109,22 +79,16 @@ function DraggableAction({ onToggleFavorite, highlight, }: DraggableActionProps) { - const { attributes, listeners, setNodeRef, transform, isDragging } = - useDraggable({ - id: `action-${action.id}`, - data: { action }, - }); + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: `action-${action.id}`, + data: { action }, + }); - const style: React.CSSProperties = transform - ? { - transform: `translate3d(${transform.x}px, ${transform.y}px,0)`, - } - : {}; + // 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). + const style: React.CSSProperties = {}; - const IconComponent = - iconMap[action.icon] ?? - // fallback icon (Sparkles) - Sparkles; + const IconComponent = iconMap[action.icon] ?? Sparkles; const categoryColors: Record = { wizard: "bg-blue-500", @@ -140,12 +104,12 @@ function DraggableAction({ {...listeners} style={style} 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", - compact ? "py-2 text-[11px]" : "py-3 text-[12px]", - isDragging && "opacity-50", + "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-1.5 text-[11px]" : "py-2 text-[12px]", + isDragging && "ring-border opacity-60 ring-1", )} draggable={false} - title={action.description ?? ""} + onDragStart={(e) => e.preventDefault()} > -
+ +
- +
@@ -187,7 +152,7 @@ function DraggableAction({
{action.description && !compact && ( -
+
{highlight ? highlightMatch(action.description, highlight) : action.description} @@ -199,10 +164,6 @@ function DraggableAction({ ); } -/* -------------------------------------------------------------------------- */ -/* Panel Component */ -/* -------------------------------------------------------------------------- */ - export function ActionLibraryPanel() { const registry = useActionRegistry(); @@ -220,7 +181,6 @@ export function ActionLibraryPanel() { const allActions = registry.getAllActions(); - /* ------------------------------- Favorites -------------------------------- */ useEffect(() => { try { const raw = localStorage.getItem(FAVORITES_STORAGE_KEY); @@ -259,7 +219,6 @@ export function ActionLibraryPanel() { [persistFavorites], ); - /* ----------------------------- Category List ------------------------------ */ const categories = useMemo( () => [ @@ -281,21 +240,48 @@ export function ActionLibraryPanel() { [], ); - const toggleCategory = useCallback((c: ActionCategory) => { - setSelectedCategories((prev) => { - const next = new Set(prev); - if (next.has(c)) { - next.delete(c); - } else { - next.add(c); - } - if (next.size === 0) { - // Keep at least one category selected - next.add(c); - } - return next; - }); - }, []); + /** + * Enforce invariant: + * - Either ALL categories selected + * - Or EXACTLY ONE selected + * + * Behaviors: + * - From ALL -> clicking a category selects ONLY that category + * - From single selected -> clicking same category returns to ALL + * - From single selected -> clicking different category switches to that single + * - Any multi-subset attempt collapses to the clicked category (prevents ambiguous subset) + */ + const toggleCategory = useCallback( + (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([c]); + } + + // Case: single selection + if (isSingle) { + // Clicking the same => expand to all + if (has) { + return new Set(allKeys); + } + // Clicking different => switch single + return new Set([c]); + } + + // (Should not normally reach: ambiguous multi-subset) + // Collapse to single clicked to restore invariant + return new Set([c]); + }); + }, + [categories], + ); const clearFilters = useCallback(() => { setSelectedCategories(new Set(categories.map((c) => c.key))); @@ -304,11 +290,9 @@ export function ActionLibraryPanel() { }, [categories]); useEffect(() => { - // On mount select all categories for richer initial view setSelectedCategories(new Set(categories.map((c) => c.key))); }, []); // eslint-disable-line react-hooks/exhaustive-deps - /* ------------------------------- Filtering -------------------------------- */ const filtered = useMemo(() => { const activeCats = selectedCategories; const q = search.trim().toLowerCase(); @@ -338,9 +322,7 @@ export function ActionLibraryPanel() { control: 0, observation: 0, }; - for (const a of allActions) { - map[a.category] += 1; - } + for (const a of allActions) map[a.category] += 1; return map; }, [allActions]); @@ -348,26 +330,51 @@ export function ActionLibraryPanel() { filtered.some((a) => a.id === id), ).length; - /* ------------------------------- Rendering -------------------------------- */ return ( -
- {/* Toolbar */} +
-
-
- - setSearch(e.target.value)} - placeholder="Search actions" - className="h-8 pl-7 text-xs" - aria-label="Search actions" - /> -
+
+ + setSearch(e.target.value)} + placeholder="Search" + className="h-8 w-full pl-7 text-xs" + aria-label="Search actions" + /> +
+ +
+ {categories.map((cat) => { + const active = selectedCategories.has(cat.key); + const Icon = cat.icon; + return ( + + ); + })} +
+ +
- {/* Category Filters */} -
- {categories.map((cat) => { - const active = selectedCategories.has(cat.key); - const Icon = cat.icon; - return ( - - ); - })} -
-
- {filtered.length} shown / {allActions.length} total + {filtered.length} / {allActions.length}
- Plugins: {registry.getDebugInfo().pluginActionsLoaded ? "✓" : "…"} + {registry.getDebugInfo().pluginActionsLoaded + ? "Plugins ✓" + : "Plugins …"}
- {/* Actions List */} - -
+ +
{filtered.length === 0 ? (
-
No actions match filters
+
No actions
) : ( filtered.map((action) => ( @@ -472,7 +454,6 @@ export function ActionLibraryPanel() {
- {/* Footer Summary */}
@@ -481,7 +462,7 @@ export function ActionLibraryPanel() { {showOnlyFavorites && ( - {visibleFavoritesCount} favorites + {visibleFavoritesCount} fav )}
@@ -491,9 +472,8 @@ export function ActionLibraryPanel() {
-

- Drag actions into the flow. Use search / category filters to narrow - results. Star actions you use frequently. +

+ Drag actions into the flow. Star frequent actions.

diff --git a/src/components/experiments/designer/panels/InspectorPanel.tsx b/src/components/experiments/designer/panels/InspectorPanel.tsx index 8b906c4..eb96d8f 100644 --- a/src/components/experiments/designer/panels/InspectorPanel.tsx +++ b/src/components/experiments/designer/panels/InspectorPanel.tsx @@ -89,9 +89,21 @@ export function InspectorPanel({ /* ------------------------------------------------------------------------ */ /* Local Active Tab State (uncontrolled mode) */ /* ------------------------------------------------------------------------ */ + const INSPECTOR_TAB_STORAGE_KEY = "hristudio-designer-inspector-tab-v1"; const [internalTab, setInternalTab] = useState< "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"; return "issues"; }); @@ -103,6 +115,25 @@ export function InspectorPanel({ if (!autoFocusOnSelection) return; if (selectedStepId || selectedActionId) { 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]); @@ -113,6 +144,11 @@ export function InspectorPanel({ onTabChange?.(val); } else { setInternalTab(val); + try { + localStorage.setItem(INSPECTOR_TAB_STORAGE_KEY, val); + } catch { + /* noop */ + } } } }, @@ -164,9 +200,12 @@ export function InspectorPanel({ return (
{/* Tab Header */}
@@ -175,41 +214,41 @@ export function InspectorPanel({ onValueChange={handleTabChange} className="w-full" > - + - + Props - + Issues{issueCount > 0 ? ` (${issueCount})` : ""} {issueCount > 0 && ( - + {issueCount} )} - + Deps{driftCount > 0 ? ` (${driftCount})` : ""} {driftCount > 0 && ( - + {driftCount} )} @@ -220,11 +259,15 @@ export function InspectorPanel({ {/* Content */}
+ {/* + Force consistent width for tab bodies to prevent reflow when + switching between content with different intrinsic widths. + */} {/* Properties */} {propertiesEmpty ? (
@@ -240,7 +283,7 @@ export function InspectorPanel({
) : ( -
+
- -
- { - if (issue.stepId) { - selectStep(issue.stepId); - if (issue.actionId) { - selectAction(issue.stepId, issue.actionId); - if (autoFocusOnSelection) { - handleTabChange("properties"); - } - } - } - }} - /> -
-
+ { + if (entityId.startsWith("action-")) { + for (const s of steps) { + const a = s.actions.find((x) => x.id === entityId); + if (a) return `${a.name} • ${s.name}`; + } + } + if (entityId.startsWith("step-")) { + const st = steps.find((s) => s.id === entityId); + if (st) return st.name; + } + return "Unknown"; + }} + onIssueClick={(issue) => { + if (issue.stepId) { + selectStep(issue.stepId); + if (issue.actionId) { + selectAction(issue.stepId, issue.actionId); + } else { + selectAction(issue.stepId, undefined); + } + if (autoFocusOnSelection) { + handleTabChange("properties"); + } + } + }} + /> {/* Dependencies */} -
+
{ + step.actions.forEach((action) => { const actionId = action.id; // Action name validation @@ -423,7 +422,10 @@ export function validateParameters( field, stepId, 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; @@ -472,7 +474,7 @@ export function validateParameters( export function validateSemantic( steps: ExperimentStep[], - context: ValidationContext, + _context: ValidationContext, ): ValidationIssue[] { const issues: ValidationIssue[] = []; @@ -629,7 +631,7 @@ export function validateSemantic( export function validateExecution( steps: ExperimentStep[], - context: ValidationContext, + _context: ValidationContext, ): ValidationIssue[] { const issues: ValidationIssue[] = []; @@ -720,7 +722,7 @@ export function groupIssuesByEntity( const grouped: Record = {}; issues.forEach((issue) => { - const entityId = issue.actionId || issue.stepId || "experiment"; + const entityId = issue.actionId ?? issue.stepId ?? "experiment"; if (!grouped[entityId]) { grouped[entityId] = []; } diff --git a/src/lib/experiment-designer/block-converter.ts b/src/lib/experiment-designer/block-converter.ts index ba8c233..ee6c9ad 100644 --- a/src/lib/experiment-designer/block-converter.ts +++ b/src/lib/experiment-designer/block-converter.ts @@ -28,7 +28,7 @@ function mapStepTypeToDatabase( ): "wizard" | "robot" | "parallel" | "conditional" { switch (stepType) { case "sequential": - return "wizard"; // Default to wizard for sequential + return "wizard"; case "parallel": return "parallel"; case "conditional": diff --git a/tsconfig.json b/tsconfig.json index 1e97358..6136f2b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,6 +32,7 @@ }, "include": [ + // FlowWorkspace (flow/FlowWorkspace.tsx) and new designer modules are included via recursive globs "next-env.d.ts", "**/*.ts", "**/*.tsx",