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 [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();

View File

@@ -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",

View File

@@ -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`,

View File

@@ -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>

View File

@@ -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(

View File

@@ -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>

View File

@@ -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>

View File

@@ -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(

View File

@@ -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>
); );
} }

View File

@@ -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 />

View File

@@ -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; 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"

View File

@@ -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; 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

View File

@@ -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;

View File

@@ -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}
> >

View File

@@ -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,
}; };

View File

@@ -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({