Break work

This commit is contained in:
2026-01-20 09:38:07 -05:00
parent d83c02759a
commit 4fbd3be324
36 changed files with 3117 additions and 2770 deletions

View File

@@ -17,3 +17,10 @@ AUTH_SECRET=""
# Drizzle # Drizzle
DATABASE_URL="postgresql://postgres:password@localhost:5433/hristudio" DATABASE_URL="postgresql://postgres:password@localhost:5433/hristudio"
# MinIO/S3 Configuration
MINIO_ENDPOINT="http://localhost:9000"
MINIO_REGION="us-east-1"
MINIO_ACCESS_KEY="minioadmin"
MINIO_SECRET_KEY="minioadmin"
MINIO_BUCKET_NAME="hristudio-data"

View File

@@ -1,5 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "hristudio", "name": "hristudio",
@@ -13,6 +14,7 @@
"@hookform/resolvers": "^5.1.1", "@hookform/resolvers": "^5.1.1",
"@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.11",
@@ -51,6 +53,7 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.60.0", "react-hook-form": "^7.60.0",
"react-resizable-panels": "^3.0.4", "react-resizable-panels": "^3.0.4",
"react-webcam": "^7.2.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"superjson": "^2.2.1", "superjson": "^2.2.1",
@@ -382,6 +385,8 @@
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5nZrJTF7gH+e0nZS7/QxFz6tJV4VimhQb1avEgtsJxvvIp5JilL+c58HICsKzPxghdwaDt48hEfPM1au4zGy+w=="],
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="], "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA=="], "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA=="],
@@ -1270,6 +1275,8 @@
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"react-webcam": ["react-webcam@7.2.0", "", { "peerDependencies": { "react": ">=16.2.0", "react-dom": ">=16.2.0" } }, "sha512-xkrzYPqa1ag2DP+2Q/kLKBmCIfEx49bVdgCCCcZf88oF+0NPEbkwYk3/s/C7Zy0mhM8k+hpdNkBLzxg8H0aWcg=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
@@ -1492,6 +1499,8 @@
"@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="], "@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="],
"@radix-ui/react-aspect-ratio/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-menu/@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="], "@radix-ui/react-menu/@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="],
"@radix-ui/react-roving-focus/@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], "@radix-ui/react-roving-focus/@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
@@ -1606,6 +1615,8 @@
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@radix-ui/react-aspect-ratio/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="],
"@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],

View File

@@ -32,6 +32,7 @@
"@hookform/resolvers": "^5.1.1", "@hookform/resolvers": "^5.1.1",
"@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.11",
@@ -70,6 +71,7 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.60.0", "react-hook-form": "^7.60.0",
"react-resizable-panels": "^3.0.4", "react-resizable-panels": "^3.0.4",
"react-webcam": "^7.2.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"superjson": "^2.2.1", "superjson": "^2.2.1",

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,25 @@
"use client"; "use client";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Suspense, useEffect } from "react"; import { Suspense, useEffect, useState } from "react";
import { import {
Activity,
BarChart3, BarChart3,
Calendar, Search,
Download,
Filter, Filter,
TrendingDown, PlayCircle,
TrendingUp, Calendar,
Clock,
ChevronRight,
User,
LayoutGrid
} from "lucide-react"; } from "lucide-react";
import { Button } from "~/components/ui/button"; import { PageHeader } from "~/components/ui/page-header";
import { import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
Card, import { useStudyContext } from "~/lib/study-context";
CardContent, import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
CardDescription, import { api } from "~/trpc/react";
CardHeader, import { TrialAnalysisView } from "~/components/trials/views/TrialAnalysisView";
CardTitle,
} from "~/components/ui/card";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -27,283 +27,180 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "~/components/ui/select"; } from "~/components/ui/select";
import { PageHeader } from "~/components/ui/page-header"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { Button } from "~/components/ui/button";
import { useStudyContext } from "~/lib/study-context"; import { ScrollArea } from "~/components/ui/scroll-area";
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails"; import { formatDistanceToNow } from "date-fns";
// Mock chart component - replace with actual charting library // -- Sub-Components --
function MockChart({ title, data }: { title: string; data: number[] }) {
const maxValue = Math.max(...data); function AnalyticsContent({
selectedTrialId,
setSelectedTrialId,
trialsList,
isLoadingList
}: {
selectedTrialId: string | null;
setSelectedTrialId: (id: string | null) => void;
trialsList: any[];
isLoadingList: boolean;
}) {
// Fetch full details of selected trial
const {
data: selectedTrial,
isLoading: isLoadingTrial,
error: trialError
} = api.trials.get.useQuery(
{ id: selectedTrialId! },
{ enabled: !!selectedTrialId }
);
// Transform trial data
const trialData = selectedTrial ? {
...selectedTrial,
startedAt: selectedTrial.startedAt ? new Date(selectedTrial.startedAt) : null,
completedAt: selectedTrial.completedAt ? new Date(selectedTrial.completedAt) : null,
eventCount: (selectedTrial as any).eventCount,
mediaCount: (selectedTrial as any).mediaCount,
} : null;
return ( return (
<div className="space-y-2"> <div className="h-[calc(100vh-140px)] flex flex-col">
<h4 className="text-sm font-medium">{title}</h4> {selectedTrialId ? (
<div className="flex h-32 items-end space-x-1"> isLoadingTrial ? (
{data.map((value, index) => ( <div className="flex-1 flex items-center justify-center bg-background/50 rounded-lg border border-dashed">
<div <div className="flex flex-col items-center gap-2 animate-pulse">
key={index} <div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
className="bg-primary min-h-[4px] flex-1 rounded-t" <span className="text-muted-foreground text-sm">Loading trial data...</span>
style={{ height: `${(value / maxValue) * 100}%` }} </div>
</div>
) : trialError ? (
<div className="flex-1 flex items-center justify-center p-8 bg-background/50 rounded-lg border border-dashed text-destructive">
<div className="max-w-md text-center">
<h3 className="font-semibold mb-2">Error Loading Trial</h3>
<p className="text-sm opacity-80">{trialError.message}</p>
<Button variant="outline" className="mt-4" onClick={() => setSelectedTrialId(null)}>
Return to Overview
</Button>
</div>
</div>
) : trialData ? (
<TrialAnalysisView trial={trialData} />
) : null
) : (
<div className="flex-1 bg-background/50 rounded-lg border shadow-sm overflow-hidden">
<StudyOverviewPlaceholder
trials={trialsList ?? []}
onSelect={(id) => setSelectedTrialId(id)}
/> />
))} </div>
</div> )}
</div> </div>
); );
} }
function AnalyticsOverview() { function StudyOverviewPlaceholder({ trials, onSelect }: { trials: any[], onSelect: (id: string) => void }) {
const metrics = [ const recentTrials = [...trials].sort((a, b) =>
{ new Date(b.startedAt || b.createdAt).getTime() - new Date(a.startedAt || a.createdAt).getTime()
title: "Total Trials This Month", ).slice(0, 5);
value: "142",
change: "+12%",
trend: "up",
description: "vs last month",
icon: Activity,
},
{
title: "Avg Trial Duration",
value: "24.5m",
change: "-3%",
trend: "down",
description: "vs last month",
icon: Calendar,
},
{
title: "Completion Rate",
value: "94.2%",
change: "+2.1%",
trend: "up",
description: "vs last month",
icon: TrendingUp,
},
{
title: "Participant Retention",
value: "87.3%",
change: "+5.4%",
trend: "up",
description: "vs last month",
icon: BarChart3,
},
];
return ( return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="h-full p-8 grid place-items-center bg-muted/5">
{metrics.map((metric) => ( <div className="max-w-3xl w-full grid gap-8 md:grid-cols-2">
<Card key={metric.title}> {/* Left: Illustration / Prompt */}
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <div className="flex flex-col justify-center space-y-4">
<CardTitle className="text-sm font-medium"> <div className="bg-primary/10 w-16 h-16 rounded-2xl flex items-center justify-center mb-2">
{metric.title} <BarChart3 className="h-8 w-8 text-primary" />
</CardTitle> </div>
<metric.icon className="text-muted-foreground h-4 w-4" /> <div>
</CardHeader> <h2 className="text-2xl font-semibold tracking-tight">Analytics & Playback</h2>
<CardContent> <CardDescription className="text-base mt-2">
<div className="text-2xl font-bold">{metric.value}</div> Select a session from the top right to review video recordings, event logs, and metrics.
<div className="text-muted-foreground flex items-center space-x-2 text-xs"> </CardDescription>
<span </div>
className={`flex items-center ${ <div className="flex gap-4 pt-4">
metric.trend === "up" ? "text-green-600" : "text-red-600" <div className="flex items-center gap-2 text-sm text-muted-foreground">
}`} <PlayCircle className="h-4 w-4" />
> Feature-rich playback
{metric.trend === "up" ? (
<TrendingUp className="mr-1 h-3 w-3" />
) : (
<TrendingDown className="mr-1 h-3 w-3" />
)}
{metric.change}
</span>
<span>{metric.description}</span>
</div> </div>
</CardContent> <div className="flex items-center gap-2 text-sm text-muted-foreground">
</Card> <Clock className="h-4 w-4" />
))} Synchronized timeline
</div>
);
}
function ChartsSection() {
const trialData = [12, 19, 15, 27, 32, 28, 35, 42, 38, 41, 37, 44];
const participantData = [8, 12, 10, 15, 18, 16, 20, 24, 22, 26, 23, 28];
const completionData = [85, 88, 92, 89, 94, 91, 95, 92, 96, 94, 97, 94];
return (
<div className="grid gap-4 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>Trial Volume</CardTitle>
<CardDescription>Monthly trial execution trends</CardDescription>
</CardHeader>
<CardContent>
<MockChart title="Trials per Month" data={trialData} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Participant Enrollment</CardTitle>
<CardDescription>New participants over time</CardDescription>
</CardHeader>
<CardContent>
<MockChart title="New Participants" data={participantData} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Completion Rates</CardTitle>
<CardDescription>Trial completion percentage</CardDescription>
</CardHeader>
<CardContent>
<MockChart title="Completion %" data={completionData} />
</CardContent>
</Card>
</div>
);
}
function RecentInsights() {
const insights = [
{
title: "Peak Performance Hours",
description:
"Participants show 23% better performance during 10-11 AM trials",
type: "trend",
severity: "info",
},
{
title: "Attention Span Decline",
description:
"Average attention span has decreased by 8% over the last month",
type: "alert",
severity: "warning",
},
{
title: "High Completion Rate",
description: "Memory retention study achieved 98% completion rate",
type: "success",
severity: "success",
},
{
title: "Equipment Utilization",
description: "Robot interaction trials are at 85% capacity utilization",
type: "info",
severity: "info",
},
];
const getSeverityColor = (severity: string) => {
switch (severity) {
case "success":
return "bg-green-50 text-green-700 border-green-200";
case "warning":
return "bg-yellow-50 text-yellow-700 border-yellow-200";
case "info":
return "bg-blue-50 text-blue-700 border-blue-200";
default:
return "bg-gray-50 text-gray-700 border-gray-200";
}
};
return (
<Card>
<CardHeader>
<CardTitle>Recent Insights</CardTitle>
<CardDescription>
AI-generated insights from your research data
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{insights.map((insight, index) => (
<div
key={index}
className={`rounded-lg border p-4 ${getSeverityColor(insight.severity)}`}
>
<h4 className="mb-1 font-medium">{insight.title}</h4>
<p className="text-sm">{insight.description}</p>
</div> </div>
))} </div>
</div> </div>
</CardContent>
</Card>
);
}
function AnalyticsContent({ studyId: _studyId }: { studyId: string }) { {/* Right: Recent Sessions */}
return (
<div className="space-y-6">
{/* Header with time range controls */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Select defaultValue="30d">
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Time range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7d">Last 7 days</SelectItem>
<SelectItem value="30d">Last 30 days</SelectItem>
<SelectItem value="90d">Last 90 days</SelectItem>
<SelectItem value="1y">Last year</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm">
<Filter className="mr-2 h-4 w-4" />
Filter
</Button>
<Button variant="outline" size="sm">
<Download className="mr-2 h-4 w-4" />
Export
</Button>
</div>
</div>
{/* Overview Metrics */}
<AnalyticsOverview />
{/* Charts */}
<ChartsSection />
{/* Insights */}
<div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-2">
<RecentInsights />
</div>
<Card> <Card>
<CardHeader> <CardHeader className="pb-3">
<CardTitle>Quick Actions</CardTitle> <CardTitle className="text-base">Recent Sessions</CardTitle>
<CardDescription>Generate custom reports</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="p-0">
<Button variant="outline" className="w-full justify-start"> <ScrollArea className="h-[240px]">
<BarChart3 className="mr-2 h-4 w-4" /> <div className="px-4 pb-4 space-y-1">
Trial Performance Report {recentTrials.map(trial => (
</Button> <button
<Button variant="outline" className="w-full justify-start"> key={trial.id}
<Activity className="mr-2 h-4 w-4" /> onClick={() => onSelect(trial.id)}
Participant Engagement className="w-full flex items-center gap-3 p-3 rounded-md hover:bg-accent transition-colors text-left group"
</Button> >
<Button variant="outline" className="w-full justify-start"> <div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-mono font-medium text-primary">
<TrendingUp className="mr-2 h-4 w-4" /> {trial.sessionNumber}
Trend Analysis </div>
</Button> <div className="flex-1 min-w-0">
<Button variant="outline" className="w-full justify-start"> <div className="flex items-center gap-2">
<Download className="mr-2 h-4 w-4" /> <span className="font-medium text-sm truncate">
Custom Export {trial.participant?.participantCode ?? "Unknown"}
</Button> </span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full border capitalize ${trial.status === 'completed' ? 'bg-green-500/10 text-green-500 border-green-500/20' :
trial.status === 'in_progress' ? 'bg-blue-500/10 text-blue-500 border-blue-500/20' :
'bg-slate-500/10 text-slate-500 border-slate-500/20'
}`}>
{trial.status.replace('_', ' ')}
</span>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-2 mt-0.5">
<Calendar className="h-3 w-3" />
{new Date(trial.createdAt).toLocaleDateString()}
<span className="text-muted-foreground top-[1px] relative text-[10px]"></span>
{formatDistanceToNow(new Date(trial.createdAt), { addSuffix: true })}
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground/30 group-hover:text-primary transition-colors" />
</button>
))}
{recentTrials.length === 0 && (
<div className="py-8 text-center text-sm text-muted-foreground">
No sessions found.
</div>
)}
</div>
</ScrollArea>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </div>
); )
} }
// -- Main Page --
export default function StudyAnalyticsPage() { export default function StudyAnalyticsPage() {
const params = useParams(); const params = useParams();
const studyId: string = typeof params.id === "string" ? params.id : ""; const studyId: string = typeof params.id === "string" ? params.id : "";
const { setSelectedStudyId, selectedStudyId } = useStudyContext(); const { setSelectedStudyId, selectedStudyId } = useStudyContext();
const { study } = useSelectedStudyDetails(); const { study } = useSelectedStudyDetails();
// State lifted up
const [selectedTrialId, setSelectedTrialId] = useState<string | null>(null);
// Fetch list of trials for the selector
const { data: trialsList, isLoading: isLoadingList } = api.trials.list.useQuery(
{ studyId, limit: 100 },
{ enabled: !!studyId }
);
// Set breadcrumbs // Set breadcrumbs
useBreadcrumbsEffect([ useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" }, { label: "Dashboard", href: "/dashboard" },
@@ -320,16 +217,53 @@ export default function StudyAnalyticsPage() {
}, [studyId, selectedStudyId, setSelectedStudyId]); }, [studyId, selectedStudyId, setSelectedStudyId]);
return ( return (
<div className="space-y-6"> <div className="h-[calc(100vh-64px)] flex flex-col p-6 gap-6">
<PageHeader <div className="flex-none">
title="Analytics" <PageHeader
description="Insights and data analysis for this study" title="Analytics"
icon={BarChart3} description="Analyze trial data and replay sessions"
/> icon={BarChart3}
actions={
<div className="flex items-center gap-2">
{/* Session Selector in Header */}
<div className="w-[300px]">
<Select
value={selectedTrialId ?? "overview"}
onValueChange={(val) => setSelectedTrialId(val === "overview" ? null : val)}
>
<SelectTrigger className="w-full h-9 text-xs">
<LayoutGrid className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
<SelectValue placeholder="Select Session" />
</SelectTrigger>
<SelectContent className="max-h-[400px]" align="end">
<SelectItem value="overview" className="border-b mb-1 pb-1 font-medium text-xs">
Show Study Overview
</SelectItem>
{trialsList?.map((trial) => (
<SelectItem key={trial.id} value={trial.id} className="text-xs">
<span className="font-mono mr-2 text-muted-foreground">#{trial.sessionNumber}</span>
{trial.participant?.participantCode ?? "Unknown"} <span className="text-muted-foreground ml-1">({new Date(trial.createdAt).toLocaleDateString()})</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
}
/>
</div>
<Suspense fallback={<div>Loading analytics...</div>}> <div className="flex-1 min-h-0 bg-transparent">
<AnalyticsContent studyId={studyId} /> <Suspense fallback={<div>Loading analytics...</div>}>
</Suspense> <AnalyticsContent
selectedTrialId={selectedTrialId}
setSelectedTrialId={setSelectedTrialId}
trialsList={trialsList ?? []}
isLoadingList={isLoadingList}
studyId={studyId}
/>
</Suspense>
</div>
</div> </div>
); );
} }

View File

@@ -3,7 +3,7 @@
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Suspense, useEffect } from "react"; import { Suspense, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { Play, Zap, ArrowLeft, User, FlaskConical } from "lucide-react"; import { Play, Zap, ArrowLeft, User, FlaskConical, LineChart } from "lucide-react";
import { PageHeader } from "~/components/ui/page-header"; import { PageHeader } from "~/components/ui/page-header";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
@@ -150,10 +150,18 @@ function TrialDetailContent() {
)} )}
{(trial.status === "in_progress" || {(trial.status === "in_progress" ||
trial.status === "scheduled") && ( trial.status === "scheduled") && (
<Button asChild>
<Link href={`/studies/${studyId}/trials/${trialId}/wizard`}>
<Zap className="mr-2 h-4 w-4" />
Wizard Interface
</Link>
</Button>
)}
{trial.status === "completed" && (
<Button asChild> <Button asChild>
<Link href={`/studies/${studyId}/trials/${trialId}/wizard`}> <Link href={`/studies/${studyId}/trials/${trialId}/analysis`}>
<Zap className="mr-2 h-4 w-4" /> <LineChart className="mr-2 h-4 w-4" />
Wizard Interface View Analysis
</Link> </Link>
</Button> </Button>
)} )}

View File

@@ -1,7 +1,7 @@
import "~/styles/globals.css"; import "~/styles/globals.css";
import { type Metadata } from "next"; import { type Metadata } from "next";
import { Geist } from "next/font/google"; import { Inter } from "next/font/google";
import { SessionProvider } from "next-auth/react"; import { SessionProvider } from "next-auth/react";
import { TRPCReactProvider } from "~/trpc/react"; import { TRPCReactProvider } from "~/trpc/react";
@@ -13,16 +13,16 @@ export const metadata: Metadata = {
icons: [{ rel: "icon", url: "/favicon.ico" }], icons: [{ rel: "icon", url: "/favicon.ico" }],
}; };
const geist = Geist({ const inter = Inter({
subsets: ["latin"], subsets: ["latin"],
variable: "--font-geist-sans", variable: "--font-sans",
}); });
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ children: React.ReactNode }>) { }: Readonly<{ children: React.ReactNode }>) {
return ( return (
<html lang="en" className={`${geist.variable}`}> <html lang="en" className={`${inter.variable}`}>
<body> <body>
<SessionProvider> <SessionProvider>
<TRPCReactProvider>{children}</TRPCReactProvider> <TRPCReactProvider>{children}</TRPCReactProvider>

View File

@@ -78,6 +78,7 @@ export class ActionRegistry {
parameters?: CoreBlockParam[]; parameters?: CoreBlockParam[];
timeoutMs?: number; timeoutMs?: number;
retryable?: boolean; retryable?: boolean;
nestable?: boolean;
} }
try { try {
@@ -139,6 +140,7 @@ export class ActionRegistry {
parameterSchemaRaw: { parameterSchemaRaw: {
parameters: block.parameters ?? [], parameters: block.parameters ?? [],
}, },
nestable: block.nestable,
}; };
this.actions.set(actionDef.id, actionDef); this.actions.set(actionDef.id, actionDef);
@@ -180,31 +182,33 @@ export class ActionRegistry {
private loadFallbackActions(): void { private loadFallbackActions(): void {
const fallbackActions: ActionDefinition[] = [ const fallbackActions: ActionDefinition[] = [
{ {
id: "wizard_speak", id: "wizard_say",
type: "wizard_speak", type: "wizard_say",
name: "Wizard Says", name: "Wizard Says",
description: "Wizard speaks to participant", description: "Wizard speaks to participant",
category: "wizard", category: "wizard",
icon: "MessageSquare", icon: "MessageSquare",
color: "#3b82f6", color: "#a855f7",
parameters: [ parameters: [
{ {
id: "text", id: "message",
name: "Text to say", name: "Message",
type: "text", type: "text",
placeholder: "Hello, participant!", placeholder: "Hello, participant!",
required: true, required: true,
}, },
], {
source: { kind: "core", baseActionId: "wizard_speak" }, id: "tone",
execution: { transport: "internal", timeoutMs: 30000 }, name: "Tone",
parameterSchemaRaw: { type: "select",
type: "object", options: ["neutral", "friendly", "encouraging"],
properties: { value: "neutral",
text: { type: "string" },
}, },
required: ["text"], ],
}, source: { kind: "core", baseActionId: "wizard_say" },
execution: { transport: "internal", timeoutMs: 30000 },
parameterSchemaRaw: {},
nestable: false,
}, },
{ {
id: "wait", id: "wait",
@@ -366,34 +370,34 @@ export class ActionRegistry {
const execution = action.ros2 const execution = action.ros2
? { ? {
transport: "ros2" as const, transport: "ros2" as const,
timeoutMs: action.timeout, timeoutMs: action.timeout,
retryable: action.retryable, retryable: action.retryable,
ros2: { ros2: {
topic: action.ros2.topic, topic: action.ros2.topic,
messageType: action.ros2.messageType, messageType: action.ros2.messageType,
service: action.ros2.service, service: action.ros2.service,
action: action.ros2.action, action: action.ros2.action,
qos: action.ros2.qos, qos: action.ros2.qos,
payloadMapping: action.ros2.payloadMapping, payloadMapping: action.ros2.payloadMapping,
}, },
} }
: action.rest : action.rest
? { ? {
transport: "rest" as const, transport: "rest" as const,
timeoutMs: action.timeout, timeoutMs: action.timeout,
retryable: action.retryable, retryable: action.retryable,
rest: { rest: {
method: action.rest.method, method: action.rest.method,
path: action.rest.path, path: action.rest.path,
headers: action.rest.headers, headers: action.rest.headers,
}, },
} }
: { : {
transport: "internal" as const, transport: "internal" as const,
timeoutMs: action.timeout, timeoutMs: action.timeout,
retryable: action.retryable, retryable: action.retryable,
}; };
const actionDef: ActionDefinition = { const actionDef: ActionDefinition = {
id: `${plugin.robotId ?? plugin.id}.${action.id}`, id: `${plugin.robotId ?? plugin.id}.${action.id}`,

View File

@@ -26,8 +26,10 @@ import {
MouseSensor, MouseSensor,
TouchSensor, TouchSensor,
KeyboardSensor, KeyboardSensor,
closestCorners,
type DragEndEvent, type DragEndEvent,
type DragStartEvent, type DragStartEvent,
type DragOverEvent,
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { BottomStatusBar } from "./layout/BottomStatusBar"; import { BottomStatusBar } from "./layout/BottomStatusBar";
import { ActionLibraryPanel } from "./panels/ActionLibraryPanel"; import { ActionLibraryPanel } from "./panels/ActionLibraryPanel";
@@ -599,11 +601,8 @@ export function DesignerRoot({
// Serialize steps for stable comparison // Serialize steps for stable comparison
const stepsHash = useMemo(() => JSON.stringify(steps), [steps]); const stepsHash = useMemo(() => JSON.stringify(steps), [steps]);
useEffect(() => { // Intentionally removed redundant recomputeHash useEffect that was causing excessive refreshes
if (!initialized) return; // The debounced useEffect (lines 352-372) handles this correctly.
void recomputeHash();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stepsHash, initialized]);
useEffect(() => { useEffect(() => {
if (selectedStepId || selectedActionId) { if (selectedStepId || selectedActionId) {
@@ -628,18 +627,10 @@ export function DesignerRoot({
) { ) {
e.preventDefault(); e.preventDefault();
void persist(); void persist();
} else if (e.key === "v" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
void validateDesign();
} else if (e.key === "e" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
void handleExport();
} else if (e.key === "n" && e.shiftKey) {
e.preventDefault();
createNewStep();
} }
// 'v' (validate), 'e' (export), 'Shift+N' (new step) shortcuts removed to prevent accidents
}, },
[hasUnsavedChanges, persist, validateDesign, handleExport, createNewStep], [hasUnsavedChanges, persist],
); );
useEffect(() => { useEffect(() => {
@@ -687,43 +678,163 @@ export function DesignerRoot({
[toggleLibraryScrollLock], [toggleLibraryScrollLock],
); );
const handleDragEnd = useCallback( const handleDragOver = useCallback((event: DragOverEvent) => {
async (event: DragEndEvent) => { const { active, over } = event;
const { active, over } = event; const store = useDesignerStore.getState();
console.debug("[DesignerRoot] dragEnd", {
active: active?.id, // Only handle Library -> Flow projection
over: over?.id ?? null, if (!active.id.toString().startsWith("action-")) {
}); if (store.insertionProjection) {
// Clear overlay immediately store.setInsertionProjection(null);
toggleLibraryScrollLock(false); }
setDragOverlayAction(null); return;
if (!over) { }
console.debug("[DesignerRoot] dragEnd: no drop target (ignored)");
if (!over) {
if (store.insertionProjection) {
store.setInsertionProjection(null);
}
return;
}
const overId = over.id.toString();
const activeDef = active.data.current?.action;
if (!activeDef) return;
let stepId: string | null = null;
let parentId: string | null = null;
let index = 0;
// Detect target based on over id
if (overId.startsWith("s-act-")) {
const data = over.data.current;
if (data && data.stepId) {
stepId = data.stepId;
parentId = data.parentId ?? null; // Use parentId from the action we are hovering over
// Use sortable index (insertion point provided by dnd-kit sortable strategy)
index = data.sortable?.index ?? 0;
}
} else if (overId.startsWith("container-")) {
// Dropping into a container (e.g. Loop)
const data = over.data.current;
if (data && data.stepId) {
stepId = data.stepId;
parentId = data.parentId ?? overId.slice("container-".length);
// If dropping into container, appending is a safe default if specific index logic is missing
// But actually we can find length if we want. For now, 0 or append logic?
// If container is empty, index 0 is correct.
// If not empty, we are hitting the container *background*, so append?
// The projection logic will insert at 'index'. If index is past length, it appends.
// Let's set a large index to ensure append, or look up length.
// Lookup requires finding the action in store. Expensive?
// Let's assume index 0 for now (prepend) or implement lookup.
// Better: lookup action -> children length.
const actionId = parentId;
const step = store.steps.find(s => s.id === stepId);
// Find action recursive? Store has `findActionById` helper but it is not exported/accessible easily here?
// Actually, `store.steps` is available.
// We can implement a quick BFS/DFS or just assume 0.
// If dragging over the container *background* (empty space), append is usually expected.
// Let's try 9999?
index = 9999;
}
} else if (overId.startsWith("s-step-") || overId.startsWith("step-")) {
// Container drop (Step)
stepId = overId.startsWith("s-step-")
? overId.slice("s-step-".length)
: overId.slice("step-".length);
const step = store.steps.find((s) => s.id === stepId);
index = step ? step.actions.length : 0;
} else if (overId === "projection-placeholder") {
// Hovering over our own projection placeholder -> keep current state
return;
}
if (stepId) {
const current = store.insertionProjection;
// Optimization: avoid redundant updates if projection matches
if (
current &&
current.stepId === stepId &&
current.parentId === parentId &&
current.index === index
) {
return; return;
} }
// Expect dragged action (library) onto a step droppable store.setInsertionProjection({
const activeId = active.id.toString(); stepId,
const overId = over.id.toString(); parentId,
index,
action: {
id: "projection-placeholder",
type: activeDef.type,
name: activeDef.name,
category: activeDef.category,
description: "Drop here",
source: activeDef.source || { kind: "library" },
parameters: {},
execution: activeDef.execution,
} as any,
});
} else {
if (store.insertionProjection) store.setInsertionProjection(null);
}
}, []);
if (activeId.startsWith("action-") && active.data.current?.action) { const handleDragEnd = useCallback(
// Resolve stepId from possible over ids: step-<id>, s-step-<id>, or s-act-<actionId> async (event: DragEndEvent) => {
let stepId: string | null = null; const { active, over } = event;
// Clear overlay immediately
toggleLibraryScrollLock(false);
setDragOverlayAction(null);
// Capture and clear projection
const store = useDesignerStore.getState();
const projection = store.insertionProjection;
store.setInsertionProjection(null);
if (!over) {
return;
}
// 1. Determine Target (Step, Parent, Index)
let stepId: string | null = null;
let parentId: string | null = null;
let index: number | undefined = undefined;
if (projection) {
stepId = projection.stepId;
parentId = projection.parentId;
index = projection.index;
} else {
// Fallback: resolution from overId (if projection failed or raced)
const overId = over.id.toString();
if (overId.startsWith("step-")) { if (overId.startsWith("step-")) {
stepId = overId.slice("step-".length); stepId = overId.slice("step-".length);
} else if (overId.startsWith("s-step-")) { } else if (overId.startsWith("s-step-")) {
stepId = overId.slice("s-step-".length); stepId = overId.slice("s-step-".length);
} else if (overId.startsWith("s-act-")) { } else if (overId.startsWith("s-act-")) {
// This might fail if s-act-projection, but that should have covered by projection check above
const actionId = overId.slice("s-act-".length); const actionId = overId.slice("s-act-".length);
const parent = steps.find((s) => const parent = steps.find((s) =>
s.actions.some((a) => a.id === actionId), s.actions.some((a) => a.id === actionId),
); );
stepId = parent?.id ?? null; stepId = parent?.id ?? null;
} }
if (!stepId) return; }
if (!stepId) return;
const targetStep = steps.find((s) => s.id === stepId);
if (!targetStep) return;
// 2. Instantiate Action
if (active.id.toString().startsWith("action-") && active.data.current?.action) {
const actionDef = active.data.current.action as { const actionDef = active.data.current.action as {
id: string; id: string; // type
type: string; type: string;
name: string; name: string;
category: string; category: string;
@@ -733,14 +844,13 @@ export function DesignerRoot({
parameters: Array<{ id: string; name: string }>; parameters: Array<{ id: string; name: string }>;
}; };
const targetStep = steps.find((s) => s.id === stepId);
if (!targetStep) return;
const fullDef = actionRegistry.getAction(actionDef.type); const fullDef = actionRegistry.getAction(actionDef.type);
const defaultParams: Record<string, unknown> = {}; const defaultParams: Record<string, unknown> = {};
if (fullDef?.parameters) { if (fullDef?.parameters) {
for (const param of fullDef.parameters) { for (const param of fullDef.parameters) {
// @ts-expect-error - 'default' property access
if (param.default !== undefined) { if (param.default !== undefined) {
// @ts-expect-error - 'default' property access
defaultParams[param.id] = param.default; defaultParams[param.id] = param.default;
} }
} }
@@ -755,39 +865,61 @@ export function DesignerRoot({
transport: actionDef.execution.transport, transport: actionDef.execution.transport,
retryable: actionDef.execution.retryable ?? false, retryable: actionDef.execution.retryable ?? false,
} }
: { : undefined;
transport: "internal",
retryable: false,
};
const newAction: ExperimentAction = { const newAction: ExperimentAction = {
id: `action-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, id: crypto.randomUUID(),
type: actionDef.type, type: actionDef.type, // this is the 'type' key
name: actionDef.name, name: actionDef.name,
category: actionDef.category as ExperimentAction["category"], category: actionDef.category as any,
description: "",
parameters: defaultParams, parameters: defaultParams,
source: actionDef.source as ExperimentAction["source"], source: actionDef.source ? {
kind: actionDef.source.kind as any,
pluginId: actionDef.source.pluginId,
pluginVersion: actionDef.source.pluginVersion,
baseActionId: actionDef.id
} : { kind: "core" },
execution, execution,
children: [],
}; };
upsertAction(stepId, newAction); // 3. Commit
// Select the newly added action and open properties upsertAction(stepId, newAction, parentId, index);
selectStep(stepId);
// Auto-select
selectAction(stepId, newAction.id); selectAction(stepId, newAction.id);
setInspectorTab("properties");
await recomputeHash(); void recomputeHash();
toast.success(`Added ${actionDef.name} to ${targetStep.name}`);
} }
}, },
[ [steps, upsertAction, selectAction, recomputeHash, toggleLibraryScrollLock],
steps,
upsertAction,
recomputeHash,
selectStep,
selectAction,
toggleLibraryScrollLock,
],
); );
// validation status badges removed (unused) // validation status badges removed (unused)
/* ------------------------------- Panels ---------------------------------- */
const leftPanel = useMemo(
() => (
<div ref={libraryRootRef} data-library-root className="h-full">
<ActionLibraryPanel />
</div>
),
[],
);
const centerPanel = useMemo(() => <FlowWorkspace />, []);
const rightPanel = useMemo(
() => (
<div className="h-full">
<InspectorPanel
activeTab={inspectorTab}
onTabChange={setInspectorTab}
studyPlugins={studyPlugins}
/>
</div>
),
[inspectorTab, studyPlugins],
);
/* ------------------------------- Render ---------------------------------- */ /* ------------------------------- Render ---------------------------------- */
if (loadingExperiment && !initialized) { if (loadingExperiment && !initialized) {
@@ -852,33 +984,33 @@ export function DesignerRoot({
<div className="flex h-[calc(100vh-12rem)] w-full max-w-full flex-col overflow-hidden rounded-md border"> <div className="flex h-[calc(100vh-12rem)] w-full max-w-full flex-col overflow-hidden rounded-md border">
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={pointerWithin} collisionDetection={closestCorners}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
onDragCancel={() => toggleLibraryScrollLock(false)} onDragCancel={() => toggleLibraryScrollLock(false)}
> >
<PanelsContainer <PanelsContainer
showDividers showDividers
className="min-h-0 flex-1" className="min-h-0 flex-1"
left={ left={leftPanel}
<div ref={libraryRootRef} data-library-root className="h-full"> center={centerPanel}
<ActionLibraryPanel /> right={rightPanel}
</div>
}
center={<FlowWorkspace />}
right={
<div className="h-full">
<InspectorPanel
activeTab={inspectorTab}
onTabChange={setInspectorTab}
studyPlugins={studyPlugins}
/>
</div>
}
/> />
<DragOverlay> <DragOverlay>
{dragOverlayAction ? ( {dragOverlayAction ? (
<div className="bg-background pointer-events-none rounded border px-2 py-1 text-xs shadow-lg select-none"> <div className="bg-background flex items-center gap-2 rounded border px-3 py-2 text-xs font-medium shadow-lg select-none">
<span
className={cn(
"h-2.5 w-2.5 rounded-full",
{
wizard: "bg-blue-500",
robot: "bg-emerald-600",
control: "bg-amber-500",
observation: "bg-purple-600",
}[dragOverlayAction.category] || "bg-slate-400",
)}
/>
{dragOverlayAction.name} {dragOverlayAction.name}
</div> </div>
) : null} ) : null}

View File

@@ -282,205 +282,22 @@ export function PropertiesPanelBase({
Parameters Parameters
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{def.parameters.map((param) => { {def.parameters.map((param) => (
const rawValue = selectedAction.parameters[param.id]; <ParameterEditor
const commonLabel = ( key={param.id}
<Label className="flex items-center gap-2 text-xs"> param={param}
{param.name} value={selectedAction.parameters[param.id]}
<span className="text-muted-foreground font-normal"> onUpdate={(val) => {
{param.type === "number" &&
(param.min !== undefined || param.max !== undefined) &&
typeof rawValue === "number" &&
`( ${rawValue} )`}
</span>
</Label>
);
/* ---- Handlers ---- */
const updateParamValue = (value: unknown) => {
setLocalParams((prev) => ({ ...prev, [param.id]: value }));
debouncedParamUpdate(
containingStep.id,
selectedAction.id,
param.id,
value,
);
};
const updateParamValueImmediate = (value: unknown) => {
setLocalParams((prev) => ({ ...prev, [param.id]: value }));
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: {
...selectedAction.parameters,
[param.id]: value,
},
});
};
const updateParamLocal = (value: unknown) => {
setLocalParams((prev) => ({ ...prev, [param.id]: value }));
};
const commitParamValue = () => {
if (localParams[param.id] !== rawValue) {
onActionUpdate(containingStep.id, selectedAction.id, { onActionUpdate(containingStep.id, selectedAction.id, {
parameters: { parameters: {
...selectedAction.parameters, ...selectedAction.parameters,
[param.id]: localParams[param.id], [param.id]: val,
}, },
}); });
} }}
}; onCommit={() => { }}
/>
/* ---- Control Rendering ---- */ ))}
let control: React.ReactNode = null;
if (param.type === "text") {
const localValue = localParams[param.id] ?? rawValue ?? "";
control = (
<Input
value={localValue as string}
placeholder={param.placeholder}
onChange={(e) => updateParamValue(e.target.value)}
onBlur={() => {
if (localParams[param.id] !== rawValue) {
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: {
...selectedAction.parameters,
[param.id]: localParams[param.id],
},
});
}
}}
className="mt-1 h-7 w-full text-xs"
/>
);
} else if (param.type === "select") {
const localValue = localParams[param.id] ?? rawValue ?? "";
control = (
<Select
value={localValue as string}
onValueChange={(val) => updateParamValueImmediate(val)}
>
<SelectTrigger className="mt-1 h-7 w-full text-xs">
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent>
{param.options?.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
);
} else if (param.type === "boolean") {
const localValue = localParams[param.id] ?? rawValue ?? false;
control = (
<div className="mt-1 flex h-7 items-center">
<Switch
checked={Boolean(localValue)}
onCheckedChange={(val) =>
updateParamValueImmediate(val)
}
aria-label={param.name}
/>
<span className="text-muted-foreground ml-2 text-[11px]">
{Boolean(localValue) ? "Enabled" : "Disabled"}
</span>
</div>
);
} else if (param.type === "number") {
const localValue = localParams[param.id] ?? rawValue;
const numericVal =
typeof localValue === "number"
? localValue
: typeof param.value === "number"
? param.value
: (param.min ?? 0);
if (param.min !== undefined || param.max !== undefined) {
const min = param.min ?? 0;
const max =
param.max ??
Math.max(
min + 1,
Number.isFinite(numericVal) ? numericVal : min + 1,
);
// Step heuristic
const range = max - min;
const step =
param.step ??
(range <= 5
? 0.1
: range <= 50
? 0.5
: Math.max(1, Math.round(range / 100)));
control = (
<div className="mt-1">
<div className="flex items-center gap-2">
<Slider
min={min}
max={max}
step={step}
value={[Number(numericVal)]}
onValueChange={(vals: number[]) =>
updateParamLocal(vals[0])
}
onPointerUp={commitParamValue}
/>
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
{step < 1
? Number(numericVal).toFixed(2)
: Number(numericVal).toString()}
</span>
</div>
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
<span>{min}</span>
<span>{max}</span>
</div>
</div>
);
} else {
control = (
<Input
type="number"
value={numericVal}
onChange={(e) =>
updateParamValue(parseFloat(e.target.value) || 0)
}
onBlur={() => {
if (localParams[param.id] !== rawValue) {
onActionUpdate(
containingStep.id,
selectedAction.id,
{
parameters: {
...selectedAction.parameters,
[param.id]: localParams[param.id],
},
},
);
}
}}
className="mt-1 h-7 w-full text-xs"
/>
);
}
}
return (
<div key={param.id} className="space-y-1">
{commonLabel}
{param.description && (
<div className="text-muted-foreground text-[10px]">
{param.description}
</div>
)}
{control}
</div>
);
})}
</div> </div>
</div> </div>
) : ( ) : (
@@ -635,3 +452,156 @@ export function PropertiesPanelBase({
} }
export const PropertiesPanel = React.memo(PropertiesPanelBase); export const PropertiesPanel = React.memo(PropertiesPanelBase);
/* -------------------------------------------------------------------------- */
/* Isolated Parameter Editor (Optimized) */
/* -------------------------------------------------------------------------- */
interface ParameterEditorProps {
param: any;
value: unknown;
onUpdate: (value: unknown) => void;
onCommit: () => void;
}
const ParameterEditor = React.memo(function ParameterEditor({
param,
value: rawValue,
onUpdate,
onCommit
}: ParameterEditorProps) {
// Local state for immediate feedback
const [localValue, setLocalValue] = useState<unknown>(rawValue);
const debounceRef = useRef<NodeJS.Timeout | undefined>();
// Sync from prop if it changes externally
useEffect(() => {
setLocalValue(rawValue);
}, [rawValue]);
const handleUpdate = useCallback((newVal: unknown, immediate = false) => {
setLocalValue(newVal);
if (debounceRef.current) clearTimeout(debounceRef.current);
if (immediate) {
onUpdate(newVal);
} else {
debounceRef.current = setTimeout(() => {
onUpdate(newVal);
}, 300);
}
}, [onUpdate]);
const handleCommit = useCallback(() => {
if (localValue !== rawValue) {
onUpdate(localValue);
}
}, [localValue, rawValue, onUpdate]);
let control: React.ReactNode = null;
if (param.type === "text") {
control = (
<Input
value={(localValue as string) ?? ""}
placeholder={param.placeholder}
onChange={(e) => handleUpdate(e.target.value)}
onBlur={handleCommit}
className="mt-1 h-7 w-full text-xs"
/>
);
} else if (param.type === "select") {
control = (
<Select
value={(localValue as string) ?? ""}
onValueChange={(val) => handleUpdate(val, true)}
>
<SelectTrigger className="mt-1 h-7 w-full text-xs">
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent>
{param.options?.map((opt: string) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
);
} else if (param.type === "boolean") {
control = (
<div className="mt-1 flex h-7 items-center">
<Switch
checked={Boolean(localValue)}
onCheckedChange={(val) => handleUpdate(val, true)}
aria-label={param.name}
/>
<span className="text-muted-foreground ml-2 text-[11px]">
{Boolean(localValue) ? "Enabled" : "Disabled"}
</span>
</div>
);
} else if (param.type === "number") {
const numericVal = typeof localValue === "number" ? localValue : (param.min ?? 0);
if (param.min !== undefined || param.max !== undefined) {
const min = param.min ?? 0;
const max = param.max ?? Math.max(min + 1, Number.isFinite(numericVal) ? numericVal : min + 1);
const range = max - min;
const step = param.step ?? (range <= 5 ? 0.1 : range <= 50 ? 0.5 : Math.max(1, Math.round(range / 100)));
control = (
<div className="mt-1">
<div className="flex items-center gap-2">
<Slider
min={min}
max={max}
step={step}
value={[Number(numericVal)]}
onValueChange={(vals) => setLocalValue(vals[0])} // Update only local visual
onPointerUp={() => handleUpdate(localValue)} // Commit on release
/>
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
{step < 1 ? Number(numericVal).toFixed(2) : Number(numericVal).toString()}
</span>
</div>
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
<span>{min}</span>
<span>{max}</span>
</div>
</div>
);
} else {
control = (
<Input
type="number"
value={numericVal}
onChange={(e) => handleUpdate(parseFloat(e.target.value) || 0)}
onBlur={handleCommit}
className="mt-1 h-7 w-full text-xs"
/>
);
}
}
return (
<div className="space-y-1">
<Label className="flex items-center gap-2 text-xs">
{param.name}
<span className="text-muted-foreground font-normal">
{param.type === "number" &&
(param.min !== undefined || param.max !== undefined) &&
typeof rawValue === "number" &&
`( ${rawValue} )`}
</span>
</Label>
{param.description && (
<div className="text-muted-foreground text-[10px]">
{param.description}
</div>
)}
{control}
</div>
);
});

View File

@@ -12,6 +12,7 @@ import {
useDndMonitor, useDndMonitor,
type DragEndEvent, type DragEndEvent,
type DragStartEvent, type DragStartEvent,
type DragOverEvent,
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { import {
useSortable, useSortable,
@@ -68,7 +69,7 @@ interface FlowWorkspaceProps {
onActionCreate?: (stepId: string, action: ExperimentAction) => void; onActionCreate?: (stepId: string, action: ExperimentAction) => void;
} }
interface VirtualItem { export interface VirtualItem {
index: number; index: number;
top: number; top: number;
height: number; height: number;
@@ -77,6 +78,232 @@ interface VirtualItem {
visible: boolean; visible: boolean;
} }
interface StepRowProps {
item: VirtualItem;
selectedStepId: string | null | undefined;
selectedActionId: string | null | undefined;
renamingStepId: string | null;
onSelectStep: (id: string | undefined) => void;
onSelectAction: (stepId: string, actionId: string | undefined) => void;
onToggleExpanded: (step: ExperimentStep) => void;
onRenameStep: (step: ExperimentStep, name: string) => void;
onDeleteStep: (step: ExperimentStep) => void;
onDeleteAction: (stepId: string, actionId: string) => void;
setRenamingStepId: (id: string | null) => void;
registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void;
}
const StepRow = React.memo(function StepRow({
item,
selectedStepId,
selectedActionId,
renamingStepId,
onSelectStep,
onSelectAction,
onToggleExpanded,
onRenameStep,
onDeleteStep,
onDeleteAction,
setRenamingStepId,
registerMeasureRef,
}: StepRowProps) {
const step = item.step;
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
const displayActions = useMemo(() => {
if (
insertionProjection?.stepId === step.id &&
insertionProjection.parentId === null
) {
const copy = [...step.actions];
// Insert placeholder action
// Ensure specific ID doesn't crash keys if collision (collision unlikely for library items)
// Actually, standard array key is action.id.
copy.splice(insertionProjection.index, 0, insertionProjection.action);
return copy;
}
return step.actions;
}, [step.actions, step.id, insertionProjection]);
const {
setNodeRef,
transform,
transition,
attributes,
listeners,
isDragging,
} = useSortable({
id: sortableStepId(step.id),
data: {
type: "step",
step: step,
},
});
const style: React.CSSProperties = {
position: "absolute",
top: item.top,
left: 0,
right: 0,
width: "100%",
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 25 : undefined,
};
return (
<div ref={setNodeRef} style={style} data-step-id={step.id}>
<div
ref={(el) => registerMeasureRef(step.id, el)}
className="relative px-3 py-4"
data-step-id={step.id}
>
<StepDroppableArea stepId={step.id} />
<div
className={cn(
"mb-2 rounded border shadow-sm transition-colors",
selectedStepId === step.id
? "border-border bg-accent/30"
: "hover:bg-accent/30",
isDragging && "opacity-80 ring-1 ring-blue-300",
)}
>
<div
className="flex items-center justify-between gap-2 border-b px-2 py-1.5"
onClick={(e) => {
const tag = (e.target as HTMLElement).tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "button")
return;
onSelectStep(step.id);
onSelectAction(step.id, undefined);
}}
role="button"
tabIndex={0}
>
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleExpanded(step);
}}
className="text-muted-foreground hover:bg-accent/60 hover:text-foreground rounded p-1"
aria-label={step.expanded ? "Collapse step" : "Expand step"}
>
{step.expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] font-normal"
>
{step.order + 1}
</Badge>
{renamingStepId === step.id ? (
<Input
autoFocus
defaultValue={step.name}
className="h-7 w-40 text-xs"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter") {
onRenameStep(
step,
(e.target as HTMLInputElement).value.trim() ||
step.name,
);
setRenamingStepId(null);
} else if (e.key === "Escape") {
setRenamingStepId(null);
}
}}
onBlur={(e) => {
onRenameStep(step, e.target.value.trim() || step.name);
setRenamingStepId(null);
}}
/>
) : (
<div className="flex items-center gap-1">
<span className="text-sm font-medium">{step.name}</span>
<button
type="button"
className="text-muted-foreground hover:text-foreground p-1 opacity-0 group-hover:opacity-100"
aria-label="Rename step"
onClick={(e) => {
e.stopPropagation();
setRenamingStepId(step.id);
}}
>
<Edit3 className="h-3.5 w-3.5" />
</button>
</div>
)}
<span className="text-muted-foreground hidden text-[11px] md:inline">
{step.actions.length} actions
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-[11px] text-red-500 hover:text-red-600"
onClick={(e) => {
e.stopPropagation();
onDeleteStep(step);
}}
aria-label="Delete step"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<div
className="text-muted-foreground cursor-grab p-1"
aria-label="Drag step"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</div>
</div>
</div>
{/* Action List (Collapsible/Virtual content) */}
{step.expanded && (
<div className="bg-background/40 min-h-[3rem] space-y-2 p-2 pb-8">
<SortableContext
items={displayActions.map((a) => sortableActionId(a.id))}
strategy={verticalListSortingStrategy}
>
<div className="flex w-full flex-col gap-2">
{displayActions.length === 0 ? (
<div className="flex h-12 items-center justify-center rounded border border-dashed text-xs text-muted-foreground">
Drop actions here
</div>
) : (
displayActions.map((action) => (
<SortableActionChip
key={action.id}
stepId={step.id}
action={action}
parentId={null}
selectedActionId={selectedActionId}
onSelectAction={onSelectAction}
onDeleteAction={onDeleteAction}
/>
))
)}
</div>
</SortableContext>
</div>
)}
</div>
</div>
</div>
);
});
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Utility */ /* Utility */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@@ -122,37 +349,125 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
interface ActionChipProps { interface ActionChipProps {
stepId: string;
action: ExperimentAction; action: ExperimentAction;
isSelected: boolean; parentId: string | null;
onSelect: () => void; selectedActionId: string | null | undefined;
onDelete: () => void; onSelectAction: (stepId: string, actionId: string | undefined) => void;
onDeleteAction: (stepId: string, actionId: string) => void;
dragHandle?: boolean; dragHandle?: boolean;
} }
function SortableActionChip({ function SortableActionChip({
stepId,
action, action,
isSelected, parentId,
onSelect, selectedActionId,
onDelete, onSelectAction,
onDeleteAction,
dragHandle,
}: ActionChipProps) { }: ActionChipProps) {
const def = actionRegistry.getAction(action.type); const def = actionRegistry.getAction(action.type);
const isSelected = selectedActionId === action.id;
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
const displayChildren = useMemo(() => {
if (
insertionProjection?.stepId === stepId &&
insertionProjection.parentId === action.id
) {
const copy = [...(action.children || [])];
copy.splice(insertionProjection.index, 0, insertionProjection.action);
return copy;
}
return action.children;
}, [action.children, action.id, stepId, insertionProjection]);
/* ------------------------------------------------------------------------ */
/* Main Sortable Logic */
/* ------------------------------------------------------------------------ */
const isPlaceholder = action.id === "projection-placeholder";
const { const {
attributes, attributes,
listeners, listeners,
setNodeRef, setNodeRef,
transform, transform,
transition, transition,
isDragging, isDragging: isSortableDragging,
} = useSortable({ } = useSortable({
id: sortableActionId(action.id), id: sortableActionId(action.id),
disabled: isPlaceholder, // Disable sortable for placeholder
data: {
type: "action",
stepId,
parentId,
id: action.id,
},
}); });
const style: React.CSSProperties = { // Use local dragging state or passed prop
transform: CSS.Transform.toString(transform), const isDragging = isSortableDragging || dragHandle;
const style = {
transform: CSS.Translate.toString(transform),
transition, transition,
zIndex: isDragging ? 30 : undefined,
}; };
/* ------------------------------------------------------------------------ */
/* Nested Droppable (for control flow containers) */
/* ------------------------------------------------------------------------ */
const nestedDroppableId = `container-${action.id}`;
const {
isOver: isOverNested,
setNodeRef: setNestedNodeRef
} = useDroppable({
id: nestedDroppableId,
disabled: !def?.nestable || isPlaceholder, // Disable droppable for placeholder
data: {
type: "container",
stepId,
parentId: action.id,
action // Pass full action for projection logic
}
});
const shouldRenderChildren = def?.nestable;
if (isPlaceholder) {
const { setNodeRef: setPlaceholderRef } = useDroppable({
id: "projection-placeholder",
data: { type: "placeholder" }
});
// Render simplified placeholder without hooks refs
// We still render the content matching the action type for visual fidelity
return (
<div
ref={setPlaceholderRef}
className="group relative flex w-full flex-col items-start gap-1 rounded border-2 border-dashed border-blue-300 bg-blue-50/50 px-3 py-2 text-[11px] opacity-70"
>
<div className="flex w-full items-center gap-2">
<span className={cn(
"h-2.5 w-2.5 rounded-full",
def ? {
wizard: "bg-blue-500",
robot: "bg-emerald-500",
control: "bg-amber-500",
observation: "bg-purple-500",
}[def.category] : "bg-gray-400"
)} />
<span className="font-medium text-foreground">{def?.name ?? action.name}</span>
</div>
{def?.description && (
<div className="text-muted-foreground line-clamp-3 w-full text-[10px] leading-snug">
{def.description}
</div>
)}
</div>
);
}
return ( return (
<div <div
ref={setNodeRef} ref={setNodeRef}
@@ -162,8 +477,13 @@ function SortableActionChip({
"bg-muted/40 hover:bg-accent/40 cursor-pointer", "bg-muted/40 hover:bg-accent/40 cursor-pointer",
isSelected && "border-border bg-accent/30", isSelected && "border-border bg-accent/30",
isDragging && "opacity-70 shadow-lg", isDragging && "opacity-70 shadow-lg",
// Visual feedback for nested drop
isOverNested && !isDragging && "ring-2 ring-blue-400 ring-offset-1 bg-blue-50/50"
)} )}
onClick={onSelect} onClick={(e) => {
e.stopPropagation();
onSelectAction(stepId, action.id);
}}
{...attributes} {...attributes}
role="button" role="button"
aria-pressed={isSelected} aria-pressed={isSelected}
@@ -197,7 +517,7 @@ function SortableActionChip({
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onDelete(); onDeleteAction(stepId, action.id);
}} }}
className="text-muted-foreground hover:text-foreground rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100" className="text-muted-foreground hover:text-foreground rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
aria-label="Delete action" aria-label="Delete action"
@@ -221,12 +541,45 @@ function SortableActionChip({
</span> </span>
))} ))}
{def.parameters.length > 4 && ( {def.parameters.length > 4 && (
<span className="text-muted-foreground text-[9px]"> <span className="text-[9px] text-muted-foreground">+{def.parameters.length - 4}</span>
+{def.parameters.length - 4} more
</span>
)} )}
</div> </div>
) : null} ) : null}
{/* Nested Actions Container */}
{shouldRenderChildren && (
<div
ref={setNestedNodeRef}
className={cn(
"mt-2 w-full flex flex-col gap-2 pl-4 border-l-2 border-border/40 transition-all min-h-[0.5rem] pb-4",
)}
>
<SortableContext
items={(displayChildren ?? action.children ?? [])
.filter(c => c.id !== "projection-placeholder")
.map(c => sortableActionId(c.id))}
strategy={verticalListSortingStrategy}
>
{(displayChildren || action.children || []).map((child) => (
<SortableActionChip
key={child.id}
stepId={stepId}
action={child}
parentId={action.id}
selectedActionId={selectedActionId}
onSelectAction={onSelectAction}
onDeleteAction={onDeleteAction}
/>
))}
{(!displayChildren?.length && !action.children?.length) && (
<div className="text-[10px] text-muted-foreground/60 italic py-1">
Drag actions here
</div>
)}
</SortableContext>
</div>
)}
</div> </div>
); );
} }
@@ -254,7 +607,7 @@ export function FlowWorkspace({
const removeAction = useDesignerStore((s) => s.removeAction); const removeAction = useDesignerStore((s) => s.removeAction);
const reorderStep = useDesignerStore((s) => s.reorderStep); const reorderStep = useDesignerStore((s) => s.reorderStep);
const reorderAction = useDesignerStore((s) => s.reorderAction); const moveAction = useDesignerStore((s) => s.moveAction);
const recomputeHash = useDesignerStore((s) => s.recomputeHash); const recomputeHash = useDesignerStore((s) => s.recomputeHash);
/* Local state */ /* Local state */
@@ -382,7 +735,10 @@ export function FlowWorkspace({
description: "", description: "",
type: "sequential", type: "sequential",
order: steps.length, order: steps.length,
trigger: { type: "trial_start", conditions: {} }, trigger:
steps.length === 0
? { type: "trial_start", conditions: {} }
: { type: "previous_step", conditions: {} },
actions: [], actions: [],
expanded: true, expanded: true,
}; };
@@ -472,34 +828,77 @@ export function FlowWorkspace({
} }
} }
} }
// Action reorder (within same parent only) // Action reorder (supports nesting)
if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) { if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) {
const fromActionId = parseSortableAction(activeId); const activeData = active.data.current;
const toActionId = parseSortableAction(overId); const overData = over.data.current;
if (fromActionId && toActionId && fromActionId !== toActionId) {
const fromParent = actionParentMap.get(fromActionId); if (
const toParent = actionParentMap.get(toActionId); activeData && overData &&
if (fromParent && toParent && fromParent === toParent) { activeData.stepId === overData.stepId &&
const step = steps.find((s) => s.id === fromParent); activeData.type === 'action' && overData.type === 'action'
if (step) { ) {
const fromIdx = step.actions.findIndex( const stepId = activeData.stepId as string;
(a) => a.id === fromActionId, const activeActionId = activeData.action.id;
); const overActionId = overData.action.id;
const toIdx = step.actions.findIndex((a) => a.id === toActionId);
if (fromIdx >= 0 && toIdx >= 0) { if (activeActionId !== overActionId) {
reorderAction(step.id, fromIdx, toIdx); const newParentId = overData.parentId as string | null;
void recomputeHash(); const newIndex = overData.sortable.index; // index within that parent's list
}
} moveAction(stepId, activeActionId, newParentId, newIndex);
void recomputeHash();
} }
} }
} }
}, },
[steps, reorderStep, reorderAction, actionParentMap, recomputeHash], [steps, reorderStep, moveAction, recomputeHash],
);
/* ------------------------------------------------------------------------ */
/* Drag Over (Live Sorting) */
/* ------------------------------------------------------------------------ */
const handleLocalDragOver = useCallback(
(event: DragOverEvent) => {
const { active, over } = event;
if (!over) return;
const activeId = active.id.toString();
const overId = over.id.toString();
// Only handle action reordering
if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) {
const activeData = active.data.current;
const overData = over.data.current;
if (
activeData &&
overData &&
activeData.type === 'action' &&
overData.type === 'action'
) {
const activeActionId = activeData.action.id;
const overActionId = overData.action.id;
const activeStepId = activeData.stepId;
const overStepId = overData.stepId;
const activeParentId = activeData.parentId;
const overParentId = overData.parentId;
// If moving between different lists (parents/steps), move immediately to visualize snap
if (activeParentId !== overParentId || activeStepId !== overStepId) {
// Determine new index
// verification of safe move handled by store
moveAction(overStepId, activeActionId, overParentId, overData.sortable.index);
}
}
}
},
[moveAction]
); );
useDndMonitor({ useDndMonitor({
onDragStart: handleLocalDragStart, onDragStart: handleLocalDragStart,
onDragOver: handleLocalDragOver,
onDragEnd: handleLocalDragEnd, onDragEnd: handleLocalDragEnd,
onDragCancel: () => { onDragCancel: () => {
// no-op // no-op
@@ -509,204 +908,22 @@ export function FlowWorkspace({
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
/* Step Row (Sortable + Virtualized) */ /* Step Row (Sortable + Virtualized) */
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
function StepRow({ item }: { item: VirtualItem }) { // StepRow moved outside of component to prevent re-mounting on every render (flashing fix)
const step = item.step;
const {
setNodeRef,
transform,
transition,
attributes,
listeners,
isDragging,
} = useSortable({
id: sortableStepId(step.id),
});
const style: React.CSSProperties = { const registerMeasureRef = useCallback(
position: "absolute", (stepId: string, el: HTMLDivElement | null) => {
top: item.top, const prev = measureRefs.current.get(stepId) ?? null;
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) { if (prev && prev !== el) {
roRef.current?.unobserve(prev); roRef.current?.unobserve(prev);
measureRefs.current.delete(step.id); measureRefs.current.delete(stepId);
} }
if (el) { if (el) {
measureRefs.current.set(step.id, el); measureRefs.current.set(stepId, el);
roRef.current?.observe(el); roRef.current?.observe(el);
} }
}; },
[],
return ( );
<div ref={setNodeRef} style={style} data-step-id={step.id}>
<div
ref={setMeasureRef}
className="relative px-3 py-4"
data-step-id={step.id}
>
<StepDroppableArea stepId={step.id} />
<div
className={cn(
"mb-2 rounded border shadow-sm transition-colors",
selectedStepId === step.id
? "border-border bg-accent/30"
: "hover:bg-accent/30",
isDragging && "opacity-80 ring-1 ring-blue-300",
)}
>
<div
className="flex items-center justify-between gap-2 border-b px-2 py-1.5"
onClick={(e) => {
// Avoid selecting step when interacting with controls or inputs
const tag = (e.target as HTMLElement).tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "button")
return;
selectStep(step.id);
selectAction(step.id, undefined);
}}
role="button"
tabIndex={0}
>
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleExpanded(step);
}}
className="text-muted-foreground hover:bg-accent/60 hover:text-foreground rounded p-1"
aria-label={step.expanded ? "Collapse step" : "Expand step"}
>
{step.expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] font-normal"
>
{step.order + 1}
</Badge>
{renamingStepId === step.id ? (
<Input
autoFocus
defaultValue={step.name}
className="h-7 w-40 text-xs"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter") {
renameStep(
step,
(e.target as HTMLInputElement).value.trim() ||
step.name,
);
setRenamingStepId(null);
void recomputeHash();
} else if (e.key === "Escape") {
setRenamingStepId(null);
}
}}
onBlur={(e) => {
renameStep(step, e.target.value.trim() || step.name);
setRenamingStepId(null);
void recomputeHash();
}}
/>
) : (
<div className="flex items-center gap-1">
<span className="text-sm font-medium">{step.name}</span>
<button
type="button"
className="text-muted-foreground hover:text-foreground p-1 opacity-0 group-hover:opacity-100"
aria-label="Rename step"
onClick={(e) => {
e.stopPropagation();
setRenamingStepId(step.id);
}}
>
<Edit3 className="h-3.5 w-3.5" />
</button>
</div>
)}
<span className="text-muted-foreground hidden text-[11px] md:inline">
{step.actions.length} actions
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-[11px] text-red-500 hover:text-red-600"
onClick={(e) => {
e.stopPropagation();
deleteStep(step);
}}
aria-label="Delete step"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<div
className="text-muted-foreground cursor-grab p-1"
aria-label="Drag step"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</div>
</div>
</div>
{step.expanded && (
<div className="space-y-2 px-3 py-3">
<div className="flex flex-wrap gap-2">
{step.actions.length > 0 && (
<SortableContext
items={step.actions.map((a) => sortableActionId(a.id))}
strategy={verticalListSortingStrategy}
>
<div className="flex w-full flex-col gap-2">
{step.actions.map((action) => (
<SortableActionChip
key={action.id}
action={action}
isSelected={
selectedStepId === step.id &&
selectedActionId === action.id
}
onSelect={() => {
selectStep(step.id);
selectAction(step.id, action.id);
}}
onDelete={() => deleteAction(step.id, action.id)}
/>
))}
</div>
</SortableContext>
)}
</div>
{/* Persistent centered bottom drop hint */}
<div className="mt-3 flex w-full items-center justify-center">
<div className="text-muted-foreground border-muted-foreground/30 rounded border border-dashed px-2 py-1 text-[11px]">
Drop actions here
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
/* Render */ /* Render */
@@ -767,7 +984,27 @@ export function FlowWorkspace({
> >
<div style={{ height: totalHeight, position: "relative" }}> <div style={{ height: totalHeight, position: "relative" }}>
{virtualItems.map( {virtualItems.map(
(vi) => vi.visible && <StepRow key={vi.key} item={vi} />, (vi) =>
vi.visible && (
<StepRow
key={vi.key}
item={vi}
selectedStepId={selectedStepId}
selectedActionId={selectedActionId}
renamingStepId={renamingStepId}
onSelectStep={selectStep}
onSelectAction={selectAction}
onToggleExpanded={toggleExpanded}
onRenameStep={(step, name) => {
renameStep(step, name);
void recomputeHash();
}}
onDeleteStep={deleteStep}
onDeleteAction={deleteAction}
setRenamingStepId={setRenamingStepId}
registerMeasureRef={registerMeasureRef}
/>
),
)} )}
</div> </div>
</SortableContext> </SortableContext>

View File

@@ -53,6 +53,30 @@ export interface PanelsContainerProps {
* - Resize handles are absolutely positioned over the grid at the left and right boundaries. * - Resize handles are absolutely positioned over the grid at the left and right boundaries.
* - Fractions are clamped with configurable min/max so panels remain usable at all sizes. * - Fractions are clamped with configurable min/max so panels remain usable at all sizes.
*/ */
const Panel: React.FC<React.PropsWithChildren<{
className?: string;
panelClassName?: string;
contentClassName?: string;
}>> = ({
className: panelCls,
panelClassName,
contentClassName,
children,
}) => (
<section
className={cn("min-w-0 overflow-hidden", panelCls, panelClassName)}
>
<div
className={cn(
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto",
contentClassName,
)}
>
{children}
</div>
</section>
);
export function PanelsContainer({ export function PanelsContainer({
left, left,
center, center,
@@ -209,10 +233,10 @@ export function PanelsContainer({
// CSS variables for the grid fractions // CSS variables for the grid fractions
const styleVars: React.CSSProperties & Record<string, string> = hasCenter const styleVars: React.CSSProperties & Record<string, string> = hasCenter
? { ? {
"--col-left": `${(hasLeft ? l : 0) * 100}%`, "--col-left": `${(hasLeft ? l : 0) * 100}%`,
"--col-center": `${c * 100}%`, "--col-center": `${c * 100}%`,
"--col-right": `${(hasRight ? r : 0) * 100}%`, "--col-right": `${(hasRight ? r : 0) * 100}%`,
} }
: {}; : {};
// Explicit grid template depending on which side panels exist // Explicit grid template depending on which side panels exist
@@ -229,28 +253,12 @@ export function PanelsContainer({
const centerDividers = const centerDividers =
showDividers && hasCenter showDividers && hasCenter
? cn({ ? cn({
"border-l": hasLeft, "border-l": hasLeft,
"border-r": hasRight, "border-r": hasRight,
}) })
: undefined; : undefined;
const Panel: React.FC<React.PropsWithChildren<{ className?: string }>> = ({
className: panelCls,
children,
}) => (
<section
className={cn("min-w-0 overflow-hidden", panelCls, panelClassName)}
>
<div
className={cn(
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto",
contentClassName,
)}
>
{children}
</div>
</section>
);
return ( return (
<div <div
@@ -263,11 +271,33 @@ export function PanelsContainer({
className, className,
)} )}
> >
{hasLeft && <Panel>{left}</Panel>} {hasLeft && (
<Panel
panelClassName={panelClassName}
contentClassName={contentClassName}
>
{left}
</Panel>
)}
{hasCenter && <Panel className={centerDividers}>{center}</Panel>} {hasCenter && (
<Panel
className={centerDividers}
panelClassName={panelClassName}
contentClassName={contentClassName}
>
{center}
</Panel>
)}
{hasRight && <Panel>{right}</Panel>} {hasRight && (
<Panel
panelClassName={panelClassName}
contentClassName={contentClassName}
>
{right}
</Panel>
)}
{/* Resize handles (only render where applicable) */} {/* Resize handles (only render where applicable) */}
{hasCenter && hasLeft && ( {hasCenter && hasLeft && (

View File

@@ -174,7 +174,7 @@ export function ActionLibraryPanel() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [selectedCategories, setSelectedCategories] = useState< const [selectedCategories, setSelectedCategories] = useState<
Set<ActionCategory> Set<ActionCategory>
>(new Set<ActionCategory>(["wizard"])); >(new Set<ActionCategory>(["wizard", "robot", "control", "observation"]));
const [favorites, setFavorites] = useState<FavoritesState>({ const [favorites, setFavorites] = useState<FavoritesState>({
favorites: new Set<string>(), favorites: new Set<string>(),
}); });
@@ -293,9 +293,7 @@ export function ActionLibraryPanel() {
setShowOnlyFavorites(false); setShowOnlyFavorites(false);
}, [categories]); }, [categories]);
useEffect(() => {
setSelectedCategories(new Set(categories.map((c) => c.key)));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const filtered = useMemo(() => { const filtered = useMemo(() => {
const activeCats = selectedCategories; const activeCats = selectedCategories;

View File

@@ -155,8 +155,9 @@ function projectActionForDesign(
pluginVersion: action.source.pluginVersion, pluginVersion: action.source.pluginVersion,
baseActionId: action.source.baseActionId, baseActionId: action.source.baseActionId,
}, },
execution: projectExecutionDescriptor(action.execution), execution: action.execution ? projectExecutionDescriptor(action.execution) : null,
parameterKeysOrValues: parameterProjection, parameterKeysOrValues: parameterProjection,
children: action.children?.map(c => projectActionForDesign(c, options)) ?? [],
}; };
if (options.includeActionNames) { if (options.includeActionNames) {

View File

@@ -79,6 +79,23 @@ export interface DesignerState {
busyHashing: boolean; busyHashing: boolean;
busyValidating: boolean; busyValidating: boolean;
/* ---------------------- DnD Projection (Transient) ----------------------- */
insertionProjection: {
stepId: string;
parentId: string | null;
index: number;
action: ExperimentAction;
} | null;
setInsertionProjection: (
projection: {
stepId: string;
parentId: string | null;
index: number;
action: ExperimentAction;
} | null
) => void;
/* ------------------------------ Mutators --------------------------------- */ /* ------------------------------ Mutators --------------------------------- */
// Selection // Selection
@@ -92,9 +109,10 @@ export interface DesignerState {
reorderStep: (from: number, to: number) => void; reorderStep: (from: number, to: number) => void;
// Actions // Actions
upsertAction: (stepId: string, action: ExperimentAction) => void; upsertAction: (stepId: string, action: ExperimentAction, parentId?: string | null, index?: number) => void;
removeAction: (stepId: string, actionId: string) => void; removeAction: (stepId: string, actionId: string) => void;
reorderAction: (stepId: string, from: number, to: number) => void; reorderAction: (stepId: string, from: number, to: number) => void;
moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) => void;
// Dirty // Dirty
markDirty: (id: string) => void; markDirty: (id: string) => void;
@@ -159,17 +177,73 @@ function reindexActions(actions: ExperimentAction[]): ExperimentAction[] {
return actions.map((a) => ({ ...a })); return actions.map((a) => ({ ...a }));
} }
function updateActionList( function findActionById(
existing: ExperimentAction[], list: ExperimentAction[],
id: string,
): ExperimentAction | null {
for (const action of list) {
if (action.id === id) return action;
if (action.children) {
const found = findActionById(action.children, id);
if (found) return found;
}
}
return null;
}
function updateActionInTree(
list: ExperimentAction[],
action: ExperimentAction, action: ExperimentAction,
): ExperimentAction[] { ): ExperimentAction[] {
const idx = existing.findIndex((a) => a.id === action.id); return list.map((a) => {
if (idx >= 0) { if (a.id === action.id) return { ...action };
const copy = [...existing]; if (a.children) {
copy[idx] = { ...action }; return { ...a, children: updateActionInTree(a.children, action) };
}
return a;
});
}
// Immutable removal
function removeActionFromTree(
list: ExperimentAction[],
id: string,
): ExperimentAction[] {
return list
.filter((a) => a.id !== id)
.map((a) => ({
...a,
children: a.children ? removeActionFromTree(a.children, id) : undefined,
}));
}
// Immutable insertion
function insertActionIntoTree(
list: ExperimentAction[],
action: ExperimentAction,
parentId: string | null,
index: number,
): ExperimentAction[] {
if (!parentId) {
// Insert at root level
const copy = [...list];
copy.splice(index, 0, action);
return copy; return copy;
} }
return [...existing, { ...action }]; return list.map((a) => {
if (a.id === parentId) {
const children = a.children ? [...a.children] : [];
children.splice(index, 0, action);
return { ...a, children };
}
if (a.children) {
return {
...a,
children: insertActionIntoTree(a.children, action, parentId, index),
};
}
return a;
});
} }
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@@ -187,6 +261,7 @@ export const useDesignerStore = create<DesignerState>((set, get) => ({
autoSaveEnabled: true, autoSaveEnabled: true,
busyHashing: false, busyHashing: false,
busyValidating: false, busyValidating: false,
insertionProjection: null,
/* ------------------------------ Selection -------------------------------- */ /* ------------------------------ Selection -------------------------------- */
selectStep: (id) => selectStep: (id) =>
@@ -263,16 +338,31 @@ export const useDesignerStore = create<DesignerState>((set, get) => ({
}), }),
/* ------------------------------- Actions --------------------------------- */ /* ------------------------------- Actions --------------------------------- */
upsertAction: (stepId: string, action: ExperimentAction) => upsertAction: (stepId: string, action: ExperimentAction, parentId: string | null = null, index?: number) =>
set((state: DesignerState) => { set((state: DesignerState) => {
const stepsDraft: ExperimentStep[] = state.steps.map((s) => const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
s.id === stepId if (s.id !== stepId) return s;
? {
...s, // Check if exists (update)
actions: reindexActions(updateActionList(s.actions, action)), const exists = findActionById(s.actions, action.id);
} if (exists) {
: s, // If updating, we don't (currently) support moving via upsert.
); // Use moveAction for moving.
return {
...s,
actions: updateActionInTree(s.actions, action)
};
}
// Add new
// If index is provided, use it. Otherwise append.
const insertIndex = index ?? s.actions.length;
return {
...s,
actions: insertActionIntoTree(s.actions, action, parentId, insertIndex)
};
});
return { return {
steps: stepsDraft, steps: stepsDraft,
dirtyEntities: new Set<string>([ dirtyEntities: new Set<string>([
@@ -288,11 +378,9 @@ export const useDesignerStore = create<DesignerState>((set, get) => ({
const stepsDraft: ExperimentStep[] = state.steps.map((s) => const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
s.id === stepId s.id === stepId
? { ? {
...s, ...s,
actions: reindexActions( actions: removeActionFromTree(s.actions, actionId),
s.actions.filter((a) => a.id !== actionId), }
),
}
: s, : s,
); );
const dirty = new Set<string>(state.dirtyEntities); const dirty = new Set<string>(state.dirtyEntities);
@@ -308,31 +396,29 @@ export const useDesignerStore = create<DesignerState>((set, get) => ({
}; };
}), }),
reorderAction: (stepId: string, from: number, to: number) => moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) =>
set((state: DesignerState) => { set((state: DesignerState) => {
const stepsDraft: ExperimentStep[] = state.steps.map((s) => { const stepsDraft = state.steps.map((s) => {
if (s.id !== stepId) return s; if (s.id !== stepId) return s;
if (
from < 0 || const actionToMove = findActionById(s.actions, actionId);
to < 0 || if (!actionToMove) return s;
from >= s.actions.length ||
to >= s.actions.length || const pruned = removeActionFromTree(s.actions, actionId);
from === to const inserted = insertActionIntoTree(pruned, actionToMove, newParentId, newIndex);
) { return { ...s, actions: inserted };
return s;
}
const actionsDraft = [...s.actions];
const [moved] = actionsDraft.splice(from, 1);
if (!moved) return s;
actionsDraft.splice(to, 0, moved);
return { ...s, actions: reindexActions(actionsDraft) };
}); });
return { return {
steps: stepsDraft, steps: stepsDraft,
dirtyEntities: new Set<string>([...state.dirtyEntities, stepId]), dirtyEntities: new Set<string>([...state.dirtyEntities, stepId, actionId]),
}; };
}), }),
reorderAction: (stepId: string, from: number, to: number) =>
get().moveAction(stepId, get().steps.find(s => s.id === stepId)?.actions[from]?.id!, null, to), // Legacy compat support (only works for root level reorder)
setInsertionProjection: (projection) => set({ insertionProjection: projection }),
/* -------------------------------- Dirty ---------------------------------- */ /* -------------------------------- Dirty ---------------------------------- */
markDirty: (id: string) => markDirty: (id: string) =>
set((state: DesignerState) => ({ set((state: DesignerState) => ({

View File

@@ -643,13 +643,13 @@ export function validateExecution(
if (trialStartSteps.length > 1) { if (trialStartSteps.length > 1) {
trialStartSteps.slice(1).forEach((step) => { trialStartSteps.slice(1).forEach((step) => {
issues.push({ issues.push({
severity: "warning", severity: "info",
message: message:
"Multiple steps will start simultaneously. Ensure parallel execution is intended.", "This step will start immediately at trial start. For sequential flow, use 'Previous Step' trigger.",
category: "execution", category: "execution",
field: "trigger.type", field: "trigger.type",
stepId: step.id, stepId: step.id,
suggestion: "Consider using sequential triggers for subsequent steps", suggestion: "Change trigger to 'Previous Step' if this step should follow the previous one",
}); });
}); });
} }

View File

@@ -367,10 +367,8 @@ export const columns: ColumnDef<Trial>[] = [
function ActionsCell({ row }: { row: { original: Trial } }) { function ActionsCell({ row }: { row: { original: Trial } }) {
const trial = row.original; const trial = row.original;
const router = React.useMemo(() => require("next/navigation").useRouter(), []); // Dynamic import to avoid hook rules in static context? No, this component is rendered in Table. // ActionsCell is a component rendered by the table.
// Actually, hooks must be at top level. This ActionsCell will be a regular component.
// But useRouter might fail if columns is not in component tree?
// Table cells are rendered by flexRender in React, so they are components.
// importing useRouter is fine. // importing useRouter is fine.
const utils = api.useUtils(); const utils = api.useUtils();

View File

@@ -0,0 +1,205 @@
"use client";
import React, { useMemo, useRef, useState } from "react";
import { usePlayback } from "./PlaybackContext";
import { cn } from "~/lib/utils";
import {
AlertTriangle,
CheckCircle,
Flag,
MessageSquare,
Zap,
Circle,
Bot,
User,
Activity
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "~/components/ui/tooltip";
function formatTime(seconds: number) {
const min = Math.floor(seconds / 60);
const sec = Math.floor(seconds % 60);
return `${min}:${sec.toString().padStart(2, "0")}`;
}
export function EventTimeline() {
const {
duration,
currentTime,
events,
seekTo,
startTime: contextStartTime
} = usePlayback();
// Determine effective time range
const sortedEvents = useMemo(() => {
return [...events].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
}, [events]);
const startTime = useMemo(() => {
if (contextStartTime) return new Date(contextStartTime).getTime();
if (sortedEvents.length > 0) return new Date(sortedEvents[0]!.timestamp).getTime();
return 0;
}, [contextStartTime, sortedEvents]);
const effectiveDuration = useMemo(() => {
if (duration > 0) return duration * 1000;
if (sortedEvents.length === 0) return 60000; // 1 min default
const end = new Date(sortedEvents[sortedEvents.length - 1]!.timestamp).getTime();
return Math.max(end - startTime, 1000);
}, [duration, sortedEvents, startTime]);
// Dimensions
const containerRef = useRef<HTMLDivElement>(null);
// Helpers
const getPercentage = (timestampMs: number) => {
const offset = timestampMs - startTime;
return Math.max(0, Math.min(100, (offset / effectiveDuration) * 100));
};
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const pct = Math.max(0, Math.min(1, x / rect.width));
seekTo(pct * (effectiveDuration / 1000));
};
const currentProgress = (currentTime * 1000 / effectiveDuration) * 100;
// Generate ticks for "number line" look
// We want a major tick every ~10% or meaningful time interval
const ticks = useMemo(() => {
const count = 10;
return Array.from({ length: count + 1 }).map((_, i) => ({
pct: (i / count) * 100,
label: formatTime((effectiveDuration / 1000) * (i / count))
}));
}, [effectiveDuration]);
const getEventIcon = (type: string) => {
if (type.includes("intervention") || type.includes("wizard")) return <User className="h-3 w-3" />;
if (type.includes("robot") || type.includes("action")) return <Bot className="h-3 w-3" />;
if (type.includes("completed")) return <CheckCircle className="h-3 w-3" />;
if (type.includes("start")) return <Flag className="h-3 w-3" />;
if (type.includes("note")) return <MessageSquare className="h-3 w-3" />;
if (type.includes("error")) return <AlertTriangle className="h-3 w-3" />;
return <Activity className="h-3 w-3" />;
};
const getEventColor = (type: string) => {
if (type.includes("intervention") || type.includes("wizard")) return "text-orange-500 border-orange-200 bg-orange-50";
if (type.includes("robot") || type.includes("action")) return "text-purple-500 border-purple-200 bg-purple-50";
if (type.includes("completed")) return "text-green-500 border-green-200 bg-green-50";
if (type.includes("start")) return "text-blue-500 border-blue-200 bg-blue-50";
if (type.includes("error")) return "text-red-500 border-red-200 bg-red-50";
return "text-slate-500 border-slate-200 bg-slate-50";
};
return (
<div className="w-full h-full flex flex-col select-none py-2">
<TooltipProvider>
{/* Timeline Track Container */}
<div
ref={containerRef}
className="relative w-full flex-1 min-h-[80px] group cursor-crosshair border-b border-border/50"
onClick={handleSeek}
>
{/* Background Grid/Ticks */}
<div className="absolute inset-0 pointer-events-none">
{/* Major Ticks */}
{ticks.map((tick, i) => (
<div
key={i}
className="absolute top-0 bottom-0 border-l border-border/30 flex flex-col justify-end"
style={{ left: `${tick.pct}%` }}
>
<span className="text-[10px] font-mono text-muted-foreground -ml-3 mb-1 bg-background/80 px-1 rounded">
{tick.label}
</span>
</div>
))}
</div>
{/* Central Axis Line */}
<div className="absolute top-1/2 left-0 right-0 h-px bg-border z-0" />
{/* Progress Fill (Subtle) */}
<div
className="absolute top-0 bottom-0 left-0 bg-primary/5 z-0 pointer-events-none"
style={{ width: `${currentProgress}%` }}
/>
{/* Playhead */}
<div
className="absolute top-0 bottom-0 w-px bg-red-500 z-30 pointer-events-none transition-all duration-75"
style={{ left: `${currentProgress}%` }}
>
<div className="absolute -top-1 -ml-1.5 p-0.5 bg-red-500 rounded text-[8px] font-bold text-white w-3 h-3 flex items-center justify-center">
</div>
</div>
{/* Events "Lollipops" */}
{sortedEvents.map((event, i) => {
const pct = getPercentage(new Date(event.timestamp).getTime());
const isTop = i % 2 === 0; // Stagger events top/bottom
return (
<Tooltip key={i}>
<TooltipTrigger asChild>
<div
className="absolute z-20 flex flex-col items-center group/event"
style={{
left: `${pct}%`,
top: '50%',
transform: 'translate(-50%, -50%)',
height: '100%'
}}
onClick={(e) => {
e.stopPropagation();
seekTo((new Date(event.timestamp).getTime() - startTime) / 1000);
}}
>
{/* The Stem */}
<div className={cn(
"w-px transition-all duration-200 bg-border group-hover/event:bg-primary group-hover/event:h-full",
isTop ? "h-8 mb-auto" : "h-8 mt-auto"
)} />
{/* The Node */}
<div className={cn(
"absolute w-6 h-6 rounded-full border shadow-sm flex items-center justify-center transition-transform hover:scale-110 cursor-pointer bg-background z-10",
getEventColor(event.eventType),
isTop ? "-top-2" : "-bottom-2"
)}>
{getEventIcon(event.eventType)}
</div>
</div>
</TooltipTrigger>
<TooltipContent side={isTop ? "top" : "bottom"}>
<div className="text-xs font-semibold uppercase tracking-wider mb-0.5">{event.eventType.replace(/_/g, " ")}</div>
<div className="text-[10px] font-mono opacity-70 mb-1">
{new Date(event.timestamp).toLocaleTimeString()}
</div>
{event.data && (
<div className="bg-muted/50 p-1 rounded font-mono text-[9px] max-w-[200px] break-all">
{JSON.stringify(event.data as object).slice(0, 100)}
</div>
)}
</TooltipContent>
</Tooltip>
);
})}
</div>
</TooltipProvider>
</div>
);
}

View File

@@ -0,0 +1,115 @@
"use client";
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
interface TrialEvent {
eventType: string;
timestamp: Date;
data?: unknown;
}
interface PlaybackContextType {
// State
currentTime: number;
duration: number;
isPlaying: boolean;
playbackRate: number;
startTime?: Date;
// Actions
play: () => void;
pause: () => void;
togglePlay: () => void;
seekTo: (time: number) => void;
setPlaybackRate: (rate: number) => void;
setDuration: (duration: number) => void;
setCurrentTime: (time: number) => void; // Used by VideoPlayer to update state
// Data
events: TrialEvent[];
currentEventIndex: number; // Index of the last event that happened before currentTime
}
const PlaybackContext = createContext<PlaybackContextType | null>(null);
export function usePlayback() {
const context = useContext(PlaybackContext);
if (!context) {
throw new Error("usePlayback must be used within a PlaybackProvider");
}
return context;
}
interface PlaybackProviderProps {
children: React.ReactNode;
events?: TrialEvent[];
startTime?: Date;
}
export function PlaybackProvider({ children, events = [], startTime }: PlaybackProviderProps) {
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [playbackRate, setPlaybackRate] = useState(1);
// Derived state: find the latest event index based on currentTime
const currentEventIndex = React.useMemo(() => {
if (!startTime || events.length === 0) return -1;
// Find the last event that occurred before or at currentTime
// Events are assumed to be sorted by timestamp
// Using basic iteration for now, optimization possible for large lists
let lastIndex = -1;
for (let i = 0; i < events.length; i++) {
const eventTime = new Date(events[i]!.timestamp).getTime();
const startStr = new Date(startTime).getTime();
const relativeSeconds = (eventTime - startStr) / 1000;
if (relativeSeconds <= currentTime) {
lastIndex = i;
} else {
break; // Events are sorted, so we can stop
}
}
return lastIndex;
}, [currentTime, events, startTime]);
// Actions
const play = () => setIsPlaying(true);
const pause = () => setIsPlaying(false);
const togglePlay = () => setIsPlaying(p => !p);
const seekTo = (time: number) => {
setCurrentTime(time);
// Dispatch seek event to video player via some mechanism if needed,
// usually VideoPlayer observes this context or we use a Ref to control it.
// Actually, simple way: Context holds state, VideoPlayer listens to state?
// No, VideoPlayer usually drives time.
// Let's assume VideoPlayer updates `setCurrentTime` as it plays.
// But if *we* seek (e.g. from timeline), we need to tell VideoPlayer to jump.
// We might need a `seekRequest` timestamp or similar signal.
};
const value: PlaybackContextType = {
currentTime,
duration,
isPlaying,
playbackRate,
play,
pause,
togglePlay,
seekTo,
setPlaybackRate,
setDuration,
setCurrentTime,
events,
currentEventIndex,
};
return (
<PlaybackContext.Provider value={value}>
{children}
</PlaybackContext.Provider>
);
}

View File

@@ -0,0 +1,154 @@
"use client";
import React, { useRef, useEffect } from "react";
import { usePlayback } from "./PlaybackContext";
import { AspectRatio } from "~/components/ui/aspect-ratio";
import { Loader2, Play, Pause, Volume2, VolumeX, Maximize } from "lucide-react";
import { Slider } from "~/components/ui/slider";
import { Button } from "~/components/ui/button";
interface PlaybackPlayerProps {
src: string;
}
export function PlaybackPlayer({ src }: PlaybackPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const {
currentTime,
isPlaying,
playbackRate,
setCurrentTime,
setDuration,
togglePlay,
play,
pause
} = usePlayback();
const [isBuffering, setIsBuffering] = React.useState(true);
const [volume, setVolume] = React.useState(1);
const [muted, setMuted] = React.useState(false);
// Sync Play/Pause state
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (isPlaying && video.paused) {
video.play().catch(console.error);
} else if (!isPlaying && !video.paused) {
video.pause();
}
}, [isPlaying]);
// Sync Playback Rate
useEffect(() => {
if (videoRef.current) {
videoRef.current.playbackRate = playbackRate;
}
}, [playbackRate]);
// Sync Seek (External seek request)
// Note: This is tricky because normal playback also updates currentTime.
// We need to differentiate between "time updated by video" and "time updated by user seek".
// For now, we'll let the video drive the context time, and rely on the Parent/Context
// to call a imperative sync if needed, or we implement a "seekRequest" signal in context.
// simpler: If context time differs significantly from video time, we seek.
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (Math.abs(video.currentTime - currentTime) > 0.5) {
video.currentTime = currentTime;
}
}, [currentTime]);
const handleTimeUpdate = () => {
if (videoRef.current) {
setCurrentTime(videoRef.current.currentTime);
}
};
const handleLoadedMetadata = () => {
if (videoRef.current) {
setDuration(videoRef.current.duration);
setIsBuffering(false);
}
};
const handleWaiting = () => setIsBuffering(true);
const handlePlaying = () => setIsBuffering(false);
const handleEnded = () => pause();
return (
<div className="group relative rounded-lg overflow-hidden border bg-black shadow-sm">
<AspectRatio ratio={16 / 9}>
<video
ref={videoRef}
src={src}
className="w-full h-full object-contain"
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onWaiting={handleWaiting}
onPlaying={handlePlaying}
onEnded={handleEnded}
onClick={togglePlay}
/>
{/* Overlay Controls (Visible on Hover/Pause) */}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4 opacity-0 transition-opacity group-hover:opacity-100 data-[paused=true]:opacity-100" data-paused={!isPlaying}>
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20"
onClick={togglePlay}
>
{isPlaying ? <Pause className="h-6 w-6" /> : <Play className="h-6 w-6 fill-current" />}
</Button>
<div className="flex-1">
<Slider
value={[currentTime]}
min={0}
max={videoRef.current?.duration || 100}
step={0.1}
onValueChange={([val]) => {
if (videoRef.current) {
videoRef.current.currentTime = val;
setCurrentTime(val);
}
}}
className="cursor-pointer"
/>
</div>
<div className="text-xs font-mono text-white/90">
{formatTime(currentTime)} / {formatTime(videoRef.current?.duration || 0)}
</div>
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20"
onClick={() => setMuted(!muted)}
>
{muted || volume === 0 ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
</Button>
</div>
</div>
{isBuffering && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20 pointer-events-none">
<Loader2 className="h-10 w-10 animate-spin text-white/80" />
</div>
)}
</AspectRatio>
</div>
);
}
function formatTime(seconds: number) {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}

View File

@@ -1,10 +1,18 @@
"use client"; "use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { LineChart, BarChart, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info } from "lucide-react";
import { LineChart, BarChart, Clock, Database, FileText } from "lucide-react"; import { PlaybackProvider } from "../playback/PlaybackContext";
import { formatDistanceToNow } from "date-fns"; import { PlaybackPlayer } from "../playback/PlaybackPlayer";
import { EventTimeline } from "../playback/EventTimeline";
import { api } from "~/trpc/react";
import { ScrollArea } from "~/components/ui/scroll-area";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/ui/resizable";
interface TrialAnalysisViewProps { interface TrialAnalysisViewProps {
trial: { trial: {
@@ -17,108 +25,165 @@ interface TrialAnalysisViewProps {
participant: { participantCode: string }; participant: { participantCode: string };
eventCount?: number; eventCount?: number;
mediaCount?: number; mediaCount?: number;
media?: { url: string; contentType: string }[];
}; };
} }
export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) { export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) {
// Fetch events for timeline
const { data: events = [] } = api.trials.getEvents.useQuery({
trialId: trial.id,
limit: 1000
});
const videoMedia = trial.media?.find(m => m.contentType.startsWith("video/"));
const videoUrl = videoMedia?.url;
return ( return (
<div className="container mx-auto p-6 space-y-6"> <PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="h-[calc(100vh-8rem)] flex flex-col bg-background rounded-lg border shadow-sm overflow-hidden">
<Card> {/* Header Context */}
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <div className="flex items-center justify-between p-3 border-b bg-muted/20 flex-none h-14">
<CardTitle className="text-sm font-medium">Status</CardTitle> <div className="flex items-center gap-4">
<Clock className="h-4 w-4 text-muted-foreground" /> <div className="flex flex-col">
</CardHeader> <h1 className="text-base font-semibold leading-none">
<CardContent> {trial.experiment.name}
<div className="text-2xl font-bold capitalize">{trial.status.replace("_", " ")}</div> </h1>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground mt-1">
{trial.completedAt {trial.participant.participantCode} Session {trial.id.slice(0, 4)}...
? `Completed ${formatDistanceToNow(new Date(trial.completedAt), { addSuffix: true })}` </p>
: "Not completed"}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Duration</CardTitle>
<BarChart className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{trial.duration ? `${Math.floor(trial.duration / 60)}m ${trial.duration % 60}s` : "N/A"}
</div> </div>
<p className="text-xs text-muted-foreground"> <div className="h-8 w-px bg-border" />
Total execution time <div className="flex items-center gap-3 text-xs text-muted-foreground">
</p> <div className="flex items-center gap-1.5">
</CardContent> <Clock className="h-3.5 w-3.5" />
</Card> <span>{trial.startedAt?.toLocaleDateString()} {trial.startedAt?.toLocaleTimeString()}</span>
</div>
{trial.duration && (
<Badge variant="secondary" className="text-[10px] font-mono">
{Math.floor(trial.duration / 60)}m {trial.duration % 60}s
</Badge>
)}
</div>
</div>
</div>
<Card> {/* Main Resizable Workspace */}
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <div className="flex-1 min-h-0">
<CardTitle className="text-sm font-medium">Events Logged</CardTitle> <ResizablePanelGroup direction="horizontal">
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{trial.eventCount ?? 0}</div>
<p className="text-xs text-muted-foreground">
System & user events
</p>
</CardContent>
</Card>
<Card> {/* LEFT: Video & Timeline */}
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <ResizablePanel defaultSize={65} minSize={30} className="flex flex-col min-h-0">
<CardTitle className="text-sm font-medium">Media Files</CardTitle> <ResizablePanelGroup direction="vertical">
<FileText className="h-4 w-4 text-muted-foreground" /> {/* Top: Video Player */}
</CardHeader> <ResizablePanel defaultSize={75} minSize={20} className="bg-black relative">
<CardContent> {videoUrl ? (
<div className="text-2xl font-bold">{trial.mediaCount ?? 0}</div> <div className="absolute inset-0">
<p className="text-xs text-muted-foreground"> <PlaybackPlayer src={videoUrl} />
Recordings & snapshots </div>
</p> ) : (
</CardContent> <div className="h-full w-full flex flex-col items-center justify-center text-slate-500">
</Card> <VideoOff className="h-12 w-12 mb-3 opacity-20" />
<p className="text-sm">No recording available.</p>
</div>
)}
</ResizablePanel>
<ResizableHandle withHandle />
{/* Bottom: Timeline Track */}
<ResizablePanel defaultSize={25} minSize={10} className="bg-background flex flex-col min-h-0">
<div className="p-2 border-b flex-none bg-muted/10 flex items-center gap-2">
<Info className="h-3 w-3 text-muted-foreground" />
<span className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Timeline Track</span>
</div>
<div className="flex-1 min-h-0 relative">
<div className="absolute inset-0 p-2 overflow-hidden">
<EventTimeline />
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
{/* RIGHT: Logs & Metrics */}
<ResizablePanel defaultSize={35} minSize={20} className="flex flex-col min-h-0 border-l bg-muted/5">
{/* Metrics Strip */}
<div className="grid grid-cols-2 gap-2 p-3 border-b bg-background flex-none">
<Card className="shadow-none border-dashed bg-transparent">
<CardContent className="p-3 py-2">
<div className="text-[10px] uppercase text-muted-foreground font-semibold mb-0.5">Interventions</div>
<div className="text-xl font-mono font-bold flex items-center gap-2">
{events.filter(e => e.eventType.includes("intervention")).length}
<AlertTriangle className="h-3.5 w-3.5 text-yellow-500" />
</div>
</CardContent>
</Card>
<Card className="shadow-none border-dashed bg-transparent">
<CardContent className="p-3 py-2">
<div className="text-[10px] uppercase text-muted-foreground font-semibold mb-0.5">Status</div>
<div className="text-xl font-mono font-bold flex items-center gap-2">
{trial.status === 'completed' ? 'PASS' : 'INC'}
<div className={`h-2 w-2 rounded-full ${trial.status === 'completed' ? 'bg-green-500' : 'bg-orange-500'}`} />
</div>
</CardContent>
</Card>
</div>
{/* Log Title */}
<div className="p-2 px-3 border-b bg-muted/20 flex items-center justify-between flex-none">
<span className="text-xs font-semibold flex items-center gap-2">
<FileText className="h-3.5 w-3.5 text-primary" />
Event Log
</span>
<Badge variant="outline" className="text-[10px] h-5">{events.length} Events</Badge>
</div>
{/* Scrollable Event List */}
<div className="flex-1 min-h-0 relative bg-background/50">
<ScrollArea className="h-full">
<div className="divide-y divide-border/50">
{events.map((event, i) => (
<div key={i} className="p-3 py-2 text-sm hover:bg-accent/50 transition-colors cursor-pointer group flex gap-3 items-start">
<div className="font-mono text-[10px] text-muted-foreground mt-0.5 min-w-[3rem]">
{formatTime(new Date(event.timestamp).getTime() - (trial.startedAt?.getTime() ?? 0))}
</div>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center justify-between">
<span className="font-medium text-xs text-foreground group-hover:text-primary transition-colors">
{event.eventType.replace(/_/g, " ")}
</span>
</div>
{event.data && (
<div className="text-[10px] text-muted-foreground bg-muted p-1.5 rounded border font-mono whitespace-pre-wrap break-all opacity-80 group-hover:opacity-100">
{JSON.stringify(event.data as object, null, 1).replace(/"/g, '').replace(/[{}]/g, '').trim()}
</div>
)}
</div>
</div>
))}
{events.length === 0 && (
<div className="p-8 text-center text-xs text-muted-foreground italic">
No events found in log.
</div>
)}
</div>
</ScrollArea>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div> </div>
</PlaybackProvider>
<Tabs defaultValue="overview" className="space-y-4">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="events">Event Log</TabsTrigger>
<TabsTrigger value="charts">Charts</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Analysis Overview</CardTitle>
<CardDescription>
Summary of trial execution for {trial.participant.participantCode} in experiment {trial.experiment.name}.
</CardDescription>
</CardHeader>
<CardContent className="h-[400px] flex items-center justify-center border-2 border-dashed rounded-md m-4">
<div className="text-center text-muted-foreground">
<LineChart className="h-10 w-10 mx-auto mb-2 opacity-20" />
<p>Detailed analysis visualizations coming soon.</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="events">
<Card>
<CardHeader>
<CardTitle>Event Log</CardTitle>
<CardDescription>
Chronological record of all trial events.
</CardDescription>
</CardHeader>
<CardContent className="h-[400px] flex items-center justify-center border-2 border-dashed rounded-md m-4">
<div className="text-center text-muted-foreground">
<p>Event log view placeholder.</p>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
); );
} }
function formatTime(ms: number) {
if (ms < 0) return "0:00";
const totalSeconds = Math.floor(ms / 1000);
const m = Math.floor(totalSeconds / 60);
const s = Math.floor(totalSeconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}

View File

@@ -1,366 +0,0 @@
"use client";
import {
Activity,
AlertTriangle,
Battery,
BatteryLow,
Bot,
CheckCircle,
Clock,
RefreshCw,
Signal,
SignalHigh,
SignalLow,
SignalMedium,
WifiOff,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Progress } from "~/components/ui/progress";
interface RobotStatusProps {
trialId: string;
}
interface RobotStatus {
id: string;
name: string;
connectionStatus: "connected" | "disconnected" | "connecting" | "error";
batteryLevel?: number;
signalStrength?: number;
currentMode: string;
lastHeartbeat?: Date;
errorMessage?: string;
capabilities: string[];
communicationProtocol: string;
isMoving: boolean;
position?: {
x: number;
y: number;
z?: number;
orientation?: number;
};
sensors?: Record<string, string>;
}
export function RobotStatus({ trialId: _trialId }: RobotStatusProps) {
const [robotStatus, setRobotStatus] = useState<RobotStatus | null>(null);
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
const [refreshing, setRefreshing] = useState(false);
// Mock robot status - in real implementation, this would come from API/WebSocket
useEffect(() => {
// Simulate robot status updates
const mockStatus: RobotStatus = {
id: "robot_001",
name: "TurtleBot3 Burger",
connectionStatus: "connected",
batteryLevel: 85,
signalStrength: 75,
currentMode: "autonomous_navigation",
lastHeartbeat: new Date(),
capabilities: ["navigation", "manipulation", "speech", "vision"],
communicationProtocol: "ROS2",
isMoving: false,
position: {
x: 1.2,
y: 0.8,
orientation: 45,
},
sensors: {
lidar: "operational",
camera: "operational",
imu: "operational",
odometry: "operational",
},
};
setRobotStatus(mockStatus);
// Simulate periodic updates
const interval = setInterval(() => {
setRobotStatus((prev) => {
if (!prev) return prev;
return {
...prev,
batteryLevel: Math.max(
0,
(prev.batteryLevel ?? 0) - Math.random() * 0.5,
),
signalStrength: Math.max(
0,
Math.min(
100,
(prev.signalStrength ?? 0) + (Math.random() - 0.5) * 10,
),
),
lastHeartbeat: new Date(),
position: prev.position
? {
...prev.position,
x: prev.position.x + (Math.random() - 0.5) * 0.1,
y: prev.position.y + (Math.random() - 0.5) * 0.1,
}
: undefined,
};
});
setLastUpdate(new Date());
}, 3000);
return () => clearInterval(interval);
}, []);
const getConnectionStatusConfig = (status: string) => {
switch (status) {
case "connected":
return {
icon: CheckCircle,
color: "text-green-600",
bgColor: "bg-green-100",
label: "Connected",
};
case "connecting":
return {
icon: RefreshCw,
color: "text-blue-600",
bgColor: "bg-blue-100",
label: "Connecting",
};
case "disconnected":
return {
icon: WifiOff,
color: "text-gray-600",
bgColor: "bg-gray-100",
label: "Disconnected",
};
case "error":
return {
icon: AlertTriangle,
color: "text-red-600",
bgColor: "bg-red-100",
label: "Error",
};
default:
return {
icon: WifiOff,
color: "text-gray-600",
bgColor: "bg-gray-100",
label: "Unknown",
};
}
};
const getSignalIcon = (strength: number) => {
if (strength >= 75) return SignalHigh;
if (strength >= 50) return SignalMedium;
if (strength >= 25) return SignalLow;
return Signal;
};
const getBatteryIcon = (level: number) => {
return level <= 20 ? BatteryLow : Battery;
};
const handleRefreshStatus = async () => {
setRefreshing(true);
// Simulate API call
setTimeout(() => {
setRefreshing(false);
setLastUpdate(new Date());
}, 1000);
};
if (!robotStatus) {
return (
<div className="space-y-4">
<div className="rounded-lg border p-4 text-center">
<div className="text-slate-500">
<Bot className="mx-auto mb-2 h-8 w-8 opacity-50" />
<p className="text-sm">No robot connected</p>
</div>
</div>
</div>
);
}
const statusConfig = getConnectionStatusConfig(robotStatus.connectionStatus);
const StatusIcon = statusConfig.icon;
const SignalIcon = getSignalIcon(robotStatus.signalStrength ?? 0);
const BatteryIcon = getBatteryIcon(robotStatus.batteryLevel ?? 0);
return (
<div className="space-y-4">
<div className="flex items-center justify-end">
<Button
variant="ghost"
size="sm"
onClick={handleRefreshStatus}
disabled={refreshing}
>
<RefreshCw
className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`}
/>
</Button>
</div>
{/* Main Status Card */}
<div className="rounded-lg border p-4">
<div className="space-y-3">
{/* Robot Info */}
<div className="flex items-center justify-between">
<div className="font-medium text-slate-900">{robotStatus.name}</div>
<Badge
className={`${statusConfig.bgColor} ${statusConfig.color}`}
variant="secondary"
>
<StatusIcon className="mr-1 h-3 w-3" />
{statusConfig.label}
</Badge>
</div>
{/* Connection Details */}
<div className="text-sm text-slate-600">
Protocol: {robotStatus.communicationProtocol}
</div>
{/* Status Indicators */}
<div className="grid grid-cols-2 gap-3">
{/* Battery */}
{robotStatus.batteryLevel !== undefined && (
<div className="space-y-1">
<div className="flex items-center space-x-1 text-xs text-slate-600">
<BatteryIcon className="h-3 w-3" />
<span>Battery</span>
</div>
<div className="flex items-center space-x-2">
<Progress
value={robotStatus.batteryLevel}
className="h-1.5 flex-1"
/>
<span className="w-8 text-xs font-medium">
{Math.round(robotStatus.batteryLevel)}%
</span>
</div>
</div>
)}
{/* Signal Strength */}
{robotStatus.signalStrength !== undefined && (
<div className="space-y-1">
<div className="flex items-center space-x-1 text-xs text-slate-600">
<SignalIcon className="h-3 w-3" />
<span>Signal</span>
</div>
<div className="flex items-center space-x-2">
<Progress
value={robotStatus.signalStrength}
className="h-1.5 flex-1"
/>
<span className="w-8 text-xs font-medium">
{Math.round(robotStatus.signalStrength)}%
</span>
</div>
</div>
)}
</div>
</div>
</div>
{/* Current Mode */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Activity className="h-3 w-3 text-slate-600" />
<span className="text-sm text-slate-600">Mode:</span>
</div>
<Badge variant="outline" className="text-xs">
{robotStatus.currentMode
.replace(/_/g, " ")
.replace(/\b\w/g, (l) => l.toUpperCase())}
</Badge>
</div>
{robotStatus.isMoving && (
<div className="mt-2 flex items-center space-x-1 text-xs">
<div className="h-1.5 w-1.5 animate-pulse rounded-full"></div>
<span>Robot is moving</span>
</div>
)}
</div>
{/* Position Info */}
{robotStatus.position && (
<div className="rounded-lg border p-4">
<div className="mb-3 text-sm font-medium text-slate-700">
Position
</div>
<div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex justify-between">
<span className="text-slate-600">X:</span>
<span className="font-mono">
{robotStatus.position.x.toFixed(2)}m
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Y:</span>
<span className="font-mono">
{robotStatus.position.y.toFixed(2)}m
</span>
</div>
{robotStatus.position.orientation !== undefined && (
<div className="col-span-2 flex justify-between">
<span className="text-slate-600">Orientation:</span>
<span className="font-mono">
{Math.round(robotStatus.position.orientation)}°
</span>
</div>
)}
</div>
</div>
</div>
)}
{/* Sensors Status */}
{robotStatus.sensors && (
<div className="rounded-lg border p-4">
<div className="mb-3 text-sm font-medium text-slate-700">Sensors</div>
<div>
<div className="space-y-1">
{Object.entries(robotStatus.sensors).map(([sensor, status]) => (
<div
key={sensor}
className="flex items-center justify-between text-xs"
>
<span className="text-slate-600 capitalize">{sensor}:</span>
<Badge variant="outline" className="text-xs">
{status}
</Badge>
</div>
))}
</div>
</div>
</div>
)}
{/* Error Alert */}
{robotStatus.errorMessage && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-sm">
{robotStatus.errorMessage}
</AlertDescription>
</Alert>
)}
{/* Last Update */}
<div className="flex items-center space-x-1 text-xs text-slate-500">
<Clock className="h-3 w-3" />
<span>Last update: {lastUpdate.toLocaleTimeString()}</span>
</div>
</div>
);
}

View File

@@ -195,21 +195,28 @@ export const WizardInterface = React.memo(function WizardInterface({
} }
); );
// Update local trial state from polling // Update local trial state from polling only if changed
useEffect(() => { useEffect(() => {
if (pollingData) { if (pollingData && JSON.stringify(pollingData) !== JSON.stringify(trial)) {
setTrial((prev) => ({ // Only update if specific fields we care about have changed to avoid
...prev, // unnecessary re-renders that might cause UI flashing
status: pollingData.status, if (pollingData.status !== trial.status ||
startedAt: pollingData.startedAt pollingData.startedAt?.getTime() !== trial.startedAt?.getTime() ||
? new Date(pollingData.startedAt) pollingData.completedAt?.getTime() !== trial.completedAt?.getTime()) {
: prev.startedAt,
completedAt: pollingData.completedAt setTrial((prev) => ({
? new Date(pollingData.completedAt) ...prev,
: prev.completedAt, status: pollingData.status,
})); startedAt: pollingData.startedAt
? new Date(pollingData.startedAt)
: prev.startedAt,
completedAt: pollingData.completedAt
? new Date(pollingData.completedAt)
: prev.completedAt,
}));
}
} }
}, [pollingData]); }, [pollingData, trial]);
// Auto-start trial on mount if scheduled // Auto-start trial on mount if scheduled
useEffect(() => { useEffect(() => {
@@ -675,6 +682,7 @@ export const WizardInterface = React.memo(function WizardInterface({
onTabChange={setControlPanelTab} onTabChange={setControlPanelTab}
isStarting={startTrialMutation.isPending} isStarting={startTrialMutation.isPending}
onSetAutonomousLife={setAutonomousLife} onSetAutonomousLife={setAutonomousLife}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
/> />
} }
center={ center={
@@ -695,6 +703,7 @@ export const WizardInterface = React.memo(function WizardInterface({
completedActionsCount={completedActionsCount} completedActionsCount={completedActionsCount}
onActionCompleted={() => setCompletedActionsCount(c => c + 1)} onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
onCompleteTrial={handleCompleteTrial} onCompleteTrial={handleCompleteTrial}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
/> />
} }
right={ right={
@@ -706,6 +715,7 @@ export const WizardInterface = React.memo(function WizardInterface({
connectRos={connectRos} connectRos={connectRos}
disconnectRos={disconnectRos} disconnectRos={disconnectRos}
executeRosAction={executeRosAction} executeRosAction={executeRosAction}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
/> />
} }
showDividers={true} showDividers={true}
@@ -720,6 +730,9 @@ export const WizardInterface = React.memo(function WizardInterface({
onAddAnnotation={handleAddAnnotation} onAddAnnotation={handleAddAnnotation}
isSubmitting={addAnnotationMutation.isPending} isSubmitting={addAnnotationMutation.isPending}
trialEvents={trialEvents} trialEvents={trialEvents}
// Observation pane is where observers usually work, so not readOnly for them?
// But maybe we want 'readOnly' for completed trials.
readOnly={trial.status === 'completed'}
/> />
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>

View File

@@ -0,0 +1,268 @@
"use client";
import React, { useCallback, useRef, useState } from "react";
import Webcam from "react-webcam";
import { Camera, CameraOff, Video, StopCircle, Loader2 } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { AspectRatio } from "~/components/ui/aspect-ratio";
import { toast } from "sonner";
import { api } from "~/trpc/react";
export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
const [deviceId, setDeviceId] = useState<string | null>(null);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [isCameraEnabled, setIsCameraEnabled] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const webcamRef = useRef<Webcam>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
// TRPC mutation for presigned URL
const getUploadUrlMutation = api.storage.getUploadPresignedUrl.useMutation();
const handleDevices = useCallback(
(mediaDevices: MediaDeviceInfo[]) => {
setDevices(mediaDevices.filter(({ kind, deviceId }) => kind === "videoinput" && deviceId !== ""));
},
[setDevices],
);
React.useEffect(() => {
navigator.mediaDevices.enumerateDevices().then(handleDevices);
}, [handleDevices]);
const handleEnableCamera = () => {
setIsCameraEnabled(true);
setError(null);
};
const handleDisableCamera = () => {
if (isRecording) {
handleStopRecording();
}
setIsCameraEnabled(false);
};
const handleStartRecording = () => {
if (!webcamRef.current?.stream) return;
setIsRecording(true);
chunksRef.current = [];
try {
const recorder = new MediaRecorder(webcamRef.current.stream, {
mimeType: "video/webm"
});
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
recorder.onstop = async () => {
const blob = new Blob(chunksRef.current, { type: "video/webm" });
await handleUpload(blob);
};
recorder.start();
mediaRecorderRef.current = recorder;
toast.success("Recording started");
} catch (e) {
console.error("Failed to start recorder:", e);
toast.error("Failed to start recording");
setIsRecording(false);
}
};
const handleStopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
}
};
const handleUpload = async (blob: Blob) => {
setUploading(true);
const filename = `recording-${Date.now()}.webm`;
try {
// 1. Get Presigned URL
const { url } = await getUploadUrlMutation.mutateAsync({
filename,
contentType: "video/webm",
});
// 2. Upload to S3
const response = await fetch(url, {
method: "PUT",
body: blob,
headers: {
"Content-Type": "video/webm",
},
});
if (!response.ok) {
throw new Error("Upload failed");
}
toast.success("Recording uploaded successfully");
console.log("Uploaded recording:", filename);
} catch (e) {
console.error("Upload error:", e);
toast.error("Failed to upload recording");
} finally {
setUploading(false);
}
};
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b p-3">
<h2 className="text-sm font-semibold flex items-center gap-2">
<Camera className="h-4 w-4" />
Webcam Feed
</h2>
{!readOnly && (
<div className="flex items-center gap-2">
{devices.length > 0 && (
<Select
value={deviceId ?? undefined}
onValueChange={setDeviceId}
disabled={!isCameraEnabled || isRecording}
>
<SelectTrigger className="h-7 w-[130px] text-xs">
<SelectValue placeholder="Select Camera" />
</SelectTrigger>
<SelectContent>
{devices.map((device, key) => (
<SelectItem key={key} value={device.deviceId} className="text-xs">
{device.label || `Camera ${key + 1}`}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{isCameraEnabled && (
!isRecording ? (
<Button
variant="destructive"
size="sm"
className="h-7 px-2 text-xs animate-in fade-in"
onClick={handleStartRecording}
disabled={uploading}
>
<Video className="mr-1 h-3 w-3" />
Record
</Button>
) : (
<Button
variant="secondary"
size="sm"
className="h-7 px-2 text-xs border-red-500 border text-red-500 hover:bg-red-50"
onClick={handleStopRecording}
>
<StopCircle className="mr-1 h-3 w-3 animate-pulse" />
Stop Rec
</Button>
)
)}
{isCameraEnabled ? (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={handleDisableCamera}
disabled={isRecording}
>
<CameraOff className="mr-1 h-3 w-3" />
Off
</Button>
) : (
<Button
variant="default"
size="sm"
className="h-7 px-2 text-xs"
onClick={handleEnableCamera}
>
<Camera className="mr-1 h-3 w-3" />
Start Camera
</Button>
)}
</div>
)}
</div>
<div className="flex-1 overflow-hidden bg-black p-4 flex items-center justify-center relative">
{isCameraEnabled ? (
<div className="w-full relative rounded-lg overflow-hidden border border-slate-800">
<AspectRatio ratio={16 / 9}>
<Webcam
ref={webcamRef}
audio={false}
width="100%"
height="100%"
videoConstraints={{ deviceId: deviceId ?? undefined }}
onUserMediaError={(err) => setError(String(err))}
className="object-contain w-full h-full"
/>
</AspectRatio>
{/* Recording Overlay */}
{isRecording && (
<div className="absolute top-2 right-2 flex items-center gap-2 bg-black/50 px-2 py-1 rounded-full backdrop-blur-sm">
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
<span className="text-[10px] font-medium text-white">REC</span>
</div>
)}
{/* Uploading Overlay */}
{uploading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="flex flex-col items-center gap-2 text-white">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-xs font-medium">Uploading...</span>
</div>
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-black/80">
<Alert variant="destructive" className="max-w-xs">
<AlertDescription>{error}</AlertDescription>
</Alert>
</div>
)}
</div>
) : (
<div className="text-center text-slate-500">
<CameraOff className="mx-auto mb-2 h-12 w-12 opacity-20" />
<p className="text-sm">Camera is disabled</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={handleEnableCamera}
>
Enable Camera
</Button>
</div>
)}
</div>
</div>
);
}

View File

@@ -98,6 +98,7 @@ interface WizardControlPanelProps {
onTabChange: (tab: "control" | "step" | "actions" | "robot") => void; onTabChange: (tab: "control" | "step" | "actions" | "robot") => void;
isStarting?: boolean; isStarting?: boolean;
onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>; onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>;
readOnly?: boolean;
} }
export function WizardControlPanel({ export function WizardControlPanel({
@@ -118,6 +119,7 @@ export function WizardControlPanel({
onTabChange, onTabChange,
isStarting = false, isStarting = false,
onSetAutonomousLife, onSetAutonomousLife,
readOnly = false,
}: WizardControlPanelProps) { }: WizardControlPanelProps) {
const [autonomousLife, setAutonomousLife] = React.useState(true); const [autonomousLife, setAutonomousLife] = React.useState(true);
@@ -187,7 +189,7 @@ export function WizardControlPanel({
}} }}
className="w-full" className="w-full"
size="sm" size="sm"
disabled={isStarting} disabled={isStarting || readOnly}
> >
<Play className="mr-2 h-4 w-4" /> <Play className="mr-2 h-4 w-4" />
{isStarting ? "Starting..." : "Start Trial"} {isStarting ? "Starting..." : "Start Trial"}
@@ -201,14 +203,14 @@ export function WizardControlPanel({
onClick={onPauseTrial} onClick={onPauseTrial}
variant="outline" variant="outline"
size="sm" size="sm"
disabled={false} disabled={readOnly}
> >
<Pause className="mr-1 h-3 w-3" /> <Pause className="mr-1 h-3 w-3" />
Pause Pause
</Button> </Button>
<Button <Button
onClick={onNextStep} onClick={onNextStep}
disabled={currentStepIndex >= steps.length - 1} disabled={(currentStepIndex >= steps.length - 1) || readOnly}
size="sm" size="sm"
> >
<SkipForward className="mr-1 h-3 w-3" /> <SkipForward className="mr-1 h-3 w-3" />
@@ -223,6 +225,7 @@ export function WizardControlPanel({
variant="outline" variant="outline"
className="w-full" className="w-full"
size="sm" size="sm"
disabled={readOnly}
> >
<CheckCircle className="mr-2 h-4 w-4" /> <CheckCircle className="mr-2 h-4 w-4" />
Complete Trial Complete Trial
@@ -233,6 +236,7 @@ export function WizardControlPanel({
variant="destructive" variant="destructive"
className="w-full" className="w-full"
size="sm" size="sm"
disabled={readOnly}
> >
<X className="mr-2 h-4 w-4" /> <X className="mr-2 h-4 w-4" />
Abort Trial Abort Trial
@@ -277,7 +281,7 @@ export function WizardControlPanel({
id="autonomous-life" id="autonomous-life"
checked={autonomousLife} checked={autonomousLife}
onCheckedChange={handleAutonomousLifeChange} onCheckedChange={handleAutonomousLifeChange}
disabled={!_isConnected} disabled={!_isConnected || readOnly}
className="scale-75" className="scale-75"
/> />
</div> </div>
@@ -368,7 +372,7 @@ export function WizardControlPanel({
console.log("[WizardControlPanel] Acknowledge clicked"); console.log("[WizardControlPanel] Acknowledge clicked");
onExecuteAction("acknowledge"); onExecuteAction("acknowledge");
}} }}
disabled={false} disabled={readOnly}
> >
<CheckCircle className="mr-2 h-3 w-3" /> <CheckCircle className="mr-2 h-3 w-3" />
Acknowledge Acknowledge
@@ -382,7 +386,7 @@ export function WizardControlPanel({
console.log("[WizardControlPanel] Intervene clicked"); console.log("[WizardControlPanel] Intervene clicked");
onExecuteAction("intervene"); onExecuteAction("intervene");
}} }}
disabled={false} disabled={readOnly}
> >
<AlertCircle className="mr-2 h-3 w-3" /> <AlertCircle className="mr-2 h-3 w-3" />
Intervene Intervene
@@ -396,7 +400,7 @@ export function WizardControlPanel({
console.log("[WizardControlPanel] Add Note clicked"); console.log("[WizardControlPanel] Add Note clicked");
onExecuteAction("note", { content: "Wizard note" }); onExecuteAction("note", { content: "Wizard note" });
}} }}
disabled={false} disabled={readOnly}
> >
<User className="mr-2 h-3 w-3" /> <User className="mr-2 h-3 w-3" />
Add Note Add Note
@@ -412,7 +416,7 @@ export function WizardControlPanel({
size="sm" size="sm"
className="w-full justify-start" className="w-full justify-start"
onClick={() => onExecuteAction("step_complete")} onClick={() => onExecuteAction("step_complete")}
disabled={false} disabled={readOnly}
> >
<CheckCircle className="mr-2 h-3 w-3" /> <CheckCircle className="mr-2 h-3 w-3" />
Mark Complete Mark Complete
@@ -441,11 +445,13 @@ export function WizardControlPanel({
<ScrollArea className="h-full"> <ScrollArea className="h-full">
<div className="p-3"> <div className="p-3">
{studyId && onExecuteRobotAction ? ( {studyId && onExecuteRobotAction ? (
<RobotActionsPanel <div className={readOnly ? "pointer-events-none opacity-50" : ""}>
studyId={studyId} <RobotActionsPanel
trialId={trial.id} studyId={studyId}
onExecuteAction={onExecuteRobotAction} trialId={trial.id}
/> onExecuteAction={onExecuteRobotAction}
/>
</div>
) : ( ) : (
<Alert> <Alert>
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />

View File

@@ -10,18 +10,13 @@ import {
User, User,
Activity, Activity,
Zap, Zap,
Eye,
List,
Loader2,
ArrowRight, ArrowRight,
AlertTriangle, AlertTriangle,
RotateCcw, RotateCcw,
} from "lucide-react"; } from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import { ScrollArea } from "~/components/ui/scroll-area"; import { ScrollArea } from "~/components/ui/scroll-area";
import { Alert, AlertDescription } from "~/components/ui/alert";
interface StepData { interface StepData {
id: string; id: string;
@@ -107,6 +102,7 @@ interface WizardExecutionPanelProps {
onCompleteTrial?: () => void; onCompleteTrial?: () => void;
completedActionsCount: number; completedActionsCount: number;
onActionCompleted: () => void; onActionCompleted: () => void;
readOnly?: boolean;
} }
export function WizardExecutionPanel({ export function WizardExecutionPanel({
@@ -126,47 +122,13 @@ export function WizardExecutionPanel({
onCompleteTrial, onCompleteTrial,
completedActionsCount, completedActionsCount,
onActionCompleted, onActionCompleted,
readOnly = false,
}: WizardExecutionPanelProps) { }: WizardExecutionPanelProps) {
// Local state removed in favor of parent state to prevent reset on re-render // Local state removed in favor of parent state to prevent reset on re-render
// const [completedCount, setCompletedCount] = React.useState(0); // const [completedCount, setCompletedCount] = React.useState(0);
const activeActionIndex = completedActionsCount; const activeActionIndex = completedActionsCount;
const getStepIcon = (type: string) => {
switch (type) {
case "wizard_action":
return User;
case "robot_action":
return Bot;
case "parallel_steps":
return Activity;
case "conditional_branch":
return AlertCircle;
default:
return Play;
}
};
const getStepStatus = (stepIndex: number) => {
if (stepIndex < currentStepIndex) return "completed";
if (stepIndex === currentStepIndex && trial.status === "in_progress")
return "active";
return "pending";
};
const getStepVariant = (status: string) => {
switch (status) {
case "completed":
return "default";
case "active":
return "secondary";
case "pending":
return "outline";
default:
return "outline";
}
};
// Pre-trial state // Pre-trial state
if (trial.status === "scheduled") { if (trial.status === "scheduled") {
return ( return (
@@ -252,7 +214,7 @@ export function WizardExecutionPanel({
</div> </div>
{/* Simplified Content - Sequential Focus */} {/* Simplified Content - Sequential Focus */}
<div className="flex-1 overflow-hidden"> <div className="relative flex-1 overflow-hidden">
<ScrollArea className="h-full"> <ScrollArea className="h-full">
{currentStep ? ( {currentStep ? (
<div className="flex flex-col gap-6 p-6"> <div className="flex flex-col gap-6 p-6">
@@ -281,7 +243,6 @@ export function WizardExecutionPanel({
{currentStep.actions.map((action, idx) => { {currentStep.actions.map((action, idx) => {
const isCompleted = idx < activeActionIndex; const isCompleted = idx < activeActionIndex;
const isActive = idx === activeActionIndex; const isActive = idx === activeActionIndex;
const isPending = idx > activeActionIndex;
return ( return (
<div <div
@@ -328,6 +289,7 @@ export function WizardExecutionPanel({
); );
onActionCompleted(); onActionCompleted();
}} }}
disabled={readOnly}
> >
Skip Skip
</Button> </Button>
@@ -348,6 +310,7 @@ export function WizardExecutionPanel({
); );
onActionCompleted(); onActionCompleted();
}} }}
disabled={readOnly || isExecuting}
> >
<Play className="mr-2 h-4 w-4" /> <Play className="mr-2 h-4 w-4" />
Execute Execute
@@ -364,6 +327,7 @@ export function WizardExecutionPanel({
e.preventDefault(); e.preventDefault();
onActionCompleted(); onActionCompleted();
}} }}
disabled={readOnly || isExecuting}
> >
Mark Done Mark Done
</Button> </Button>
@@ -394,6 +358,7 @@ export function WizardExecutionPanel({
{ autoAdvance: false }, { autoAdvance: false },
); );
}} }}
disabled={readOnly || isExecuting}
> >
<RotateCcw className="h-3.5 w-3.5" /> <RotateCcw className="h-3.5 w-3.5" />
</Button> </Button>
@@ -410,6 +375,7 @@ export function WizardExecutionPanel({
category: "system_issue" category: "system_issue"
}); });
}} }}
disabled={readOnly}
> >
<AlertTriangle className="h-3.5 w-3.5" /> <AlertTriangle className="h-3.5 w-3.5" />
</Button> </Button>
@@ -432,6 +398,7 @@ export function WizardExecutionPanel({
? "bg-blue-600 hover:bg-blue-700" ? "bg-blue-600 hover:bg-blue-700"
: "bg-green-600 hover:bg-green-700" : "bg-green-600 hover:bg-green-700"
}`} }`}
disabled={readOnly || isExecuting}
> >
{currentStepIndex === steps.length - 1 ? "Complete Trial" : "Complete Step"} {currentStepIndex === steps.length - 1 ? "Complete Trial" : "Complete Step"}
<ArrowRight className="ml-2 h-5 w-5" /> <ArrowRight className="ml-2 h-5 w-5" />
@@ -445,22 +412,15 @@ export function WizardExecutionPanel({
{currentStep.type === "wizard_action" && ( {currentStep.type === "wizard_action" && (
<div className="rounded-xl border border-dashed p-6 space-y-4"> <div className="rounded-xl border border-dashed p-6 space-y-4">
<h3 className="text-sm font-medium text-muted-foreground">Manual Controls</h3> <h3 className="text-sm font-medium text-muted-foreground">Manual Controls</h3>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-1 gap-3">
<Button <Button
variant="outline" variant="outline"
className="h-12 justify-start" className="h-12 justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800"
onClick={() => onExecuteAction("acknowledge")}
>
<CheckCircle className="mr-2 h-4 w-4" />
Acknowledge
</Button>
<Button
variant="outline"
className="h-12 justify-start"
onClick={() => onExecuteAction("intervene")} onClick={() => onExecuteAction("intervene")}
disabled={readOnly}
> >
<Zap className="mr-2 h-4 w-4" /> <Zap className="mr-2 h-4 w-4" />
Intervene Flag Issue / Intervention
</Button> </Button>
</div> </div>
</div> </div>
@@ -472,6 +432,8 @@ export function WizardExecutionPanel({
</div> </div>
)} )}
</ScrollArea> </ScrollArea>
{/* Scroll Hint Fade */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-background to-transparent z-10" />
</div> </div>
</div > </div >
); );

View File

@@ -11,8 +11,8 @@ import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import { ScrollArea } from "~/components/ui/scroll-area"; import { ScrollArea } from "~/components/ui/scroll-area";
import { Alert, AlertDescription } from "~/components/ui/alert"; import { Alert, AlertDescription } from "~/components/ui/alert";
import { Progress } from "~/components/ui/progress";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { WebcamPanel } from "./WebcamPanel";
interface WizardMonitoringPanelProps { interface WizardMonitoringPanelProps {
rosConnected: boolean; rosConnected: boolean;
@@ -33,6 +33,7 @@ interface WizardMonitoringPanelProps {
actionId: string, actionId: string,
parameters: Record<string, unknown>, parameters: Record<string, unknown>,
) => Promise<unknown>; ) => Promise<unknown>;
readOnly?: boolean;
} }
const WizardMonitoringPanel = function WizardMonitoringPanel({ const WizardMonitoringPanel = function WizardMonitoringPanel({
@@ -43,296 +44,315 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
connectRos, connectRos,
disconnectRos, disconnectRos,
executeRosAction, executeRosAction,
readOnly = false,
}: WizardMonitoringPanelProps) { }: WizardMonitoringPanelProps) {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col gap-2 p-2">
{/* Header */} {/* Camera View - Always Visible */}
<div className="flex items-center justify-between border-b p-3"> <div className="shrink-0 bg-black rounded-lg overflow-hidden border shadow-sm h-48 sm:h-56 relative group">
<h2 className="text-sm font-semibold">Robot Control</h2> <WebcamPanel readOnly={readOnly} />
</div> </div>
{/* Robot Status and Controls */} {/* Robot Controls - Scrollable */}
<ScrollArea className="flex-1"> <div className="flex-1 min-h-0 bg-background rounded-lg border shadow-sm overflow-hidden flex flex-col">
<div className="space-y-4 p-3"> <div className="px-3 py-2 border-b bg-muted/30 flex items-center gap-2">
{/* Robot Status */} <Bot className="h-4 w-4 text-muted-foreground" />
<div className="space-y-2"> <span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Robot Control</span>
<div className="flex items-center justify-between"> </div>
<div className="text-sm font-medium">Robot Status</div> <ScrollArea className="flex-1">
<div className="flex items-center gap-1"> <div className="space-y-4 p-3">
{rosConnected ? ( {/* Robot Status */}
<Power className="h-3 w-3 text-green-600" />
) : (
<PowerOff className="h-3 w-3 text-gray-400" />
)}
</div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs"> <div className="text-sm font-medium">Robot Status</div>
ROS Bridge
</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Badge {rosConnected ? (
variant={ <Power className="h-3 w-3 text-green-600" />
rosConnected ) : (
? "default" <PowerOff className="h-3 w-3 text-gray-400" />
: rosError
? "destructive"
: "outline"
}
className="text-xs"
>
{rosConnecting
? "Connecting..."
: rosConnected
? "Ready"
: rosError
? "Failed"
: "Offline"}
</Badge>
{rosConnected && (
<span className="animate-pulse text-xs text-green-600">
</span>
)}
{rosConnecting && (
<span className="animate-spin text-xs text-blue-600">
</span>
)} )}
</div> </div>
</div> </div>
</div> <div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
ROS Bridge
</span>
<div className="flex items-center gap-1">
<Badge
variant={
rosConnected
? "default"
: rosError
? "destructive"
: "outline"
}
className="text-xs"
>
{rosConnecting
? "Connecting..."
: rosConnected
? "Ready"
: rosError
? "Failed"
: "Offline"}
</Badge>
{rosConnected && (
<span className="animate-pulse text-xs text-green-600">
</span>
)}
{rosConnecting && (
<span className="animate-spin text-xs text-blue-600">
</span>
)}
</div>
</div>
</div>
{/* ROS Connection Controls */} {/* ROS Connection Controls */}
<div className="pt-2"> <div className="pt-2">
{!rosConnected ? ( {!rosConnected ? (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="w-full text-xs" className="w-full text-xs"
onClick={() => connectRos()} onClick={() => connectRos()}
disabled={rosConnecting || rosConnected} disabled={rosConnecting || rosConnected || readOnly}
> >
<Bot className="mr-1 h-3 w-3" /> <Bot className="mr-1 h-3 w-3" />
{rosConnecting {rosConnecting
? "Connecting..." ? "Connecting..."
: rosConnected : rosConnected
? "Connected ✓" ? "Connected ✓"
: "Connect to NAO6"} : "Connect to NAO6"}
</Button> </Button>
) : ( ) : (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="w-full text-xs" className="w-full text-xs"
onClick={() => disconnectRos()} onClick={() => disconnectRos()}
> disabled={readOnly}
<PowerOff className="mr-1 h-3 w-3" /> >
Disconnect <PowerOff className="mr-1 h-3 w-3" />
</Button> Disconnect
</Button>
)}
</div>
{rosError && (
<Alert variant="destructive" className="mt-2">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
{rosError}
</AlertDescription>
</Alert>
)}
{!rosConnected && !rosConnecting && (
<div className="mt-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
Connect to ROS bridge for live robot monitoring and
control.
</AlertDescription>
</Alert>
</div>
)} )}
</div> </div>
{rosError && ( <Separator />
<Alert variant="destructive" className="mt-2">
<AlertCircle className="h-4 w-4" /> {/* Movement Controls */}
<AlertDescription className="text-xs"> {rosConnected && (
{rosError} <div className="space-y-2">
</AlertDescription> <div className="text-sm font-medium">Movement</div>
</Alert> <div className="grid grid-cols-3 gap-2">
{/* Row 1: Turn Left, Forward, Turn Right */}
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "turn_left", {
speed: 0.3,
}).catch(console.error);
}}
disabled={readOnly}
>
Turn L
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "walk_forward", {
speed: 0.5,
}).catch(console.error);
}}
disabled={readOnly}
>
Forward
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "turn_right", {
speed: 0.3,
}).catch(console.error);
}}
disabled={readOnly}
>
Turn R
</Button>
{/* Row 2: Left, Stop, Right */}
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "strafe_left", {
speed: 0.3,
}).catch(console.error);
}}
disabled={readOnly}
>
Left
</Button>
<Button
size="sm"
variant="destructive"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "emergency_stop", {}).catch(
console.error,
);
}}
disabled={readOnly}
>
Stop
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "strafe_right", {
speed: 0.3,
}).catch(console.error);
}}
disabled={readOnly}
>
Right
</Button>
{/* Row 3: Empty, Back, Empty */}
<div></div>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "walk_backward", {
speed: 0.3,
}).catch(console.error);
}}
disabled={readOnly}
>
Back
</Button>
<div></div>
</div>
</div>
)} )}
{!rosConnected && !rosConnecting && ( <Separator />
<div className="mt-4">
<Alert> {/* Quick Actions */}
<AlertCircle className="h-4 w-4" /> {rosConnected && (
<AlertDescription className="text-xs"> <div className="space-y-2">
Connect to ROS bridge for live robot monitoring and <div className="text-sm font-medium">Quick Actions</div>
control.
</AlertDescription> {/* TTS Input */}
</Alert> <div className="flex gap-2">
<input
type="text"
placeholder="Type text to speak..."
className="flex-1 rounded-md border border-input bg-background px-2 py-1 text-xs ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
disabled={readOnly}
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value.trim() && !readOnly) {
executeRosAction("nao6-ros2", "say_text", {
text: e.currentTarget.value.trim(),
}).catch(console.error);
e.currentTarget.value = "";
}
}}
/>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={(e) => {
const input = e.currentTarget.previousElementSibling as HTMLInputElement;
if (input?.value.trim()) {
executeRosAction("nao6-ros2", "say_text", {
text: input.value.trim(),
}).catch(console.error);
input.value = "";
}
}}
disabled={readOnly}
>
Say
</Button>
</div>
{/* Preset Actions */}
<div className="grid grid-cols-2 gap-2">
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "say_text", {
text: "Hello! I am NAO!",
}).catch(console.error);
}
}}
disabled={readOnly}
>
Say Hello
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "say_text", {
text: "I am ready!",
}).catch(console.error);
}
}}
disabled={readOnly}
>
Say Ready
</Button>
</div>
</div> </div>
)} )}
</div> </div>
</ScrollArea>
<Separator /> </div>
{/* Movement Controls */}
{rosConnected && (
<div className="space-y-2">
<div className="text-sm font-medium">Movement</div>
<div className="grid grid-cols-3 gap-2">
{/* Row 1: Turn Left, Forward, Turn Right */}
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "turn_left", {
speed: 0.3,
}).catch(console.error);
}}
>
Turn L
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "walk_forward", {
speed: 0.5,
}).catch(console.error);
}}
>
Forward
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "turn_right", {
speed: 0.3,
}).catch(console.error);
}}
>
Turn R
</Button>
{/* Row 2: Left, Stop, Right */}
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "strafe_left", {
speed: 0.3,
}).catch(console.error);
}}
>
Left
</Button>
<Button
size="sm"
variant="destructive"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "emergency_stop", {}).catch(
console.error,
);
}}
>
Stop
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "strafe_right", {
speed: 0.3,
}).catch(console.error);
}}
>
Right
</Button>
{/* Row 3: Empty, Back, Empty */}
<div></div>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "walk_backward", {
speed: 0.3,
}).catch(console.error);
}}
>
Back
</Button>
<div></div>
</div>
</div>
)}
<Separator />
{/* Quick Actions */}
{rosConnected && (
<div className="space-y-2">
<div className="text-sm font-medium">Quick Actions</div>
{/* TTS Input */}
<div className="flex gap-2">
<input
type="text"
placeholder="Type text to speak..."
className="flex-1 rounded-md border border-input bg-background px-2 py-1 text-xs ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value.trim()) {
executeRosAction("nao6-ros2", "say_text", {
text: e.currentTarget.value.trim(),
}).catch(console.error);
e.currentTarget.value = "";
}
}}
/>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={(e) => {
const input = e.currentTarget.previousElementSibling as HTMLInputElement;
if (input?.value.trim()) {
executeRosAction("nao6-ros2", "say_text", {
text: input.value.trim(),
}).catch(console.error);
input.value = "";
}
}}
>
Say
</Button>
</div>
{/* Preset Actions */}
<div className="grid grid-cols-2 gap-2">
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "say_text", {
text: "Hello! I am NAO!",
}).catch(console.error);
}
}}
>
Say Hello
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "say_text", {
text: "I am ready!",
}).catch(console.error);
}
}}
>
Say Ready
</Button>
</div>
</div>
)}
</div>
</ScrollArea>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,11 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }

View File

@@ -64,6 +64,7 @@ export interface CompiledExecutionAction {
parameterSchemaRaw?: unknown; parameterSchemaRaw?: unknown;
timeout?: number; timeout?: number;
retryable?: boolean; retryable?: boolean;
children?: CompiledExecutionAction[];
} }
/* ---------- Compile Entry Point ---------- */ /* ---------- Compile Entry Point ---------- */
@@ -136,11 +137,12 @@ function compileAction(
robotId: action.source.robotId, robotId: action.source.robotId,
baseActionId: action.source.baseActionId, baseActionId: action.source.baseActionId,
}, },
execution: action.execution, execution: action.execution!, // Assumes validation passed
parameters: action.parameters, parameters: action.parameters,
parameterSchemaRaw: action.parameterSchemaRaw, parameterSchemaRaw: action.parameterSchemaRaw,
timeout: action.execution.timeoutMs, timeout: action.execution?.timeoutMs,
retryable: action.execution.retryable, retryable: action.execution?.retryable,
children: action.children?.map((child, i) => compileAction(child, i)),
}; };
} }
@@ -149,17 +151,24 @@ function compileAction(
export function collectPluginDependencies(design: ExperimentDesign): string[] { export function collectPluginDependencies(design: ExperimentDesign): string[] {
const set = new Set<string>(); const set = new Set<string>();
for (const step of design.steps) { for (const step of design.steps) {
for (const action of step.actions) { collectDependenciesFromActions(step.actions, set);
if (action.source.kind === "plugin" && action.source.pluginId) {
const versionPart = action.source.pluginVersion
? `@${action.source.pluginVersion}`
: "";
set.add(`${action.source.pluginId}${versionPart}`);
}
}
} }
return Array.from(set).sort(); return Array.from(set).sort();
} }
// Helper to recursively collect from actions list directly would be cleaner
function collectDependenciesFromActions(actions: ExperimentAction[], set: Set<string>) {
for (const action of actions) {
if (action.source.kind === "plugin" && action.source.pluginId) {
const versionPart = action.source.pluginVersion
? `@${action.source.pluginVersion}`
: "";
set.add(`${action.source.pluginId}${versionPart}`);
}
if (action.children) {
collectDependenciesFromActions(action.children, set);
}
}
}
/* ---------- Integrity Hash Generation ---------- */ /* ---------- Integrity Hash Generation ---------- */
@@ -199,6 +208,12 @@ function buildStructuralSignature(
timeout: a.timeout, timeout: a.timeout,
retryable: a.retryable ?? false, retryable: a.retryable ?? false,
parameterKeys: summarizeParametersForHash(a.parameters), parameterKeys: summarizeParametersForHash(a.parameters),
children: a.children?.map(c => ({
id: c.id,
// Recurse structural signature for children
type: c.type,
parameterKeys: summarizeParametersForHash(c.parameters),
})),
})), })),
})), })),
pluginDependencies, pluginDependencies,

View File

@@ -53,15 +53,18 @@ export interface ActionDefinition {
}; };
execution?: ExecutionDescriptor; execution?: ExecutionDescriptor;
parameterSchemaRaw?: unknown; // snapshot of original schema for validation/audit parameterSchemaRaw?: unknown; // snapshot of original schema for validation/audit
nestable?: boolean; // If true, this action can contain child actions
} }
export interface ExperimentAction { export interface ExperimentAction {
id: string; id: string;
type: ActionType; type: string; // e.g. "wizard_speak", "robot_move"
name: string; name: string;
description?: string; // Optional description
parameters: Record<string, unknown>; parameters: Record<string, unknown>;
duration?: number; duration?: number; // Estimated duration in seconds
category: ActionCategory; category: ActionCategory;
// Provenance (where did this come from?)
source: { source: {
kind: "core" | "plugin"; kind: "core" | "plugin";
pluginId?: string; pluginId?: string;
@@ -69,8 +72,14 @@ export interface ExperimentAction {
robotId?: string | null; robotId?: string | null;
baseActionId?: string; baseActionId?: string;
}; };
execution: ExecutionDescriptor; // Execution (how do we run this?)
execution?: ExecutionDescriptor;
// Snapshot of parameter schema at the time of addition (for drift detection)
parameterSchemaRaw?: unknown; parameterSchemaRaw?: unknown;
// Nested actions (control flow)
children?: ExperimentAction[];
} }
export interface StepTrigger { export interface StepTrigger {

View File

@@ -90,17 +90,26 @@ const executionDescriptorSchema = z
// Action parameter snapshot is a free-form structure retained for audit // Action parameter snapshot is a free-form structure retained for audit
const parameterSchemaRawSchema = z.unknown().optional(); const parameterSchemaRawSchema = z.unknown().optional();
// Action schema (loose input → normalized internal) // Base action schema (without recursion)
const visualActionInputSchema = z const baseActionSchema = z.object({
.object({ id: z.string().min(1),
id: z.string().min(1), type: z.string().min(1),
type: z.string().min(1), name: z.string().min(1),
name: z.string().min(1), description: z.string().optional(),
category: actionCategoryEnum.optional(), category: actionCategoryEnum.optional(),
parameters: z.record(z.string(), z.unknown()).default({}), parameters: z.record(z.string(), z.unknown()).default({}),
source: actionSourceSchema.optional(), source: actionSourceSchema.optional(),
execution: executionDescriptorSchema.optional(), execution: executionDescriptorSchema.optional(),
parameterSchemaRaw: parameterSchemaRawSchema, parameterSchemaRaw: parameterSchemaRawSchema,
});
type VisualActionInput = z.infer<typeof baseActionSchema> & {
children?: VisualActionInput[];
};
const visualActionInputSchema: z.ZodType<VisualActionInput> = baseActionSchema
.extend({
children: z.lazy(() => z.array(visualActionInputSchema)).optional(),
}) })
.strict(); .strict();
@@ -144,8 +153,7 @@ export function parseVisualDesignSteps(raw: unknown): {
issues.push( issues.push(
...zodErr.issues.map( ...zodErr.issues.map(
(issue) => (issue) =>
`steps${ `steps${issue.path.length ? "." + issue.path.join(".") : ""
issue.path.length ? "." + issue.path.join(".") : ""
}: ${issue.message} (code=${issue.code})`, }: ${issue.message} (code=${issue.code})`,
), ),
); );
@@ -155,69 +163,73 @@ export function parseVisualDesignSteps(raw: unknown): {
// Normalize to internal ExperimentStep[] shape // Normalize to internal ExperimentStep[] shape
const inputSteps = parsed.data; const inputSteps = parsed.data;
const normalized: ExperimentStep[] = inputSteps.map((s, idx) => { const normalizeAction = (a: VisualActionInput): ExperimentAction => {
const actions: ExperimentAction[] = s.actions.map((a) => { // Default provenance
// Default provenance const source: {
const source: { kind: "core" | "plugin";
kind: "core" | "plugin"; pluginId?: string;
pluginId?: string; pluginVersion?: string;
pluginVersion?: string; robotId?: string | null;
robotId?: string | null; baseActionId?: string;
baseActionId?: string; } = a.source
} = a.source
? { ? {
kind: a.source.kind, kind: a.source.kind,
pluginId: a.source.pluginId, pluginId: a.source.pluginId,
pluginVersion: a.source.pluginVersion, pluginVersion: a.source.pluginVersion,
robotId: a.source.robotId ?? null, robotId: a.source.robotId ?? null,
baseActionId: a.source.baseActionId, baseActionId: a.source.baseActionId,
} }
: { kind: "core" }; : { kind: "core" };
// Default execution // Default execution
const execution: ExecutionDescriptor = a.execution const execution: ExecutionDescriptor = a.execution
? { ? {
transport: a.execution.transport, transport: a.execution.transport,
timeoutMs: a.execution.timeoutMs, timeoutMs: a.execution.timeoutMs,
retryable: a.execution.retryable, retryable: a.execution.retryable,
ros2: a.execution.ros2, ros2: a.execution.ros2,
rest: a.execution.rest rest: a.execution.rest
? { ? {
method: a.execution.rest.method, method: a.execution.rest.method,
path: a.execution.rest.path, path: a.execution.rest.path,
headers: a.execution.rest.headers headers: a.execution.rest.headers
? Object.fromEntries( ? Object.fromEntries(
Object.entries(a.execution.rest.headers).filter( Object.entries(a.execution.rest.headers).filter(
(kv): kv is [string, string] => (kv): kv is [string, string] =>
typeof kv[1] === "string", typeof kv[1] === "string",
), ),
) )
: undefined,
}
: undefined, : undefined,
} }
: { transport: "internal" }; : undefined,
}
: { transport: "internal" };
return { return {
id: a.id, id: a.id,
type: a.type, // dynamic (pluginId.actionId) type: a.type,
name: a.name, name: a.name,
parameters: a.parameters ?? {}, description: a.description,
duration: undefined, parameters: a.parameters ?? {},
category: (a.category ?? "wizard") as ActionCategory, duration: undefined,
source: { category: (a.category ?? "wizard") as ActionCategory,
kind: source.kind, source: {
pluginId: source.kind === "plugin" ? source.pluginId : undefined, kind: source.kind,
pluginVersion: pluginId: source.kind === "plugin" ? source.pluginId : undefined,
source.kind === "plugin" ? source.pluginVersion : undefined, pluginVersion:
robotId: source.kind === "plugin" ? (source.robotId ?? null) : null, source.kind === "plugin" ? source.pluginVersion : undefined,
baseActionId: robotId: source.kind === "plugin" ? (source.robotId ?? null) : null,
source.kind === "plugin" ? source.baseActionId : undefined, baseActionId:
}, source.kind === "plugin" ? source.baseActionId : undefined,
execution, },
parameterSchemaRaw: a.parameterSchemaRaw, execution,
}; parameterSchemaRaw: a.parameterSchemaRaw,
}); children: a.children?.map(normalizeAction) ?? [],
};
};
const normalized: ExperimentStep[] = inputSteps.map((s, idx) => {
const actions: ExperimentAction[] = s.actions.map(normalizeAction);
// Construct step // Construct step
return { return {

View File

@@ -11,6 +11,7 @@ import { robotsRouter } from "~/server/api/routers/robots";
import { studiesRouter } from "~/server/api/routers/studies"; import { studiesRouter } from "~/server/api/routers/studies";
import { trialsRouter } from "~/server/api/routers/trials"; import { trialsRouter } from "~/server/api/routers/trials";
import { usersRouter } from "~/server/api/routers/users"; import { usersRouter } from "~/server/api/routers/users";
import { storageRouter } from "~/server/api/routers/storage";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
/** /**
@@ -32,6 +33,7 @@ export const appRouter = createTRPCRouter({
collaboration: collaborationRouter, collaboration: collaborationRouter,
admin: adminRouter, admin: adminRouter,
dashboard: dashboardRouter, dashboard: dashboardRouter,
storage: storageRouter,
}); });
// export type definition of API // export type definition of API

View File

@@ -0,0 +1,71 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { s3Client } from "~/server/storage";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env } from "~/env";
import { TRPCError } from "@trpc/server";
import { db } from "~/server/db";
import { mediaCaptures } from "~/server/db/schema";
export const storageRouter = createTRPCRouter({
getUploadPresignedUrl: protectedProcedure
.input(
z.object({
filename: z.string(),
contentType: z.string(),
})
)
.mutation(async ({ input }) => {
const bucket = env.MINIO_BUCKET_NAME ?? "hristudio-data";
const key = input.filename;
try {
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
ContentType: input.contentType,
});
const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
return {
url,
key,
bucket,
};
} catch (error) {
console.error("Error generating presigned URL:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to generate upload URL",
});
}
}),
saveRecording: protectedProcedure
.input(
z.object({
trialId: z.string(),
storagePath: z.string(),
fileSize: z.number().optional(),
format: z.string().optional(),
mediaType: z.enum(["video", "audio", "image"]).default("video"),
})
)
.mutation(async ({ ctx, input }) => {
const { db } = ctx;
await db.insert(mediaCaptures).values({
trialId: input.trialId,
mediaType: input.mediaType,
storagePath: input.storagePath,
fileSize: input.fileSize,
format: input.format,
startTimestamp: new Date(), // Approximate
// metadata: { uploadedBy: ctx.session.user.id }
});
return { success: true };
}),
});

View File

@@ -30,6 +30,10 @@ import {
TrialExecutionEngine, TrialExecutionEngine,
type ActionDefinition, type ActionDefinition,
} from "~/server/services/trial-execution"; } from "~/server/services/trial-execution";
import { s3Client } from "~/server/storage";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env } from "~/env";
// Helper function to check if user has access to trial // Helper function to check if user has access to trial
async function checkTrialAccess( async function checkTrialAccess(
@@ -270,15 +274,34 @@ export const trialsRouter = createTRPCRouter({
.from(trialEvents) .from(trialEvents)
.where(eq(trialEvents.trialId, input.id)); .where(eq(trialEvents.trialId, input.id));
const mediaCount = await db const media = await db
.select({ count: count() }) .select()
.from(mediaCaptures) .from(mediaCaptures)
.where(eq(mediaCaptures.trialId, input.id)); .where(eq(mediaCaptures.trialId, input.id))
.orderBy(desc(mediaCaptures.createdAt)); // Get latest first
return { return {
...trial[0], ...trial[0],
eventCount: eventCount[0]?.count ?? 0, eventCount: eventCount[0]?.count ?? 0,
mediaCount: mediaCount[0]?.count ?? 0, mediaCount: media.length,
media: await Promise.all(media.map(async (m) => {
let url = "";
try {
// Generate Presigned GET URL
const command = new GetObjectCommand({
Bucket: env.MINIO_BUCKET_NAME ?? "hristudio-data",
Key: m.storagePath,
});
url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
} catch (e) {
console.error("Failed to sign URL for media", m.id, e);
}
return {
...m,
url, // Add the signed URL to the response
contentType: m.format === 'webm' ? 'video/webm' : 'application/octet-stream', // Infer or store content type
};
})),
}; };
}), }),

20
src/server/storage.ts Normal file
View File

@@ -0,0 +1,20 @@
import { S3Client } from "@aws-sdk/client-s3";
import { env } from "~/env";
const globalForS3 = globalThis as unknown as {
s3Client: S3Client | undefined;
};
export const s3Client =
globalForS3.s3Client ??
new S3Client({
region: env.MINIO_REGION ?? "us-east-1",
endpoint: env.MINIO_ENDPOINT,
credentials: {
accessKeyId: env.MINIO_ACCESS_KEY ?? "minioadmin",
secretAccessKey: env.MINIO_SECRET_KEY ?? "minioadmin",
},
forcePathStyle: true, // Needed for MinIO
});
if (env.NODE_ENV !== "production") globalForS3.s3Client = s3Client;

View File

@@ -4,8 +4,7 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme { @theme {
--font-sans: --font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif,
var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
} }
@@ -45,9 +44,7 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--font-sans: Geist Mono, monospace;
--font-mono: Geist Mono, monospace;
--font-serif: Geist Mono, monospace;
--radius: 0rem; --radius: 0rem;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em); --tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em); --tracking-tight: calc(var(--tracking-normal) - 0.025em);
@@ -108,9 +105,7 @@
--sidebar-border: oklch(0.85 0.03 245); --sidebar-border: oklch(0.85 0.03 245);
--sidebar-ring: oklch(0.6 0.05 240); --sidebar-ring: oklch(0.6 0.05 240);
--destructive-foreground: oklch(0.9702 0 0); --destructive-foreground: oklch(0.9702 0 0);
--font-sans: Geist Mono, monospace;
--font-serif: Geist Mono, monospace;
--font-mono: Geist Mono, monospace;
--shadow-color: hsl(0 0% 0%); --shadow-color: hsl(0 0% 0%);
--shadow-opacity: 0; --shadow-opacity: 0;
--shadow-blur: 0px; --shadow-blur: 0px;
@@ -171,6 +166,43 @@
} }
} }
@layer base {
.dark {
--background: oklch(0.12 0.008 250);
--foreground: oklch(0.95 0.005 250);
--card: oklch(0.18 0.008 250);
--card-foreground: oklch(0.95 0.005 250);
--popover: oklch(0.2 0.01 250);
--popover-foreground: oklch(0.95 0.005 250);
--primary: oklch(0.65 0.1 240);
--primary-foreground: oklch(0.08 0.02 250);
--secondary: oklch(0.25 0.015 245);
--secondary-foreground: oklch(0.92 0.008 250);
--muted: oklch(0.22 0.01 250);
--muted-foreground: oklch(0.65 0.02 245);
--accent: oklch(0.35 0.025 245);
--accent-foreground: oklch(0.92 0.008 250);
--destructive: oklch(0.7022 0.1892 22.2279);
--border: oklch(0.3 0.015 250);
--input: oklch(0.28 0.015 250);
--ring: oklch(0.65 0.1 240);
--chart-1: oklch(0.65 0.1 240);
--chart-2: oklch(0.7 0.12 200);
--chart-3: oklch(0.75 0.15 160);
--chart-4: oklch(0.8 0.12 120);
--chart-5: oklch(0.7 0.18 80);
--sidebar: oklch(0.14 0.025 250);
--sidebar-foreground: oklch(0.88 0.02 250);
--sidebar-primary: oklch(0.8 0.06 240);
--sidebar-primary-foreground: oklch(0.12 0.025 250);
--sidebar-accent: oklch(0.22 0.04 245);
--sidebar-accent-foreground: oklch(0.88 0.02 250);
--sidebar-border: oklch(0.32 0.035 250);
--sidebar-ring: oklch(0.55 0.08 240);
--destructive-foreground: oklch(0.95 0.01 250);
}
}
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;