mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 06:34:44 -05:00
feat: Relocate experiment designer routes under studies, update ROS2 topic paths, and enhance designer hashing and performance.
This commit is contained in:
@@ -64,7 +64,8 @@ export default function NaoTestPage() {
|
||||
const [sensorData, setSensorData] = useState<any>({});
|
||||
const 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();
|
||||
|
||||
@@ -48,7 +48,7 @@ export function DesignerPageClient({
|
||||
},
|
||||
{
|
||||
label: experiment.name,
|
||||
href: `/experiments/${experiment.id}`,
|
||||
href: `/studies/${experiment.study.id}/experiments/${experiment.id}`,
|
||||
},
|
||||
{
|
||||
label: "Designer",
|
||||
@@ -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`,
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user