mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-12 07:04:44 -05:00
feat: Relocate experiment designer routes under studies, update ROS2 topic paths, and enhance designer hashing and performance.
This commit is contained in:
@@ -64,7 +64,8 @@ export default function NaoTestPage() {
|
|||||||
const [sensorData, setSensorData] = useState<any>({});
|
const [sensorData, setSensorData] = useState<any>({});
|
||||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const ROS_BRIDGE_URL = "ws://134.82.159.25:9090";
|
const ROS_BRIDGE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_ROS_BRIDGE_URL || "ws://localhost:9090";
|
||||||
|
|
||||||
const addLog = (message: string) => {
|
const addLog = (message: string) => {
|
||||||
const timestamp = new Date().toLocaleTimeString();
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function DesignerPageClient({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: experiment.name,
|
label: experiment.name,
|
||||||
href: `/experiments/${experiment.id}`,
|
href: `/studies/${experiment.study.id}/experiments/${experiment.id}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Designer",
|
label: "Designer",
|
||||||
@@ -11,7 +11,7 @@ import { DesignerPageClient } from "./DesignerPageClient";
|
|||||||
|
|
||||||
interface ExperimentDesignerPageProps {
|
interface ExperimentDesignerPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
id: string;
|
experimentId: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ export default async function ExperimentDesignerPage({
|
|||||||
}: ExperimentDesignerPageProps) {
|
}: ExperimentDesignerPageProps) {
|
||||||
try {
|
try {
|
||||||
const resolvedParams = await params;
|
const resolvedParams = await params;
|
||||||
const experiment = await api.experiments.get({ id: resolvedParams.id });
|
const experiment = await api.experiments.get({ id: resolvedParams.experimentId });
|
||||||
|
|
||||||
if (!experiment) {
|
if (!experiment) {
|
||||||
notFound();
|
notFound();
|
||||||
@@ -258,7 +258,7 @@ export async function generateMetadata({
|
|||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const resolvedParams = await params;
|
const resolvedParams = await params;
|
||||||
const experiment = await api.experiments.get({ id: resolvedParams.id });
|
const experiment = await api.experiments.get({ id: resolvedParams.experimentId });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${experiment?.name} - Designer | HRIStudio`,
|
title: `${experiment?.name} - Designer | HRIStudio`,
|
||||||
@@ -185,7 +185,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href={`/experiments/new?studyId=${study.id}`}>
|
<Link href={`/studies/${study.id}/experiments/new`}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
New Experiment
|
New Experiment
|
||||||
</Link>
|
</Link>
|
||||||
@@ -232,7 +232,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
|||||||
description="Design and manage experimental protocols for this study"
|
description="Design and manage experimental protocols for this study"
|
||||||
actions={
|
actions={
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
<Link href={`/experiments/new?studyId=${study.id}`}>
|
<Link href={`/studies/${study.id}/experiments/new`}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Experiment
|
Add Experiment
|
||||||
</Link>
|
</Link>
|
||||||
@@ -246,7 +246,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
|||||||
description="Create your first experiment to start designing research protocols"
|
description="Create your first experiment to start designing research protocols"
|
||||||
action={
|
action={
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href={`/experiments/new?studyId=${study.id}`}>
|
<Link href={`/studies/${study.id}/experiments/new`}>
|
||||||
Create First Experiment
|
Create First Experiment
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -263,15 +263,14 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
|||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<h4 className="font-medium">
|
<h4 className="font-medium">
|
||||||
<Link
|
<Link
|
||||||
href={`/experiments/${experiment.id}`}
|
href={`/studies/${study.id}/experiments/${experiment.id}`}
|
||||||
className="hover:underline"
|
className="hover:underline"
|
||||||
>
|
>
|
||||||
{experiment.name}
|
{experiment.name}
|
||||||
</Link>
|
</Link>
|
||||||
</h4>
|
</h4>
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
|
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${experiment.status === "draft"
|
||||||
experiment.status === "draft"
|
|
||||||
? "bg-gray-100 text-gray-800"
|
? "bg-gray-100 text-gray-800"
|
||||||
: experiment.status === "ready"
|
: experiment.status === "ready"
|
||||||
? "bg-green-100 text-green-800"
|
? "bg-green-100 text-green-800"
|
||||||
@@ -300,12 +299,12 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
<Link href={`/experiments/${experiment.id}/designer`}>
|
<Link href={`/studies/${study.id}/experiments/${experiment.id}/designer`}>
|
||||||
Design
|
Design
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
<Link href={`/experiments/${experiment.id}`}>View</Link>
|
<Link href={`/studies/${study.id}/experiments/${experiment.id}`}>View</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: experiment.name,
|
label: experiment.name,
|
||||||
href: `/experiments/${experiment.id}`,
|
href: `/studies/${selectedStudyId}/experiments/${experiment.id}`,
|
||||||
},
|
},
|
||||||
{ label: "Edit" },
|
{ label: "Edit" },
|
||||||
]
|
]
|
||||||
@@ -108,7 +108,7 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: experiment.name,
|
label: experiment.name,
|
||||||
href: `/experiments/${experiment.id}`,
|
href: `/studies/${experiment.studyId}/experiments/${experiment.id}`,
|
||||||
},
|
},
|
||||||
{ label: "Edit" },
|
{ label: "Edit" },
|
||||||
]
|
]
|
||||||
@@ -153,14 +153,14 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
|
|||||||
...data,
|
...data,
|
||||||
estimatedDuration: data.estimatedDuration ?? undefined,
|
estimatedDuration: data.estimatedDuration ?? undefined,
|
||||||
});
|
});
|
||||||
router.push(`/experiments/${newExperiment.id}/designer`);
|
router.push(`/studies/${data.studyId}/experiments/${newExperiment.id}/designer`);
|
||||||
} else {
|
} else {
|
||||||
const updatedExperiment = await updateExperimentMutation.mutateAsync({
|
const updatedExperiment = await updateExperimentMutation.mutateAsync({
|
||||||
id: experimentId!,
|
id: experimentId!,
|
||||||
...data,
|
...data,
|
||||||
estimatedDuration: data.estimatedDuration ?? undefined,
|
estimatedDuration: data.estimatedDuration ?? undefined,
|
||||||
});
|
});
|
||||||
router.push(`/experiments/${updatedExperiment.id}`);
|
router.push(`/studies/${experiment?.studyId ?? data.studyId}/experiments/${updatedExperiment.id}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(
|
setError(
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ function ExperimentCard({ experiment }: ExperimentCardProps) {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<CardTitle className="truncate text-lg font-semibold text-slate-900 transition-colors group-hover:text-blue-600">
|
<CardTitle className="truncate text-lg font-semibold text-slate-900 transition-colors group-hover:text-blue-600">
|
||||||
<Link
|
<Link
|
||||||
href={`/experiments/${experiment.id}`}
|
href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}
|
||||||
className="hover:underline"
|
className="hover:underline"
|
||||||
>
|
>
|
||||||
{experiment.name}
|
{experiment.name}
|
||||||
@@ -158,10 +158,10 @@ function ExperimentCard({ experiment }: ExperimentCardProps) {
|
|||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<Button asChild size="sm" className="flex-1">
|
<Button asChild size="sm" className="flex-1">
|
||||||
<Link href={`/experiments/${experiment.id}`}>View Details</Link>
|
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>View Details</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild size="sm" variant="outline" className="flex-1">
|
<Button asChild size="sm" variant="outline" className="flex-1">
|
||||||
<Link href={`/experiments/${experiment.id}/designer`}>
|
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
|
||||||
<Settings className="mr-1 h-3 w-3" />
|
<Settings className="mr-1 h-3 w-3" />
|
||||||
Design
|
Design
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export const columns: ColumnDef<Experiment>[] = [
|
|||||||
<div className="max-w-[200px]">
|
<div className="max-w-[200px]">
|
||||||
<div className="truncate font-medium">
|
<div className="truncate font-medium">
|
||||||
<Link
|
<Link
|
||||||
href={`/experiments/${row.original.id}`}
|
href={`/studies/${row.original.studyId}/experiments/${row.original.id}`}
|
||||||
className="hover:underline"
|
className="hover:underline"
|
||||||
>
|
>
|
||||||
{String(name)}
|
{String(name)}
|
||||||
@@ -263,15 +263,15 @@ export const columns: ColumnDef<Experiment>[] = [
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/experiments/${experiment.id}`}>View details</Link>
|
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>View details</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/experiments/${experiment.id}/edit`}>
|
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
|
||||||
Edit experiment
|
Edit experiment
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/experiments/${experiment.id}/designer`}>
|
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
|
||||||
Open designer
|
Open designer
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -57,6 +57,15 @@ export interface DependencyInspectorProps {
|
|||||||
* Available action definitions from registry
|
* Available action definitions from registry
|
||||||
*/
|
*/
|
||||||
actionDefinitions: ActionDefinition[];
|
actionDefinitions: ActionDefinition[];
|
||||||
|
/**
|
||||||
|
* Study plugins with name and metadata
|
||||||
|
*/
|
||||||
|
studyPlugins?: Array<{
|
||||||
|
id: string;
|
||||||
|
robotId: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
}>;
|
||||||
/**
|
/**
|
||||||
* Called when user wants to reconcile a drifted action
|
* Called when user wants to reconcile a drifted action
|
||||||
*/
|
*/
|
||||||
@@ -80,6 +89,12 @@ function extractPluginDependencies(
|
|||||||
steps: ExperimentStep[],
|
steps: ExperimentStep[],
|
||||||
actionDefinitions: ActionDefinition[],
|
actionDefinitions: ActionDefinition[],
|
||||||
driftedActions: Set<string>,
|
driftedActions: Set<string>,
|
||||||
|
studyPlugins?: Array<{
|
||||||
|
id: string;
|
||||||
|
robotId: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
}>,
|
||||||
): PluginDependency[] {
|
): PluginDependency[] {
|
||||||
const dependencyMap = new Map<string, PluginDependency>();
|
const dependencyMap = new Map<string, PluginDependency>();
|
||||||
|
|
||||||
@@ -134,9 +149,12 @@ function extractPluginDependencies(
|
|||||||
dep.installedVersion = dep.version;
|
dep.installedVersion = dep.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set plugin name from first available definition
|
// Set plugin name from studyPlugins if available
|
||||||
if (availableActions[0]) {
|
if (availableActions[0]) {
|
||||||
dep.name = availableActions[0].source.pluginId; // Could be enhanced with actual plugin name
|
const pluginMeta = studyPlugins?.find(
|
||||||
|
(p) => p.robotId === dep.pluginId,
|
||||||
|
);
|
||||||
|
dep.name = pluginMeta?.name ?? dep.pluginId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -247,7 +265,9 @@ function PluginDependencyItem({
|
|||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium">{dependency.pluginId}</span>
|
<span className="text-sm font-medium">
|
||||||
|
{dependency.name ?? dependency.pluginId}
|
||||||
|
</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant={config.badgeVariant}
|
variant={config.badgeVariant}
|
||||||
className={cn("h-4 text-[10px]", config.badgeColor)}
|
className={cn("h-4 text-[10px]", config.badgeColor)}
|
||||||
@@ -382,6 +402,7 @@ export function DependencyInspector({
|
|||||||
steps,
|
steps,
|
||||||
actionSignatureDrift,
|
actionSignatureDrift,
|
||||||
actionDefinitions,
|
actionDefinitions,
|
||||||
|
studyPlugins,
|
||||||
onReconcileAction,
|
onReconcileAction,
|
||||||
onRefreshDependencies,
|
onRefreshDependencies,
|
||||||
onInstallPlugin,
|
onInstallPlugin,
|
||||||
@@ -389,8 +410,13 @@ export function DependencyInspector({
|
|||||||
}: DependencyInspectorProps) {
|
}: DependencyInspectorProps) {
|
||||||
const dependencies = useMemo(
|
const dependencies = useMemo(
|
||||||
() =>
|
() =>
|
||||||
extractPluginDependencies(steps, actionDefinitions, actionSignatureDrift),
|
extractPluginDependencies(
|
||||||
[steps, actionDefinitions, actionSignatureDrift],
|
steps,
|
||||||
|
actionDefinitions,
|
||||||
|
actionSignatureDrift,
|
||||||
|
studyPlugins,
|
||||||
|
),
|
||||||
|
[steps, actionDefinitions, actionSignatureDrift, studyPlugins],
|
||||||
);
|
);
|
||||||
|
|
||||||
const drifts = useMemo(
|
const drifts = useMemo(
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Play } from "lucide-react";
|
import { Play, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
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";
|
||||||
@@ -150,20 +157,28 @@ export function DesignerRoot({
|
|||||||
} = api.experiments.get.useQuery({ id: experimentId });
|
} = api.experiments.get.useQuery({ id: experimentId });
|
||||||
|
|
||||||
const updateExperiment = api.experiments.update.useMutation({
|
const updateExperiment = api.experiments.update.useMutation({
|
||||||
onSuccess: async () => {
|
|
||||||
toast.success("Experiment saved");
|
|
||||||
await refetchExperiment();
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
toast.error(`Save failed: ${err.message}`);
|
toast.error(`Save failed: ${err.message}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: studyPlugins } = api.robots.plugins.getStudyPlugins.useQuery(
|
const { data: studyPluginsRaw } = api.robots.plugins.getStudyPlugins.useQuery(
|
||||||
{ studyId: experiment?.studyId ?? "" },
|
{ studyId: experiment?.studyId ?? "" },
|
||||||
{ enabled: !!experiment?.studyId },
|
{ enabled: !!experiment?.studyId },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Map studyPlugins to format expected by DependencyInspector
|
||||||
|
const studyPlugins = useMemo(
|
||||||
|
() =>
|
||||||
|
studyPluginsRaw?.map((sp) => ({
|
||||||
|
id: sp.plugin.id,
|
||||||
|
robotId: sp.plugin.robotId ?? "",
|
||||||
|
name: sp.plugin.name,
|
||||||
|
version: sp.plugin.version,
|
||||||
|
})),
|
||||||
|
[studyPluginsRaw],
|
||||||
|
);
|
||||||
|
|
||||||
/* ------------------------------ Store Access ----------------------------- */
|
/* ------------------------------ Store Access ----------------------------- */
|
||||||
const steps = useDesignerStore((s) => s.steps);
|
const steps = useDesignerStore((s) => s.steps);
|
||||||
const setSteps = useDesignerStore((s) => s.setSteps);
|
const setSteps = useDesignerStore((s) => s.setSteps);
|
||||||
@@ -230,6 +245,7 @@ export function DesignerRoot({
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isValidating, setIsValidating] = useState(false);
|
const [isValidating, setIsValidating] = useState(false);
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
const [isReady, setIsReady] = useState(false); // Track when everything is loaded
|
||||||
|
|
||||||
const [lastSavedAt, setLastSavedAt] = useState<Date | undefined>(undefined);
|
const [lastSavedAt, setLastSavedAt] = useState<Date | undefined>(undefined);
|
||||||
const [inspectorTab, setInspectorTab] = useState<
|
const [inspectorTab, setInspectorTab] = useState<
|
||||||
@@ -250,6 +266,13 @@ export function DesignerRoot({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialized) return;
|
if (initialized) return;
|
||||||
if (loadingExperiment && !initialDesign) return;
|
if (loadingExperiment && !initialDesign) return;
|
||||||
|
|
||||||
|
console.log('[DesignerRoot] 🚀 INITIALIZING', {
|
||||||
|
hasExperiment: !!experiment,
|
||||||
|
hasInitialDesign: !!initialDesign,
|
||||||
|
loadingExperiment,
|
||||||
|
});
|
||||||
|
|
||||||
const adapted =
|
const adapted =
|
||||||
initialDesign ??
|
initialDesign ??
|
||||||
(experiment
|
(experiment
|
||||||
@@ -274,8 +297,9 @@ export function DesignerRoot({
|
|||||||
setValidatedHash(ih);
|
setValidatedHash(ih);
|
||||||
}
|
}
|
||||||
setInitialized(true);
|
setInitialized(true);
|
||||||
// Kick initial hash
|
// NOTE: We don't call recomputeHash() here because the automatic
|
||||||
void recomputeHash();
|
// hash recomputation useEffect will trigger when setSteps() updates the steps array
|
||||||
|
console.log('[DesignerRoot] 🚀 Initialization complete, steps set');
|
||||||
}, [
|
}, [
|
||||||
initialized,
|
initialized,
|
||||||
loadingExperiment,
|
loadingExperiment,
|
||||||
@@ -299,26 +323,69 @@ export function DesignerRoot({
|
|||||||
// Load plugin actions when study plugins available
|
// Load plugin actions when study plugins available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!experiment?.studyId) return;
|
if (!experiment?.studyId) return;
|
||||||
if (!studyPlugins || studyPlugins.length === 0) return;
|
if (!studyPluginsRaw) return;
|
||||||
actionRegistry.loadPluginActions(
|
// @ts-expect-error - studyPluginsRaw type from tRPC is compatible but TypeScript can't infer it
|
||||||
experiment.studyId,
|
actionRegistry.loadPluginActions(experiment.studyId, studyPluginsRaw);
|
||||||
studyPlugins.map((sp) => ({
|
}, [experiment?.studyId, studyPluginsRaw]);
|
||||||
plugin: {
|
|
||||||
id: sp.plugin.id,
|
/* ------------------------- Ready State Management ------------------------ */
|
||||||
robotId: sp.plugin.robotId,
|
// Mark as ready once initialized and plugins are loaded
|
||||||
version: sp.plugin.version,
|
useEffect(() => {
|
||||||
actionDefinitions: Array.isArray(sp.plugin.actionDefinitions)
|
if (!initialized || isReady) return;
|
||||||
? sp.plugin.actionDefinitions
|
|
||||||
: undefined,
|
// Check if plugins are loaded by verifying the action registry has plugin actions
|
||||||
},
|
const debugInfo = actionRegistry.getDebugInfo();
|
||||||
})),
|
const hasPlugins = debugInfo.pluginActionsLoaded;
|
||||||
);
|
|
||||||
}, [experiment?.studyId, studyPlugins]);
|
if (hasPlugins) {
|
||||||
|
// Small delay to ensure all components have rendered
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsReady(true);
|
||||||
|
console.log('[DesignerRoot] ✅ Designer ready (plugins loaded), fading in');
|
||||||
|
}, 150);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [initialized, isReady, studyPluginsRaw]);
|
||||||
|
|
||||||
|
/* ----------------------- Automatic Hash Recomputation -------------------- */
|
||||||
|
// Automatically recompute hash when steps change (debounced to avoid excessive computation)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialized) return;
|
||||||
|
|
||||||
|
console.log('[DesignerRoot] Steps changed, scheduling hash recomputation', {
|
||||||
|
stepsCount: steps.length,
|
||||||
|
actionsCount: steps.reduce((sum, s) => sum + s.actions.length, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(async () => {
|
||||||
|
console.log('[DesignerRoot] Executing debounced hash recomputation');
|
||||||
|
const result = await recomputeHash();
|
||||||
|
if (result) {
|
||||||
|
console.log('[DesignerRoot] Hash recomputed:', {
|
||||||
|
newHash: result.designHash.slice(0, 16),
|
||||||
|
fullHash: result.designHash,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 300); // Debounce 300ms
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [steps, initialized, recomputeHash]);
|
||||||
|
|
||||||
|
|
||||||
/* ----------------------------- Derived State ----------------------------- */
|
/* ----------------------------- Derived State ----------------------------- */
|
||||||
const hasUnsavedChanges =
|
const hasUnsavedChanges =
|
||||||
!!currentDesignHash && lastPersistedHash !== currentDesignHash;
|
!!currentDesignHash && lastPersistedHash !== currentDesignHash;
|
||||||
|
|
||||||
|
// Debug logging to track hash updates and save button state
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[DesignerRoot] Hash State:', {
|
||||||
|
currentDesignHash: currentDesignHash?.slice(0, 10),
|
||||||
|
lastPersistedHash: lastPersistedHash?.slice(0, 10),
|
||||||
|
hasUnsavedChanges,
|
||||||
|
stepsCount: steps.length,
|
||||||
|
});
|
||||||
|
}, [currentDesignHash, lastPersistedHash, hasUnsavedChanges, steps.length]);
|
||||||
|
|
||||||
/* ------------------------------- Step Ops -------------------------------- */
|
/* ------------------------------- Step Ops -------------------------------- */
|
||||||
const createNewStep = useCallback(() => {
|
const createNewStep = useCallback(() => {
|
||||||
const newStep: ExperimentStep = {
|
const newStep: ExperimentStep = {
|
||||||
@@ -386,8 +453,7 @@ export function DesignerRoot({
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(
|
toast.error(
|
||||||
`Validation error: ${
|
`Validation error: ${err instanceof Error ? err.message : "Unknown error"
|
||||||
err instanceof Error ? err.message : "Unknown error"
|
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -404,6 +470,14 @@ export function DesignerRoot({
|
|||||||
/* --------------------------------- Save ---------------------------------- */
|
/* --------------------------------- Save ---------------------------------- */
|
||||||
const persist = useCallback(async () => {
|
const persist = useCallback(async () => {
|
||||||
if (!initialized) return;
|
if (!initialized) return;
|
||||||
|
|
||||||
|
console.log('[DesignerRoot] 💾 SAVE initiated', {
|
||||||
|
stepsCount: steps.length,
|
||||||
|
actionsCount: steps.reduce((sum, s) => sum + s.actions.length, 0),
|
||||||
|
currentHash: currentDesignHash?.slice(0, 16),
|
||||||
|
lastPersistedHash: lastPersistedHash?.slice(0, 16),
|
||||||
|
});
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const visualDesign = {
|
const visualDesign = {
|
||||||
@@ -411,15 +485,43 @@ export function DesignerRoot({
|
|||||||
version: designMeta.version,
|
version: designMeta.version,
|
||||||
lastSaved: new Date().toISOString(),
|
lastSaved: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
updateExperiment.mutate({
|
|
||||||
|
console.log('[DesignerRoot] 💾 Sending to server...', {
|
||||||
|
experimentId,
|
||||||
|
stepsCount: steps.length,
|
||||||
|
version: designMeta.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for mutation to complete
|
||||||
|
await updateExperiment.mutateAsync({
|
||||||
id: experimentId,
|
id: experimentId,
|
||||||
visualDesign,
|
visualDesign,
|
||||||
createSteps: true,
|
createSteps: true,
|
||||||
compileExecution: autoCompile,
|
compileExecution: autoCompile,
|
||||||
});
|
});
|
||||||
// Optimistic hash recompute
|
|
||||||
await recomputeHash();
|
console.log('[DesignerRoot] 💾 Server save successful');
|
||||||
|
|
||||||
|
// NOTE: We do NOT refetch here because it would reset the local steps state
|
||||||
|
// to the server state, which would cause the hash to match the persisted hash,
|
||||||
|
// preventing the save button from re-enabling on subsequent changes.
|
||||||
|
// The local state is already the source of truth after a successful save.
|
||||||
|
|
||||||
|
// Recompute hash and update persisted hash
|
||||||
|
const hashResult = await recomputeHash();
|
||||||
|
if (hashResult?.designHash) {
|
||||||
|
console.log('[DesignerRoot] 💾 Updated persisted hash:', {
|
||||||
|
newPersistedHash: hashResult.designHash.slice(0, 16),
|
||||||
|
fullHash: hashResult.designHash,
|
||||||
|
});
|
||||||
|
setPersistedHash(hashResult.designHash);
|
||||||
|
}
|
||||||
|
|
||||||
setLastSavedAt(new Date());
|
setLastSavedAt(new Date());
|
||||||
|
toast.success("Experiment saved");
|
||||||
|
|
||||||
|
console.log('[DesignerRoot] 💾 SAVE complete');
|
||||||
|
|
||||||
onPersist?.({
|
onPersist?.({
|
||||||
id: experimentId,
|
id: experimentId,
|
||||||
name: designMeta.name,
|
name: designMeta.name,
|
||||||
@@ -428,16 +530,22 @@ export function DesignerRoot({
|
|||||||
version: designMeta.version,
|
version: designMeta.version,
|
||||||
lastSaved: new Date(),
|
lastSaved: new Date(),
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DesignerRoot] 💾 SAVE failed:', error);
|
||||||
|
// Error already handled by mutation onError
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
initialized,
|
initialized,
|
||||||
steps,
|
steps,
|
||||||
designMeta,
|
designMeta,
|
||||||
experimentId,
|
experimentId,
|
||||||
updateExperiment,
|
|
||||||
recomputeHash,
|
recomputeHash,
|
||||||
|
currentDesignHash,
|
||||||
|
setPersistedHash,
|
||||||
|
refetchExperiment,
|
||||||
onPersist,
|
onPersist,
|
||||||
autoCompile,
|
autoCompile,
|
||||||
]);
|
]);
|
||||||
@@ -479,8 +587,7 @@ export function DesignerRoot({
|
|||||||
toast.success("Exported design bundle");
|
toast.success("Exported design bundle");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(
|
toast.error(
|
||||||
`Export failed: ${
|
`Export failed: ${err instanceof Error ? err.message : "Unknown error"
|
||||||
err instanceof Error ? err.message : "Unknown error"
|
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -489,10 +596,14 @@ export function DesignerRoot({
|
|||||||
}, [currentDesignHash, steps, experimentId, designMeta, experiment]);
|
}, [currentDesignHash, steps, experimentId, designMeta, experiment]);
|
||||||
|
|
||||||
/* ---------------------------- Incremental Hash --------------------------- */
|
/* ---------------------------- Incremental Hash --------------------------- */
|
||||||
|
// Serialize steps for stable comparison
|
||||||
|
const stepsHash = useMemo(() => JSON.stringify(steps), [steps]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialized) return;
|
if (!initialized) return;
|
||||||
void recomputeHash();
|
void recomputeHash();
|
||||||
}, [steps.length, initialized, recomputeHash]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [stepsHash, initialized]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedStepId || selectedActionId) {
|
if (selectedStepId || selectedActionId) {
|
||||||
@@ -677,13 +788,7 @@ export function DesignerRoot({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const actions = (
|
||||||
<div className="space-y-4">
|
|
||||||
<PageHeader
|
|
||||||
title={designMeta.name}
|
|
||||||
description="Compose ordered steps with provenance-aware actions."
|
|
||||||
icon={Play}
|
|
||||||
actions={
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -704,9 +809,35 @@ export function DesignerRoot({
|
|||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||||
|
<PageHeader
|
||||||
|
title={designMeta.name}
|
||||||
|
description={designMeta.description || "No description"}
|
||||||
|
icon={Play}
|
||||||
|
actions={actions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="relative flex flex-1 flex-col overflow-hidden">
|
||||||
|
{/* Loading Overlay */}
|
||||||
|
{!isReady && (
|
||||||
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<RefreshCw className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p className="text-muted-foreground text-sm">Loading designer...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Content - Fade in when ready */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 flex-col overflow-hidden transition-opacity duration-500",
|
||||||
|
isReady ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex h-[calc(100vh-12rem)] w-full max-w-full flex-col overflow-hidden rounded-md border">
|
<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}
|
||||||
@@ -729,6 +860,7 @@ export function DesignerRoot({
|
|||||||
<InspectorPanel
|
<InspectorPanel
|
||||||
activeTab={inspectorTab}
|
activeTab={inspectorTab}
|
||||||
onTabChange={setInspectorTab}
|
onTabChange={setInspectorTab}
|
||||||
|
studyPlugins={studyPlugins}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -746,6 +878,7 @@ export function DesignerRoot({
|
|||||||
onSave={() => persist()}
|
onSave={() => persist()}
|
||||||
onValidate={() => validateDesign()}
|
onValidate={() => validateDesign()}
|
||||||
onExport={() => handleExport()}
|
onExport={() => handleExport()}
|
||||||
|
onRecalculateHash={() => recomputeHash()}
|
||||||
lastSavedAt={lastSavedAt}
|
lastSavedAt={lastSavedAt}
|
||||||
saving={isSaving}
|
saving={isSaving}
|
||||||
validating={isValidating}
|
validating={isValidating}
|
||||||
@@ -754,6 +887,8 @@ export function DesignerRoot({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import {
|
import {
|
||||||
@@ -80,6 +80,85 @@ export function PropertiesPanel({
|
|||||||
}: PropertiesPanelProps) {
|
}: PropertiesPanelProps) {
|
||||||
const registry = actionRegistry;
|
const registry = actionRegistry;
|
||||||
|
|
||||||
|
// Local state for controlled inputs
|
||||||
|
const [localActionName, setLocalActionName] = useState("");
|
||||||
|
const [localStepName, setLocalStepName] = useState("");
|
||||||
|
const [localStepDescription, setLocalStepDescription] = useState("");
|
||||||
|
const [localParams, setLocalParams] = useState<Record<string, unknown>>({});
|
||||||
|
|
||||||
|
// Debounce timers
|
||||||
|
const actionUpdateTimer = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||||
|
const stepUpdateTimer = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||||
|
const paramUpdateTimers = useRef(new Map<string, NodeJS.Timeout>());
|
||||||
|
|
||||||
|
// Sync local state when selection ID changes (not on every object recreation)
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedAction) {
|
||||||
|
setLocalActionName(selectedAction.name);
|
||||||
|
setLocalParams(selectedAction.parameters);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedAction?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedStep) {
|
||||||
|
setLocalStepName(selectedStep.name);
|
||||||
|
setLocalStepDescription(selectedStep.description ?? "");
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedStep?.id]);
|
||||||
|
|
||||||
|
// Cleanup timers on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
const timersMap = paramUpdateTimers.current;
|
||||||
|
return () => {
|
||||||
|
if (actionUpdateTimer.current) clearTimeout(actionUpdateTimer.current);
|
||||||
|
if (stepUpdateTimer.current) clearTimeout(stepUpdateTimer.current);
|
||||||
|
timersMap.forEach((timer) => clearTimeout(timer));
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Debounced update handlers
|
||||||
|
const debouncedActionUpdate = useCallback(
|
||||||
|
(stepId: string, actionId: string, updates: Partial<ExperimentAction>) => {
|
||||||
|
if (actionUpdateTimer.current) clearTimeout(actionUpdateTimer.current);
|
||||||
|
actionUpdateTimer.current = setTimeout(() => {
|
||||||
|
onActionUpdate(stepId, actionId, updates);
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
[onActionUpdate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const debouncedStepUpdate = useCallback(
|
||||||
|
(stepId: string, updates: Partial<ExperimentStep>) => {
|
||||||
|
if (stepUpdateTimer.current) clearTimeout(stepUpdateTimer.current);
|
||||||
|
stepUpdateTimer.current = setTimeout(() => {
|
||||||
|
onStepUpdate(stepId, updates);
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
[onStepUpdate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const debouncedParamUpdate = useCallback(
|
||||||
|
(stepId: string, actionId: string, paramId: string, value: unknown) => {
|
||||||
|
const existing = paramUpdateTimers.current.get(paramId);
|
||||||
|
if (existing) clearTimeout(existing);
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
onActionUpdate(stepId, actionId, {
|
||||||
|
parameters: {
|
||||||
|
...selectedAction?.parameters,
|
||||||
|
[paramId]: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
paramUpdateTimers.current.delete(paramId);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
paramUpdateTimers.current.set(paramId, timer);
|
||||||
|
},
|
||||||
|
[onActionUpdate, selectedAction?.parameters],
|
||||||
|
);
|
||||||
|
|
||||||
// Find containing step for selected action (if any)
|
// Find containing step for selected action (if any)
|
||||||
const containingStep =
|
const containingStep =
|
||||||
selectedAction &&
|
selectedAction &&
|
||||||
@@ -176,12 +255,21 @@ export function PropertiesPanel({
|
|||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">Display Name</Label>
|
<Label className="text-xs">Display Name</Label>
|
||||||
<Input
|
<Input
|
||||||
value={selectedAction.name}
|
value={localActionName}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
|
const newName = e.target.value;
|
||||||
|
setLocalActionName(newName);
|
||||||
|
debouncedActionUpdate(containingStep.id, selectedAction.id, {
|
||||||
|
name: newName,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (localActionName !== selectedAction.name) {
|
||||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||||
name: e.target.value,
|
name: localActionName,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
className="mt-1 h-7 w-full text-xs"
|
className="mt-1 h-7 w-full text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,6 +298,17 @@ export function PropertiesPanel({
|
|||||||
|
|
||||||
/* ---- Handlers ---- */
|
/* ---- Handlers ---- */
|
||||||
const updateParamValue = (value: unknown) => {
|
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, {
|
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||||
parameters: {
|
parameters: {
|
||||||
...selectedAction.parameters,
|
...selectedAction.parameters,
|
||||||
@@ -218,23 +317,50 @@ export function PropertiesPanel({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateParamLocal = (value: unknown) => {
|
||||||
|
setLocalParams((prev) => ({ ...prev, [param.id]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitParamValue = () => {
|
||||||
|
if (localParams[param.id] !== rawValue) {
|
||||||
|
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||||
|
parameters: {
|
||||||
|
...selectedAction.parameters,
|
||||||
|
[param.id]: localParams[param.id],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/* ---- Control Rendering ---- */
|
/* ---- Control Rendering ---- */
|
||||||
let control: React.ReactNode = null;
|
let control: React.ReactNode = null;
|
||||||
|
|
||||||
if (param.type === "text") {
|
if (param.type === "text") {
|
||||||
|
const localValue = localParams[param.id] ?? rawValue ?? "";
|
||||||
control = (
|
control = (
|
||||||
<Input
|
<Input
|
||||||
value={(rawValue as string) ?? ""}
|
value={localValue as string}
|
||||||
placeholder={param.placeholder}
|
placeholder={param.placeholder}
|
||||||
onChange={(e) => updateParamValue(e.target.value)}
|
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"
|
className="mt-1 h-7 w-full text-xs"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (param.type === "select") {
|
} else if (param.type === "select") {
|
||||||
|
const localValue = localParams[param.id] ?? rawValue ?? "";
|
||||||
control = (
|
control = (
|
||||||
<Select
|
<Select
|
||||||
value={(rawValue as string) ?? ""}
|
value={localValue as string}
|
||||||
onValueChange={(val) => updateParamValue(val)}
|
onValueChange={(val) => updateParamValueImmediate(val)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1 h-7 w-full text-xs">
|
<SelectTrigger className="mt-1 h-7 w-full text-xs">
|
||||||
<SelectValue placeholder="Select…" />
|
<SelectValue placeholder="Select…" />
|
||||||
@@ -249,22 +375,26 @@ export function PropertiesPanel({
|
|||||||
</Select>
|
</Select>
|
||||||
);
|
);
|
||||||
} else if (param.type === "boolean") {
|
} else if (param.type === "boolean") {
|
||||||
|
const localValue = localParams[param.id] ?? rawValue ?? false;
|
||||||
control = (
|
control = (
|
||||||
<div className="mt-1 flex h-7 items-center">
|
<div className="mt-1 flex h-7 items-center">
|
||||||
<Switch
|
<Switch
|
||||||
checked={Boolean(rawValue)}
|
checked={Boolean(localValue)}
|
||||||
onCheckedChange={(val) => updateParamValue(val)}
|
onCheckedChange={(val) =>
|
||||||
|
updateParamValueImmediate(val)
|
||||||
|
}
|
||||||
aria-label={param.name}
|
aria-label={param.name}
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground ml-2 text-[11px]">
|
<span className="text-muted-foreground ml-2 text-[11px]">
|
||||||
{Boolean(rawValue) ? "Enabled" : "Disabled"}
|
{Boolean(localValue) ? "Enabled" : "Disabled"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (param.type === "number") {
|
} else if (param.type === "number") {
|
||||||
|
const localValue = localParams[param.id] ?? rawValue;
|
||||||
const numericVal =
|
const numericVal =
|
||||||
typeof rawValue === "number"
|
typeof localValue === "number"
|
||||||
? rawValue
|
? localValue
|
||||||
: typeof param.value === "number"
|
: typeof param.value === "number"
|
||||||
? param.value
|
? param.value
|
||||||
: (param.min ?? 0);
|
: (param.min ?? 0);
|
||||||
@@ -295,8 +425,9 @@ export function PropertiesPanel({
|
|||||||
step={step}
|
step={step}
|
||||||
value={[Number(numericVal)]}
|
value={[Number(numericVal)]}
|
||||||
onValueChange={(vals: number[]) =>
|
onValueChange={(vals: number[]) =>
|
||||||
updateParamValue(vals[0])
|
updateParamLocal(vals[0])
|
||||||
}
|
}
|
||||||
|
onPointerUp={commitParamValue}
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
|
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
|
||||||
{step < 1
|
{step < 1
|
||||||
@@ -318,6 +449,20 @@ export function PropertiesPanel({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateParamValue(parseFloat(e.target.value) || 0)
|
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"
|
className="mt-1 h-7 w-full text-xs"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -373,23 +518,41 @@ export function PropertiesPanel({
|
|||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">Name</Label>
|
<Label className="text-xs">Name</Label>
|
||||||
<Input
|
<Input
|
||||||
value={selectedStep.name}
|
value={localStepName}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
onStepUpdate(selectedStep.id, { name: e.target.value })
|
const newName = e.target.value;
|
||||||
|
setLocalStepName(newName);
|
||||||
|
debouncedStepUpdate(selectedStep.id, { name: newName });
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (localStepName !== selectedStep.name) {
|
||||||
|
onStepUpdate(selectedStep.id, { name: localStepName });
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
className="mt-1 h-7 w-full text-xs"
|
className="mt-1 h-7 w-full text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">Description</Label>
|
<Label className="text-xs">Description</Label>
|
||||||
<Input
|
<Input
|
||||||
value={selectedStep.description ?? ""}
|
value={localStepDescription}
|
||||||
placeholder="Optional step description"
|
placeholder="Optional step description"
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
|
const newDesc = e.target.value;
|
||||||
|
setLocalStepDescription(newDesc);
|
||||||
|
debouncedStepUpdate(selectedStep.id, {
|
||||||
|
description: newDesc,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (
|
||||||
|
localStepDescription !== (selectedStep.description ?? "")
|
||||||
|
) {
|
||||||
onStepUpdate(selectedStep.id, {
|
onStepUpdate(selectedStep.id, {
|
||||||
description: e.target.value,
|
description: localStepDescription,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
className="mt-1 h-7 w-full text-xs"
|
className="mt-1 h-7 w-full text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -405,9 +568,9 @@ export function PropertiesPanel({
|
|||||||
<Label className="text-xs">Type</Label>
|
<Label className="text-xs">Type</Label>
|
||||||
<Select
|
<Select
|
||||||
value={selectedStep.type}
|
value={selectedStep.type}
|
||||||
onValueChange={(val) =>
|
onValueChange={(val) => {
|
||||||
onStepUpdate(selectedStep.id, { type: val as StepType })
|
onStepUpdate(selectedStep.id, { type: val as StepType });
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1 h-7 w-full text-xs">
|
<SelectTrigger className="mt-1 h-7 w-full text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -424,14 +587,14 @@ export function PropertiesPanel({
|
|||||||
<Label className="text-xs">Trigger</Label>
|
<Label className="text-xs">Trigger</Label>
|
||||||
<Select
|
<Select
|
||||||
value={selectedStep.trigger.type}
|
value={selectedStep.trigger.type}
|
||||||
onValueChange={(val) =>
|
onValueChange={(val) => {
|
||||||
onStepUpdate(selectedStep.id, {
|
onStepUpdate(selectedStep.id, {
|
||||||
trigger: {
|
trigger: {
|
||||||
...selectedStep.trigger,
|
...selectedStep.trigger,
|
||||||
type: val as TriggerType,
|
type: val as TriggerType,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1 h-7 w-full text-xs">
|
<SelectTrigger className="mt-1 h-7 w-full text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|||||||
@@ -777,4 +777,6 @@ export function FlowWorkspace({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FlowWorkspace;
|
// Wrap in React.memo to prevent unnecessary re-renders causing flashing
|
||||||
|
export default React.memo(FlowWorkspace);
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export interface BottomStatusBarProps {
|
|||||||
onValidate?: () => void;
|
onValidate?: () => void;
|
||||||
onExport?: () => void;
|
onExport?: () => void;
|
||||||
onOpenCommandPalette?: () => void;
|
onOpenCommandPalette?: () => void;
|
||||||
onToggleVersionStrategy?: () => void;
|
onRecalculateHash?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
validating?: boolean;
|
validating?: boolean;
|
||||||
@@ -56,7 +56,7 @@ export function BottomStatusBar({
|
|||||||
onValidate,
|
onValidate,
|
||||||
onExport,
|
onExport,
|
||||||
onOpenCommandPalette,
|
onOpenCommandPalette,
|
||||||
onToggleVersionStrategy,
|
onRecalculateHash,
|
||||||
className,
|
className,
|
||||||
saving,
|
saving,
|
||||||
validating,
|
validating,
|
||||||
@@ -198,9 +198,9 @@ export function BottomStatusBar({
|
|||||||
if (onOpenCommandPalette) onOpenCommandPalette();
|
if (onOpenCommandPalette) onOpenCommandPalette();
|
||||||
}, [onOpenCommandPalette]);
|
}, [onOpenCommandPalette]);
|
||||||
|
|
||||||
const handleToggleVersionStrategy = useCallback(() => {
|
const handleRecalculateHash = useCallback(() => {
|
||||||
if (onToggleVersionStrategy) onToggleVersionStrategy();
|
if (onRecalculateHash) onRecalculateHash();
|
||||||
}, [onToggleVersionStrategy]);
|
}, [onRecalculateHash]);
|
||||||
|
|
||||||
/* ------------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------------ */
|
||||||
/* Render */
|
/* Render */
|
||||||
@@ -265,12 +265,21 @@ export function BottomStatusBar({
|
|||||||
{autoSaveEnabled ? "auto-save on" : "auto-save off"}
|
{autoSaveEnabled ? "auto-save on" : "auto-save off"}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="hidden cursor-pointer items-center gap-1 sm:flex"
|
className="hidden items-center gap-1 font-mono text-[10px] sm:flex"
|
||||||
title={`Version strategy: ${versionStrategy}`}
|
title={`Current design hash: ${currentDesignHash ?? 'Not computed'}`}
|
||||||
onClick={handleToggleVersionStrategy}
|
|
||||||
>
|
>
|
||||||
<Wand2 className="h-3 w-3" />
|
<Hash className="h-3 w-3" />
|
||||||
{versionStrategy.replace(/_/g, " ")}
|
{currentDesignHash?.slice(0, 16) ?? '—'}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-5 px-1 ml-1"
|
||||||
|
onClick={handleRecalculateHash}
|
||||||
|
aria-label="Recalculate hash"
|
||||||
|
title="Recalculate hash"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="text-muted-foreground/80 hidden items-center gap-1 text-[10px] font-normal tracking-wide md:flex"
|
className="text-muted-foreground/80 hidden items-center gap-1 text-[10px] font-normal tracking-wide md:flex"
|
||||||
|
|||||||
@@ -487,4 +487,6 @@ export function ActionLibraryPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ActionLibraryPanel;
|
// Wrap in React.memo to prevent unnecessary re-renders causing flashing in categories
|
||||||
|
export default React.memo(ActionLibraryPanel);
|
||||||
|
|
||||||
|
|||||||
@@ -48,9 +48,18 @@ export interface InspectorPanelProps {
|
|||||||
*/
|
*/
|
||||||
onTabChange?: (tab: "properties" | "issues" | "dependencies") => void;
|
onTabChange?: (tab: "properties" | "issues" | "dependencies") => void;
|
||||||
/**
|
/**
|
||||||
* Whether to auto-switch to properties tab when selection changes.
|
* If true, auto-switch to "properties" when a selection occurs.
|
||||||
*/
|
*/
|
||||||
autoFocusOnSelection?: boolean;
|
autoFocusOnSelection?: boolean;
|
||||||
|
/**
|
||||||
|
* Study plugins with name and metadata
|
||||||
|
*/
|
||||||
|
studyPlugins?: Array<{
|
||||||
|
id: string;
|
||||||
|
robotId: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InspectorPanel({
|
export function InspectorPanel({
|
||||||
@@ -58,6 +67,7 @@ export function InspectorPanel({
|
|||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
autoFocusOnSelection = true,
|
autoFocusOnSelection = true,
|
||||||
|
studyPlugins,
|
||||||
}: InspectorPanelProps) {
|
}: InspectorPanelProps) {
|
||||||
/* ------------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------------ */
|
||||||
/* Store Selectors */
|
/* Store Selectors */
|
||||||
@@ -339,6 +349,7 @@ export function InspectorPanel({
|
|||||||
steps={steps}
|
steps={steps}
|
||||||
actionSignatureDrift={actionSignatureDrift}
|
actionSignatureDrift={actionSignatureDrift}
|
||||||
actionDefinitions={actionRegistry.getAllActions()}
|
actionDefinitions={actionRegistry.getAllActions()}
|
||||||
|
studyPlugins={studyPlugins}
|
||||||
onReconcileAction={(actionId) => {
|
onReconcileAction={(actionId) => {
|
||||||
// Placeholder: future diff modal / signature update
|
// Placeholder: future diff modal / signature update
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export interface DesignHashOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_OPTIONS: Required<DesignHashOptions> = {
|
const DEFAULT_OPTIONS: Required<DesignHashOptions> = {
|
||||||
includeParameterValues: false,
|
includeParameterValues: true, // Changed to true so parameter changes trigger hash updates
|
||||||
includeActionNames: true,
|
includeActionNames: true,
|
||||||
includeStepNames: true,
|
includeStepNames: true,
|
||||||
};
|
};
|
||||||
@@ -301,7 +301,12 @@ export async function computeIncrementalDesignHash(
|
|||||||
// First compute per-action hashes
|
// First compute per-action hashes
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
for (const action of step.actions) {
|
for (const action of step.actions) {
|
||||||
const existing = previous?.actionHashes.get(action.id);
|
// Only reuse cached hash if we're NOT including parameter values
|
||||||
|
// (because parameter values can change without changing the action ID)
|
||||||
|
const existing = !options.includeParameterValues
|
||||||
|
? previous?.actionHashes.get(action.id)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Simple heuristic: if shallow structural keys unchanged, reuse
|
// Simple heuristic: if shallow structural keys unchanged, reuse
|
||||||
// (We still project to confirm minimal structure; deeper diff omitted for performance.)
|
// (We still project to confirm minimal structure; deeper diff omitted for performance.)
|
||||||
@@ -316,7 +321,12 @@ export async function computeIncrementalDesignHash(
|
|||||||
|
|
||||||
// Then compute step hashes (including ordered list of action hashes)
|
// Then compute step hashes (including ordered list of action hashes)
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
const existing = previous?.stepHashes.get(step.id);
|
// Only reuse cached hash if we're NOT including parameter values
|
||||||
|
// (because parameter values in actions can change without changing the step ID)
|
||||||
|
const existing = !options.includeParameterValues
|
||||||
|
? previous?.stepHashes.get(step.id)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
stepHashes.set(step.id, existing);
|
stepHashes.set(step.id, existing);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -114,14 +114,14 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/experiments/${experiment.id}`}>
|
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
View Details
|
View Details
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/experiments/${experiment.id}/designer`}>
|
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
|
||||||
<FlaskConical className="mr-2 h-4 w-4" />
|
<FlaskConical className="mr-2 h-4 w-4" />
|
||||||
Open Designer
|
Open Designer
|
||||||
</Link>
|
</Link>
|
||||||
@@ -129,7 +129,7 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
|
|||||||
|
|
||||||
{experiment.canEdit && (
|
{experiment.canEdit && (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/experiments/${experiment.id}/edit`}>
|
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit Experiment
|
Edit Experiment
|
||||||
</Link>
|
</Link>
|
||||||
@@ -202,7 +202,7 @@ export const experimentsColumns: ColumnDef<Experiment>[] = [
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-[200px] min-w-0 space-y-1">
|
<div className="max-w-[200px] min-w-0 space-y-1">
|
||||||
<Link
|
<Link
|
||||||
href={`/experiments/${experiment.id}`}
|
href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}
|
||||||
className="block truncate font-medium hover:underline"
|
className="block truncate font-medium hover:underline"
|
||||||
title={experiment.name}
|
title={experiment.name}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function getCameraImage(
|
|||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const camera = params.camera as string;
|
const camera = params.camera as string;
|
||||||
const topic =
|
const topic =
|
||||||
camera === "front" ? "/camera/front/image_raw" : "/camera/bottom/image_raw";
|
camera === "front" ? "/naoqi_driver/camera/front/image_raw" : "/naoqi_driver/camera/bottom/image_raw";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscribe: true,
|
subscribe: true,
|
||||||
@@ -88,7 +88,7 @@ export function getJointStates(
|
|||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
subscribe: true,
|
subscribe: true,
|
||||||
topic: "/joint_states",
|
topic: "/naoqi_driver/joint_states",
|
||||||
messageType: "sensor_msgs/msg/JointState",
|
messageType: "sensor_msgs/msg/JointState",
|
||||||
once: true,
|
once: true,
|
||||||
};
|
};
|
||||||
@@ -102,7 +102,7 @@ export function getImuData(
|
|||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
subscribe: true,
|
subscribe: true,
|
||||||
topic: "/imu/torso",
|
topic: "/naoqi_driver/imu/torso",
|
||||||
messageType: "sensor_msgs/msg/Imu",
|
messageType: "sensor_msgs/msg/Imu",
|
||||||
once: true,
|
once: true,
|
||||||
};
|
};
|
||||||
@@ -116,7 +116,7 @@ export function getBumperStatus(
|
|||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
subscribe: true,
|
subscribe: true,
|
||||||
topic: "/bumper",
|
topic: "/naoqi_driver/bumper",
|
||||||
messageType: "naoqi_bridge_msgs/msg/Bumper",
|
messageType: "naoqi_bridge_msgs/msg/Bumper",
|
||||||
once: true,
|
once: true,
|
||||||
};
|
};
|
||||||
@@ -129,7 +129,7 @@ export function getTouchSensors(
|
|||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const sensorType = params.sensor_type as string;
|
const sensorType = params.sensor_type as string;
|
||||||
const topic = sensorType === "hand" ? "/hand_touch" : "/head_touch";
|
const topic = sensorType === "hand" ? "/naoqi_driver/hand_touch" : "/naoqi_driver/head_touch";
|
||||||
const messageType =
|
const messageType =
|
||||||
sensorType === "hand"
|
sensorType === "hand"
|
||||||
? "naoqi_bridge_msgs/msg/HandTouch"
|
? "naoqi_bridge_msgs/msg/HandTouch"
|
||||||
@@ -153,12 +153,12 @@ export function getSonarRange(
|
|||||||
let topic: string;
|
let topic: string;
|
||||||
|
|
||||||
if (sensor === "left") {
|
if (sensor === "left") {
|
||||||
topic = "/sonar/left";
|
topic = "/naoqi_driver/sonar/left";
|
||||||
} else if (sensor === "right") {
|
} else if (sensor === "right") {
|
||||||
topic = "/sonar/right";
|
topic = "/naoqi_driver/sonar/right";
|
||||||
} else {
|
} else {
|
||||||
// For "both", we'll default to left and let the wizard interface handle multiple calls
|
// For "both", we'll default to left and let the wizard interface handle multiple calls
|
||||||
topic = "/sonar/left";
|
topic = "/naoqi_driver/sonar/left";
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -177,7 +177,7 @@ export function getRobotInfo(
|
|||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
subscribe: true,
|
subscribe: true,
|
||||||
topic: "/info",
|
topic: "/naoqi_driver/info",
|
||||||
messageType: "naoqi_bridge_msgs/msg/RobotInfo",
|
messageType: "naoqi_bridge_msgs/msg/RobotInfo",
|
||||||
once: true,
|
once: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -152,10 +152,10 @@ export const experimentsRouter = createTRPCRouter({
|
|||||||
.select({
|
.select({
|
||||||
experimentId: trials.experimentId,
|
experimentId: trials.experimentId,
|
||||||
latest: sql`max(GREATEST(
|
latest: sql`max(GREATEST(
|
||||||
COALESCE(${trials.completedAt}, 'epoch'::timestamptz),
|
COALESCE(${trials.completedAt}, 'epoch':: timestamptz),
|
||||||
COALESCE(${trials.startedAt}, 'epoch'::timestamptz),
|
COALESCE(${trials.startedAt}, 'epoch':: timestamptz),
|
||||||
COALESCE(${trials.createdAt}, 'epoch'::timestamptz)
|
COALESCE(${trials.createdAt}, 'epoch':: timestamptz)
|
||||||
))`.as("latest"),
|
))`.as("latest"),
|
||||||
})
|
})
|
||||||
.from(trials)
|
.from(trials)
|
||||||
.where(inArray(trials.experimentId, experimentIds))
|
.where(inArray(trials.experimentId, experimentIds))
|
||||||
@@ -511,8 +511,7 @@ export const experimentsRouter = createTRPCRouter({
|
|||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
issues: [
|
issues: [
|
||||||
`Compilation failed: ${
|
`Compilation failed: ${err instanceof Error ? err.message : "Unknown error"
|
||||||
err instanceof Error ? err.message : "Unknown error"
|
|
||||||
}`,
|
}`,
|
||||||
],
|
],
|
||||||
pluginDependencies: [],
|
pluginDependencies: [],
|
||||||
@@ -570,6 +569,7 @@ export const experimentsRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { id, createSteps, compileExecution, ...updateData } = input;
|
const { id, createSteps, compileExecution, ...updateData } = input;
|
||||||
const userId = ctx.session.user.id;
|
const userId = ctx.session.user.id;
|
||||||
|
console.log("[DEBUG] experiments.update called", { id, visualDesign: updateData.visualDesign, createSteps });
|
||||||
|
|
||||||
// Get experiment to check study access
|
// Get experiment to check study access
|
||||||
const experiment = await ctx.db.query.experiments.findFirst({
|
const experiment = await ctx.db.query.experiments.findFirst({
|
||||||
@@ -607,7 +607,7 @@ export const experimentsRouter = createTRPCRouter({
|
|||||||
if (issues.length) {
|
if (issues.length) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: `Visual design validation failed:\n- ${issues.join("\n- ")}`,
|
message: `Visual design validation failed: \n - ${issues.join("\n- ")}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
normalizedSteps = guardedSteps;
|
normalizedSteps = guardedSteps;
|
||||||
@@ -637,8 +637,7 @@ export const experimentsRouter = createTRPCRouter({
|
|||||||
} catch (compileErr) {
|
} catch (compileErr) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: `Execution graph compilation failed: ${
|
message: `Execution graph compilation failed: ${compileErr instanceof Error
|
||||||
compileErr instanceof Error
|
|
||||||
? compileErr.message
|
? compileErr.message
|
||||||
: "Unknown error"
|
: "Unknown error"
|
||||||
}`,
|
}`,
|
||||||
@@ -735,11 +734,13 @@ export const experimentsRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const updatedExperiment = updatedExperimentResults[0];
|
const updatedExperiment = updatedExperimentResults[0];
|
||||||
if (!updatedExperiment) {
|
if (!updatedExperiment) {
|
||||||
|
console.error("[DEBUG] Failed to update experiment - no result returned");
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
message: "Failed to update experiment",
|
message: "Failed to update experiment",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
console.log("[DEBUG] Experiment updated successfully", { updatedAt: updatedExperiment.updatedAt });
|
||||||
|
|
||||||
// Log activity
|
// Log activity
|
||||||
await ctx.db.insert(activityLogs).values({
|
await ctx.db.insert(activityLogs).values({
|
||||||
|
|||||||
Reference in New Issue
Block a user