feat: Relocate experiment designer routes under studies, update ROS2 topic paths, and enhance designer hashing and performance.

This commit is contained in:
2025-11-19 18:05:19 -05:00
parent 86b5ed80c4
commit b21ed8e805
19 changed files with 647 additions and 288 deletions

0
bun.lock Executable file → Normal file
View File

View File

@@ -64,7 +64,8 @@ export default function NaoTestPage() {
const [sensorData, setSensorData] = useState<any>({});
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 timestamp = new Date().toLocaleTimeString();

View File

@@ -48,7 +48,7 @@ export function DesignerPageClient({
},
{
label: experiment.name,
href: `/experiments/${experiment.id}`,
href: `/studies/${experiment.study.id}/experiments/${experiment.id}`,
},
{
label: "Designer",

View File

@@ -11,7 +11,7 @@ import { DesignerPageClient } from "./DesignerPageClient";
interface ExperimentDesignerPageProps {
params: Promise<{
id: string;
experimentId: string;
}>;
}
@@ -20,7 +20,7 @@ export default async function ExperimentDesignerPage({
}: ExperimentDesignerPageProps) {
try {
const resolvedParams = await params;
const experiment = await api.experiments.get({ id: resolvedParams.id });
const experiment = await api.experiments.get({ id: resolvedParams.experimentId });
if (!experiment) {
notFound();
@@ -36,13 +36,13 @@ export default async function ExperimentDesignerPage({
// Only pass initialDesign if there's existing visual design data
let initialDesign:
| {
id: string;
name: string;
description: string;
steps: ExperimentStep[];
version: number;
lastSaved: Date;
}
id: string;
name: string;
description: string;
steps: ExperimentStep[];
version: number;
lastSaved: Date;
}
| undefined;
if (existingDesign?.steps && existingDesign.steps.length > 0) {
@@ -258,7 +258,7 @@ export async function generateMetadata({
}> {
try {
const resolvedParams = await params;
const experiment = await api.experiments.get({ id: resolvedParams.id });
const experiment = await api.experiments.get({ id: resolvedParams.experimentId });
return {
title: `${experiment?.name} - Designer | HRIStudio`,

View File

@@ -185,7 +185,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
</Link>
</Button>
<Button asChild>
<Link href={`/experiments/new?studyId=${study.id}`}>
<Link href={`/studies/${study.id}/experiments/new`}>
<Plus className="mr-2 h-4 w-4" />
New Experiment
</Link>
@@ -232,7 +232,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
description="Design and manage experimental protocols for this study"
actions={
<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" />
Add Experiment
</Link>
@@ -246,7 +246,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
description="Create your first experiment to start designing research protocols"
action={
<Button asChild>
<Link href={`/experiments/new?studyId=${study.id}`}>
<Link href={`/studies/${study.id}/experiments/new`}>
Create First Experiment
</Link>
</Button>
@@ -263,20 +263,19 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
<div className="flex items-center space-x-3">
<h4 className="font-medium">
<Link
href={`/experiments/${experiment.id}`}
href={`/studies/${study.id}/experiments/${experiment.id}`}
className="hover:underline"
>
{experiment.name}
</Link>
</h4>
<span
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
experiment.status === "draft"
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${experiment.status === "draft"
? "bg-gray-100 text-gray-800"
: experiment.status === "ready"
? "bg-green-100 text-green-800"
: "bg-blue-100 text-blue-800"
}`}
}`}
>
{experiment.status}
</span>
@@ -300,12 +299,12 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
</div>
<div className="flex items-center space-x-2">
<Button asChild variant="outline" size="sm">
<Link href={`/experiments/${experiment.id}/designer`}>
<Link href={`/studies/${study.id}/experiments/${experiment.id}/designer`}>
Design
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/experiments/${experiment.id}`}>View</Link>
<Link href={`/studies/${study.id}/experiments/${experiment.id}`}>View</Link>
</Button>
</div>
</div>

View File

@@ -87,33 +87,33 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
{ label: "Studies", href: "/studies" },
...(selectedStudyId
? [
{
label: experiment?.study?.name ?? "Study",
href: `/studies/${selectedStudyId}`,
},
{ label: "Experiments", href: "/experiments" },
...(mode === "edit" && experiment
? [
{
label: experiment.name,
href: `/experiments/${experiment.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Experiment" }]),
]
{
label: experiment?.study?.name ?? "Study",
href: `/studies/${selectedStudyId}`,
},
{ label: "Experiments", href: "/experiments" },
...(mode === "edit" && experiment
? [
{
label: experiment.name,
href: `/studies/${selectedStudyId}/experiments/${experiment.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Experiment" }]),
]
: [
{ label: "Experiments", href: "/experiments" },
...(mode === "edit" && experiment
? [
{
label: experiment.name,
href: `/experiments/${experiment.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Experiment" }]),
]),
{ label: "Experiments", href: "/experiments" },
...(mode === "edit" && experiment
? [
{
label: experiment.name,
href: `/studies/${experiment.studyId}/experiments/${experiment.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Experiment" }]),
]),
];
useBreadcrumbsEffect(breadcrumbs);
@@ -153,14 +153,14 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
...data,
estimatedDuration: data.estimatedDuration ?? undefined,
});
router.push(`/experiments/${newExperiment.id}/designer`);
router.push(`/studies/${data.studyId}/experiments/${newExperiment.id}/designer`);
} else {
const updatedExperiment = await updateExperimentMutation.mutateAsync({
id: experimentId!,
...data,
estimatedDuration: data.estimatedDuration ?? undefined,
});
router.push(`/experiments/${updatedExperiment.id}`);
router.push(`/studies/${experiment?.studyId ?? data.studyId}/experiments/${updatedExperiment.id}`);
}
} catch (error) {
setError(

View File

@@ -78,7 +78,7 @@ function ExperimentCard({ experiment }: ExperimentCardProps) {
<div className="min-w-0 flex-1">
<CardTitle className="truncate text-lg font-semibold text-slate-900 transition-colors group-hover:text-blue-600">
<Link
href={`/experiments/${experiment.id}`}
href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}
className="hover:underline"
>
{experiment.name}
@@ -158,10 +158,10 @@ function ExperimentCard({ experiment }: ExperimentCardProps) {
{/* Actions */}
<div className="flex gap-2 pt-2">
<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 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" />
Design
</Link>

View File

@@ -103,7 +103,7 @@ export const columns: ColumnDef<Experiment>[] = [
<div className="max-w-[200px]">
<div className="truncate font-medium">
<Link
href={`/experiments/${row.original.id}`}
href={`/studies/${row.original.studyId}/experiments/${row.original.id}`}
className="hover:underline"
>
{String(name)}
@@ -263,15 +263,15 @@ export const columns: ColumnDef<Experiment>[] = [
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/experiments/${experiment.id}`}>View details</Link>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>View details</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/experiments/${experiment.id}/edit`}>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
Edit experiment
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/experiments/${experiment.id}/designer`}>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
Open designer
</Link>
</DropdownMenuItem>

View File

@@ -57,6 +57,15 @@ export interface DependencyInspectorProps {
* Available action definitions from registry
*/
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
*/
@@ -80,6 +89,12 @@ function extractPluginDependencies(
steps: ExperimentStep[],
actionDefinitions: ActionDefinition[],
driftedActions: Set<string>,
studyPlugins?: Array<{
id: string;
robotId: string;
name: string;
version: string;
}>,
): PluginDependency[] {
const dependencyMap = new Map<string, PluginDependency>();
@@ -134,9 +149,12 @@ function extractPluginDependencies(
dep.installedVersion = dep.version;
}
// Set plugin name from first available definition
// Set plugin name from studyPlugins if available
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="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
variant={config.badgeVariant}
className={cn("h-4 text-[10px]", config.badgeColor)}
@@ -382,6 +402,7 @@ export function DependencyInspector({
steps,
actionSignatureDrift,
actionDefinitions,
studyPlugins,
onReconcileAction,
onRefreshDependencies,
onInstallPlugin,
@@ -389,8 +410,13 @@ export function DependencyInspector({
}: DependencyInspectorProps) {
const dependencies = useMemo(
() =>
extractPluginDependencies(steps, actionDefinitions, actionSignatureDrift),
[steps, actionDefinitions, actionSignatureDrift],
extractPluginDependencies(
steps,
actionDefinitions,
actionSignatureDrift,
studyPlugins,
),
[steps, actionDefinitions, actionSignatureDrift, studyPlugins],
);
const drifts = useMemo(

View File

@@ -1,9 +1,16 @@
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
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 { Button } from "~/components/ui/button";
@@ -150,20 +157,28 @@ export function DesignerRoot({
} = api.experiments.get.useQuery({ id: experimentId });
const updateExperiment = api.experiments.update.useMutation({
onSuccess: async () => {
toast.success("Experiment saved");
await refetchExperiment();
},
onError: (err) => {
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 ?? "" },
{ 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 ----------------------------- */
const steps = useDesignerStore((s) => s.steps);
const setSteps = useDesignerStore((s) => s.setSteps);
@@ -230,6 +245,7 @@ export function DesignerRoot({
const [isSaving, setIsSaving] = useState(false);
const [isValidating, setIsValidating] = 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 [inspectorTab, setInspectorTab] = useState<
@@ -250,6 +266,13 @@ export function DesignerRoot({
useEffect(() => {
if (initialized) return;
if (loadingExperiment && !initialDesign) return;
console.log('[DesignerRoot] 🚀 INITIALIZING', {
hasExperiment: !!experiment,
hasInitialDesign: !!initialDesign,
loadingExperiment,
});
const adapted =
initialDesign ??
(experiment
@@ -274,8 +297,9 @@ export function DesignerRoot({
setValidatedHash(ih);
}
setInitialized(true);
// Kick initial hash
void recomputeHash();
// NOTE: We don't call recomputeHash() here because the automatic
// hash recomputation useEffect will trigger when setSteps() updates the steps array
console.log('[DesignerRoot] 🚀 Initialization complete, steps set');
}, [
initialized,
loadingExperiment,
@@ -299,26 +323,69 @@ export function DesignerRoot({
// Load plugin actions when study plugins available
useEffect(() => {
if (!experiment?.studyId) return;
if (!studyPlugins || studyPlugins.length === 0) return;
actionRegistry.loadPluginActions(
experiment.studyId,
studyPlugins.map((sp) => ({
plugin: {
id: sp.plugin.id,
robotId: sp.plugin.robotId,
version: sp.plugin.version,
actionDefinitions: Array.isArray(sp.plugin.actionDefinitions)
? sp.plugin.actionDefinitions
: undefined,
},
})),
);
}, [experiment?.studyId, studyPlugins]);
if (!studyPluginsRaw) return;
// @ts-expect-error - studyPluginsRaw type from tRPC is compatible but TypeScript can't infer it
actionRegistry.loadPluginActions(experiment.studyId, studyPluginsRaw);
}, [experiment?.studyId, studyPluginsRaw]);
/* ------------------------- Ready State Management ------------------------ */
// Mark as ready once initialized and plugins are loaded
useEffect(() => {
if (!initialized || isReady) return;
// Check if plugins are loaded by verifying the action registry has plugin actions
const debugInfo = actionRegistry.getDebugInfo();
const hasPlugins = debugInfo.pluginActionsLoaded;
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 ----------------------------- */
const hasUnsavedChanges =
!!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 -------------------------------- */
const createNewStep = useCallback(() => {
const newStep: ExperimentStep = {
@@ -386,8 +453,7 @@ export function DesignerRoot({
}
} catch (err) {
toast.error(
`Validation error: ${
err instanceof Error ? err.message : "Unknown error"
`Validation error: ${err instanceof Error ? err.message : "Unknown error"
}`,
);
} finally {
@@ -404,6 +470,14 @@ export function DesignerRoot({
/* --------------------------------- Save ---------------------------------- */
const persist = useCallback(async () => {
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);
try {
const visualDesign = {
@@ -411,15 +485,43 @@ export function DesignerRoot({
version: designMeta.version,
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,
visualDesign,
createSteps: true,
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());
toast.success("Experiment saved");
console.log('[DesignerRoot] 💾 SAVE complete');
onPersist?.({
id: experimentId,
name: designMeta.name,
@@ -428,16 +530,22 @@ export function DesignerRoot({
version: designMeta.version,
lastSaved: new Date(),
});
} catch (error) {
console.error('[DesignerRoot] 💾 SAVE failed:', error);
// Error already handled by mutation onError
} finally {
setIsSaving(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
initialized,
steps,
designMeta,
experimentId,
updateExperiment,
recomputeHash,
currentDesignHash,
setPersistedHash,
refetchExperiment,
onPersist,
autoCompile,
]);
@@ -479,8 +587,7 @@ export function DesignerRoot({
toast.success("Exported design bundle");
} catch (err) {
toast.error(
`Export failed: ${
err instanceof Error ? err.message : "Unknown error"
`Export failed: ${err instanceof Error ? err.message : "Unknown error"
}`,
);
} finally {
@@ -489,10 +596,14 @@ export function DesignerRoot({
}, [currentDesignHash, steps, experimentId, designMeta, experiment]);
/* ---------------------------- Incremental Hash --------------------------- */
// Serialize steps for stable comparison
const stepsHash = useMemo(() => JSON.stringify(steps), [steps]);
useEffect(() => {
if (!initialized) return;
void recomputeHash();
}, [steps.length, initialized, recomputeHash]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stepsHash, initialized]);
useEffect(() => {
if (selectedStepId || selectedActionId) {
@@ -627,17 +738,17 @@ export function DesignerRoot({
const execution: ExperimentAction["execution"] =
actionDef.execution &&
(actionDef.execution.transport === "internal" ||
actionDef.execution.transport === "rest" ||
actionDef.execution.transport === "ros2")
(actionDef.execution.transport === "internal" ||
actionDef.execution.transport === "rest" ||
actionDef.execution.transport === "ros2")
? {
transport: actionDef.execution.transport,
retryable: actionDef.execution.retryable ?? false,
}
transport: actionDef.execution.transport,
retryable: actionDef.execution.retryable ?? false,
}
: {
transport: "internal",
retryable: false,
};
transport: "internal",
retryable: false,
};
const newAction: ExperimentAction = {
id: `action-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
type: actionDef.type,
@@ -677,80 +788,104 @@ export function DesignerRoot({
);
}
const actions = (
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="default"
className="h-8 px-3 text-xs"
onClick={() => validateDesign()}
disabled={isValidating}
>
Validate
</Button>
<Button
size="sm"
variant="secondary"
className="h-8 px-3 text-xs"
onClick={() => persist()}
disabled={!hasUnsavedChanges || isSaving}
>
Save
</Button>
</div>
);
return (
<div className="space-y-4">
<div className="flex h-full w-full flex-col overflow-hidden">
<PageHeader
title={designMeta.name}
description="Compose ordered steps with provenance-aware actions."
description={designMeta.description || "No description"}
icon={Play}
actions={
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="default"
className="h-8 px-3 text-xs"
onClick={() => validateDesign()}
disabled={isValidating}
>
Validate
</Button>
<Button
size="sm"
variant="secondary"
className="h-8 px-3 text-xs"
onClick={() => persist()}
disabled={!hasUnsavedChanges || isSaving}
>
Save
</Button>
</div>
}
actions={actions}
/>
<div className="flex h-[calc(100vh-12rem)] w-full max-w-full flex-col overflow-hidden rounded-md border">
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={() => toggleLibraryScrollLock(false)}
<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"
)}
>
<PanelsContainer
showDividers
className="min-h-0 flex-1"
left={
<div ref={libraryRootRef} data-library-root className="h-full">
<ActionLibraryPanel />
</div>
}
center={<FlowWorkspace />}
right={
<div className="h-full">
<InspectorPanel
activeTab={inspectorTab}
onTabChange={setInspectorTab}
/>
</div>
}
/>
<DragOverlay>
{dragOverlayAction ? (
<div className="bg-background pointer-events-none rounded border px-2 py-1 text-xs shadow-lg select-none">
{dragOverlayAction.name}
</div>
) : null}
</DragOverlay>
</DndContext>
<div className="flex-shrink-0 border-t">
<BottomStatusBar
onSave={() => persist()}
onValidate={() => validateDesign()}
onExport={() => handleExport()}
lastSavedAt={lastSavedAt}
saving={isSaving}
validating={isValidating}
exporting={isExporting}
/>
<div className="flex h-[calc(100vh-12rem)] w-full max-w-full flex-col overflow-hidden rounded-md border">
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={() => toggleLibraryScrollLock(false)}
>
<PanelsContainer
showDividers
className="min-h-0 flex-1"
left={
<div ref={libraryRootRef} data-library-root className="h-full">
<ActionLibraryPanel />
</div>
}
center={<FlowWorkspace />}
right={
<div className="h-full">
<InspectorPanel
activeTab={inspectorTab}
onTabChange={setInspectorTab}
studyPlugins={studyPlugins}
/>
</div>
}
/>
<DragOverlay>
{dragOverlayAction ? (
<div className="bg-background pointer-events-none rounded border px-2 py-1 text-xs shadow-lg select-none">
{dragOverlayAction.name}
</div>
) : null}
</DragOverlay>
</DndContext>
<div className="flex-shrink-0 border-t">
<BottomStatusBar
onSave={() => persist()}
onValidate={() => validateDesign()}
onExport={() => handleExport()}
onRecalculateHash={() => recomputeHash()}
lastSavedAt={lastSavedAt}
saving={isSaving}
validating={isValidating}
exporting={isExporting}
/>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import React from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
@@ -80,6 +80,85 @@ export function PropertiesPanel({
}: PropertiesPanelProps) {
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)
const containingStep =
selectedAction &&
@@ -176,12 +255,21 @@ export function PropertiesPanel({
<div>
<Label className="text-xs">Display Name</Label>
<Input
value={selectedAction.name}
onChange={(e) =>
onActionUpdate(containingStep.id, selectedAction.id, {
name: e.target.value,
})
}
value={localActionName}
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, {
name: localActionName,
});
}
}}
className="mt-1 h-7 w-full text-xs"
/>
</div>
@@ -210,6 +298,17 @@ export function PropertiesPanel({
/* ---- 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,
@@ -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 ---- */
let control: React.ReactNode = null;
if (param.type === "text") {
const localValue = localParams[param.id] ?? rawValue ?? "";
control = (
<Input
value={(rawValue as string) ?? ""}
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={(rawValue as string) ?? ""}
onValueChange={(val) => updateParamValue(val)}
value={localValue as string}
onValueChange={(val) => updateParamValueImmediate(val)}
>
<SelectTrigger className="mt-1 h-7 w-full text-xs">
<SelectValue placeholder="Select…" />
@@ -249,22 +375,26 @@ export function PropertiesPanel({
</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(rawValue)}
onCheckedChange={(val) => updateParamValue(val)}
checked={Boolean(localValue)}
onCheckedChange={(val) =>
updateParamValueImmediate(val)
}
aria-label={param.name}
/>
<span className="text-muted-foreground ml-2 text-[11px]">
{Boolean(rawValue) ? "Enabled" : "Disabled"}
{Boolean(localValue) ? "Enabled" : "Disabled"}
</span>
</div>
);
} else if (param.type === "number") {
const localValue = localParams[param.id] ?? rawValue;
const numericVal =
typeof rawValue === "number"
? rawValue
typeof localValue === "number"
? localValue
: typeof param.value === "number"
? param.value
: (param.min ?? 0);
@@ -295,8 +425,9 @@ export function PropertiesPanel({
step={step}
value={[Number(numericVal)]}
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">
{step < 1
@@ -318,6 +449,20 @@ export function PropertiesPanel({
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"
/>
);
@@ -373,23 +518,41 @@ export function PropertiesPanel({
<div>
<Label className="text-xs">Name</Label>
<Input
value={selectedStep.name}
onChange={(e) =>
onStepUpdate(selectedStep.id, { name: e.target.value })
}
value={localStepName}
onChange={(e) => {
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"
/>
</div>
<div>
<Label className="text-xs">Description</Label>
<Input
value={selectedStep.description ?? ""}
value={localStepDescription}
placeholder="Optional step description"
onChange={(e) =>
onStepUpdate(selectedStep.id, {
description: e.target.value,
})
}
onChange={(e) => {
const newDesc = e.target.value;
setLocalStepDescription(newDesc);
debouncedStepUpdate(selectedStep.id, {
description: newDesc,
});
}}
onBlur={() => {
if (
localStepDescription !== (selectedStep.description ?? "")
) {
onStepUpdate(selectedStep.id, {
description: localStepDescription,
});
}
}}
className="mt-1 h-7 w-full text-xs"
/>
</div>
@@ -405,9 +568,9 @@ export function PropertiesPanel({
<Label className="text-xs">Type</Label>
<Select
value={selectedStep.type}
onValueChange={(val) =>
onStepUpdate(selectedStep.id, { type: val as StepType })
}
onValueChange={(val) => {
onStepUpdate(selectedStep.id, { type: val as StepType });
}}
>
<SelectTrigger className="mt-1 h-7 w-full text-xs">
<SelectValue />
@@ -424,14 +587,14 @@ export function PropertiesPanel({
<Label className="text-xs">Trigger</Label>
<Select
value={selectedStep.trigger.type}
onValueChange={(val) =>
onValueChange={(val) => {
onStepUpdate(selectedStep.id, {
trigger: {
...selectedStep.trigger,
type: val as TriggerType,
},
})
}
});
}}
>
<SelectTrigger className="mt-1 h-7 w-full text-xs">
<SelectValue />

View File

@@ -111,7 +111,7 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
className={cn(
"pointer-events-none absolute inset-0 rounded-md transition-colors",
isOver &&
"bg-blue-50/40 ring-2 ring-blue-400/60 ring-offset-0 dark:bg-blue-950/20",
"bg-blue-50/40 ring-2 ring-blue-400/60 ring-offset-0 dark:bg-blue-950/20",
)}
/>
);
@@ -182,11 +182,11 @@ function SortableActionChip({
"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]
wizard: "bg-blue-500",
robot: "bg-emerald-500",
control: "bg-amber-500",
observation: "bg-purple-500",
}[def.category]
: "bg-slate-400",
)}
/>
@@ -608,7 +608,7 @@ export function FlowWorkspace({
renameStep(
step,
(e.target as HTMLInputElement).value.trim() ||
step.name,
step.name,
);
setRenamingStepId(null);
void recomputeHash();
@@ -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);

View File

@@ -40,7 +40,7 @@ export interface BottomStatusBarProps {
onValidate?: () => void;
onExport?: () => void;
onOpenCommandPalette?: () => void;
onToggleVersionStrategy?: () => void;
onRecalculateHash?: () => void;
className?: string;
saving?: boolean;
validating?: boolean;
@@ -56,7 +56,7 @@ export function BottomStatusBar({
onValidate,
onExport,
onOpenCommandPalette,
onToggleVersionStrategy,
onRecalculateHash,
className,
saving,
validating,
@@ -198,9 +198,9 @@ export function BottomStatusBar({
if (onOpenCommandPalette) onOpenCommandPalette();
}, [onOpenCommandPalette]);
const handleToggleVersionStrategy = useCallback(() => {
if (onToggleVersionStrategy) onToggleVersionStrategy();
}, [onToggleVersionStrategy]);
const handleRecalculateHash = useCallback(() => {
if (onRecalculateHash) onRecalculateHash();
}, [onRecalculateHash]);
/* ------------------------------------------------------------------------ */
/* Render */
@@ -265,12 +265,21 @@ export function BottomStatusBar({
{autoSaveEnabled ? "auto-save on" : "auto-save off"}
</div>
<div
className="hidden cursor-pointer items-center gap-1 sm:flex"
title={`Version strategy: ${versionStrategy}`}
onClick={handleToggleVersionStrategy}
className="hidden items-center gap-1 font-mono text-[10px] sm:flex"
title={`Current design hash: ${currentDesignHash ?? 'Not computed'}`}
>
<Wand2 className="h-3 w-3" />
{versionStrategy.replace(/_/g, " ")}
<Hash className="h-3 w-3" />
{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
className="text-muted-foreground/80 hidden items-center gap-1 text-[10px] font-normal tracking-wide md:flex"

View File

@@ -88,8 +88,8 @@ function DraggableAction({
const style: React.CSSProperties = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
: {};
const IconComponent = iconMap[action.icon] ?? Sparkles;
@@ -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);

View File

@@ -48,9 +48,18 @@ export interface InspectorPanelProps {
*/
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;
/**
* Study plugins with name and metadata
*/
studyPlugins?: Array<{
id: string;
robotId: string;
name: string;
version: string;
}>;
}
export function InspectorPanel({
@@ -58,6 +67,7 @@ export function InspectorPanel({
activeTab,
onTabChange,
autoFocusOnSelection = true,
studyPlugins,
}: InspectorPanelProps) {
/* ------------------------------------------------------------------------ */
/* Store Selectors */
@@ -339,6 +349,7 @@ export function InspectorPanel({
steps={steps}
actionSignatureDrift={actionSignatureDrift}
actionDefinitions={actionRegistry.getAllActions()}
studyPlugins={studyPlugins}
onReconcileAction={(actionId) => {
// Placeholder: future diff modal / signature update

View File

@@ -130,7 +130,7 @@ export interface DesignHashOptions {
}
const DEFAULT_OPTIONS: Required<DesignHashOptions> = {
includeParameterValues: false,
includeParameterValues: true, // Changed to true so parameter changes trigger hash updates
includeActionNames: true,
includeStepNames: true,
};
@@ -175,16 +175,16 @@ function projectExecutionDescriptor(
timeoutMs: exec.timeoutMs ?? null,
ros2: exec.ros2
? {
topic: exec.ros2.topic ?? null,
service: exec.ros2.service ?? null,
action: exec.ros2.action ?? null,
}
topic: exec.ros2.topic ?? null,
service: exec.ros2.service ?? null,
action: exec.ros2.action ?? null,
}
: null,
rest: exec.rest
? {
method: exec.rest.method,
path: exec.rest.path,
}
method: exec.rest.method,
path: exec.rest.path,
}
: null,
};
}
@@ -244,10 +244,10 @@ export async function computeActionSignature(
baseActionId: def.baseActionId ?? null,
execution: def.execution
? {
transport: def.execution.transport,
retryable: def.execution.retryable ?? false,
timeoutMs: def.execution.timeoutMs ?? null,
}
transport: def.execution.transport,
retryable: def.execution.retryable ?? false,
timeoutMs: def.execution.timeoutMs ?? null,
}
: null,
schema: def.parameterSchemaRaw ? canonicalize(def.parameterSchemaRaw) : null,
};
@@ -301,7 +301,12 @@ export async function computeIncrementalDesignHash(
// First compute per-action hashes
for (const step of steps) {
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) {
// Simple heuristic: if shallow structural keys unchanged, reuse
// (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)
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) {
stepHashes.set(step.id, existing);
continue;

View File

@@ -114,14 +114,14 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/experiments/${experiment.id}`}>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
<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" />
Open Designer
</Link>
@@ -129,7 +129,7 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
{experiment.canEdit && (
<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 Experiment
</Link>
@@ -202,7 +202,7 @@ export const experimentsColumns: ColumnDef<Experiment>[] = [
return (
<div className="max-w-[200px] min-w-0 space-y-1">
<Link
href={`/experiments/${experiment.id}`}
href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}
className="block truncate font-medium hover:underline"
title={experiment.name}
>

View File

@@ -70,7 +70,7 @@ export function getCameraImage(
): Record<string, unknown> {
const camera = params.camera as string;
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 {
subscribe: true,
@@ -88,7 +88,7 @@ export function getJointStates(
): Record<string, unknown> {
return {
subscribe: true,
topic: "/joint_states",
topic: "/naoqi_driver/joint_states",
messageType: "sensor_msgs/msg/JointState",
once: true,
};
@@ -102,7 +102,7 @@ export function getImuData(
): Record<string, unknown> {
return {
subscribe: true,
topic: "/imu/torso",
topic: "/naoqi_driver/imu/torso",
messageType: "sensor_msgs/msg/Imu",
once: true,
};
@@ -116,7 +116,7 @@ export function getBumperStatus(
): Record<string, unknown> {
return {
subscribe: true,
topic: "/bumper",
topic: "/naoqi_driver/bumper",
messageType: "naoqi_bridge_msgs/msg/Bumper",
once: true,
};
@@ -129,7 +129,7 @@ export function getTouchSensors(
params: Record<string, unknown>,
): Record<string, unknown> {
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 =
sensorType === "hand"
? "naoqi_bridge_msgs/msg/HandTouch"
@@ -153,12 +153,12 @@ export function getSonarRange(
let topic: string;
if (sensor === "left") {
topic = "/sonar/left";
topic = "/naoqi_driver/sonar/left";
} else if (sensor === "right") {
topic = "/sonar/right";
topic = "/naoqi_driver/sonar/right";
} else {
// For "both", we'll default to left and let the wizard interface handle multiple calls
topic = "/sonar/left";
topic = "/naoqi_driver/sonar/left";
}
return {
@@ -177,7 +177,7 @@ export function getRobotInfo(
): Record<string, unknown> {
return {
subscribe: true,
topic: "/info",
topic: "/naoqi_driver/info",
messageType: "naoqi_bridge_msgs/msg/RobotInfo",
once: true,
};

View File

@@ -152,10 +152,10 @@ export const experimentsRouter = createTRPCRouter({
.select({
experimentId: trials.experimentId,
latest: sql`max(GREATEST(
COALESCE(${trials.completedAt}, 'epoch'::timestamptz),
COALESCE(${trials.startedAt}, 'epoch'::timestamptz),
COALESCE(${trials.createdAt}, 'epoch'::timestamptz)
))`.as("latest"),
COALESCE(${trials.completedAt}, 'epoch':: timestamptz),
COALESCE(${trials.startedAt}, 'epoch':: timestamptz),
COALESCE(${trials.createdAt}, 'epoch':: timestamptz)
))`.as("latest"),
})
.from(trials)
.where(inArray(trials.experimentId, experimentIds))
@@ -360,24 +360,24 @@ export const experimentsRouter = createTRPCRouter({
const executionGraphSummary = stepsArray
? {
steps: stepsArray.length,
actions: stepsArray.reduce((total, step) => {
const acts = step.actions;
return (
total +
(Array.isArray(acts)
? acts.reduce(
(aTotal, a) =>
aTotal +
(Array.isArray(a?.actions) ? a.actions.length : 0),
0,
)
: 0)
);
}, 0),
generatedAt: eg?.generatedAt ?? null,
version: eg?.version ?? null,
}
steps: stepsArray.length,
actions: stepsArray.reduce((total, step) => {
const acts = step.actions;
return (
total +
(Array.isArray(acts)
? acts.reduce(
(aTotal, a) =>
aTotal +
(Array.isArray(a?.actions) ? a.actions.length : 0),
0,
)
: 0)
);
}, 0),
generatedAt: eg?.generatedAt ?? null,
version: eg?.version ?? null,
}
: null;
return {
@@ -511,8 +511,7 @@ export const experimentsRouter = createTRPCRouter({
return {
valid: false,
issues: [
`Compilation failed: ${
err instanceof Error ? err.message : "Unknown error"
`Compilation failed: ${err instanceof Error ? err.message : "Unknown error"
}`,
],
pluginDependencies: [],
@@ -541,13 +540,13 @@ export const experimentsRouter = createTRPCRouter({
integrityHash: compiledGraph?.hash ?? null,
compiled: compiledGraph
? {
steps: compiledGraph.steps.length,
actions: compiledGraph.steps.reduce(
(acc, s) => acc + s.actions.length,
0,
),
transportSummary: summarizeTransports(compiledGraph.steps),
}
steps: compiledGraph.steps.length,
actions: compiledGraph.steps.reduce(
(acc, s) => acc + s.actions.length,
0,
),
transportSummary: summarizeTransports(compiledGraph.steps),
}
: null,
};
}),
@@ -570,6 +569,7 @@ export const experimentsRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const { id, createSteps, compileExecution, ...updateData } = input;
const userId = ctx.session.user.id;
console.log("[DEBUG] experiments.update called", { id, visualDesign: updateData.visualDesign, createSteps });
// Get experiment to check study access
const experiment = await ctx.db.query.experiments.findFirst({
@@ -607,7 +607,7 @@ export const experimentsRouter = createTRPCRouter({
if (issues.length) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Visual design validation failed:\n- ${issues.join("\n- ")}`,
message: `Visual design validation failed: \n - ${issues.join("\n- ")}`,
});
}
normalizedSteps = guardedSteps;
@@ -637,11 +637,10 @@ export const experimentsRouter = createTRPCRouter({
} catch (compileErr) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Execution graph compilation failed: ${
compileErr instanceof Error
? compileErr.message
: "Unknown error"
}`,
message: `Execution graph compilation failed: ${compileErr instanceof Error
? compileErr.message
: "Unknown error"
}`,
});
}
}
@@ -735,11 +734,13 @@ export const experimentsRouter = createTRPCRouter({
const updatedExperiment = updatedExperimentResults[0];
if (!updatedExperiment) {
console.error("[DEBUG] Failed to update experiment - no result returned");
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to update experiment",
});
}
console.log("[DEBUG] Experiment updated successfully", { updatedAt: updatedExperiment.updatedAt });
// Log activity
await ctx.db.insert(activityLogs).values({