mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-23 19:27:51 -04:00
- Fixed client bundle contamination by moving child_process-dependent code - Created standalone /api/robots/command route for SSH robot commands - Created plugins router to replace robots.plugins for plugin management - Added getStudyPlugins procedure to studies router - Fixed trial.studyId references to trial.experiment.studyId - Updated WizardInterface to use REST API for robot commands
1418 lines
46 KiB
TypeScript
Executable File
1418 lines
46 KiB
TypeScript
Executable File
"use client";
|
|
|
|
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { toast } from "sonner";
|
|
import {
|
|
Play,
|
|
RefreshCw,
|
|
HelpCircle,
|
|
PanelLeftClose,
|
|
PanelLeftOpen,
|
|
PanelRightClose,
|
|
PanelRightOpen,
|
|
Maximize2,
|
|
Minimize2,
|
|
Settings,
|
|
} from "lucide-react";
|
|
|
|
import { cn } from "~/lib/utils";
|
|
import { PageHeader } from "~/components/ui/page-header";
|
|
import { useTour } from "~/components/onboarding/TourProvider";
|
|
import { SettingsModal } from "./SettingsModal";
|
|
|
|
import { Button } from "~/components/ui/button";
|
|
import { api } from "~/trpc/react";
|
|
|
|
import { PanelsContainer } from "./layout/PanelsContainer";
|
|
import {
|
|
DndContext,
|
|
DragOverlay,
|
|
pointerWithin,
|
|
useSensor,
|
|
useSensors,
|
|
MouseSensor,
|
|
TouchSensor,
|
|
KeyboardSensor,
|
|
closestCenter,
|
|
type DragEndEvent,
|
|
type DragStartEvent,
|
|
type DragOverEvent,
|
|
} from "@dnd-kit/core";
|
|
import { BottomStatusBar } from "./layout/BottomStatusBar";
|
|
import { ActionLibraryPanel } from "./panels/ActionLibraryPanel";
|
|
import { InspectorPanel } from "./panels/InspectorPanel";
|
|
import { FlowWorkspace, StepCardPreview } from "./flow/FlowWorkspace";
|
|
import { SortableActionChip } from "./flow/ActionChip";
|
|
import { GripVertical } from "lucide-react";
|
|
|
|
import {
|
|
type ExperimentDesign,
|
|
type ExperimentStep,
|
|
type ExperimentAction,
|
|
} from "~/lib/experiment-designer/types";
|
|
|
|
import { useDesignerStore } from "./state/store";
|
|
import { actionRegistry, useActionRegistry } from "./ActionRegistry";
|
|
import { computeDesignHash } from "./state/hashing";
|
|
import {
|
|
validateExperimentDesign,
|
|
groupIssuesByEntity,
|
|
} from "./state/validators";
|
|
import { convertDatabaseToSteps } from "~/lib/experiment-designer/block-converter";
|
|
|
|
/**
|
|
* DesignerRoot
|
|
*
|
|
* New high-level orchestrator for the Experiment Designer refactor.
|
|
* Replaces the previous monolithic DesignerShell with a composable,
|
|
* panel-based layout (left: action library, center: flow workspace,
|
|
* right: contextual inspector, bottom: status bar).
|
|
*
|
|
* Responsibilities:
|
|
* - Remote experiment fetch + initial design hydration
|
|
* - Store initialization (steps, persisted / validated hash)
|
|
* - Save / validate / export orchestration
|
|
* - Keyboard shortcut wiring
|
|
* - Action / plugin registry initialization
|
|
*
|
|
* Non-Responsibilities:
|
|
* - Raw per-field editing (delegated to sub-panels)
|
|
* - Drag/drop internals (delegated to flow + library components)
|
|
* - Low-level hashing/incremental logic (state/store)
|
|
*
|
|
* Future Enhancements (planned hooks / modules):
|
|
* - Command Palette (action insertion & navigation)
|
|
* - Virtualized step list
|
|
* - Graph view toggle
|
|
* - Drift diff / reconciliation modal
|
|
* - Autosave throttling controls
|
|
*/
|
|
|
|
export interface DesignerRootProps {
|
|
experimentId: string;
|
|
initialDesign?: ExperimentDesign;
|
|
autoCompile?: boolean;
|
|
onPersist?: (design: ExperimentDesign) => void;
|
|
experiment?: {
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
status: string;
|
|
studyId: string;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
study: {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
};
|
|
designStats?: {
|
|
stepCount: number;
|
|
actionCount: number;
|
|
};
|
|
}
|
|
|
|
interface RawExperiment {
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
studyId: string;
|
|
integrityHash?: string | null;
|
|
pluginDependencies?: string[] | null;
|
|
visualDesign?: unknown;
|
|
steps?: unknown[]; // DB steps from relation
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Util: Adapt Existing Stored Visual Design */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
|
|
console.log("[adaptExistingDesign] Entry - exp.steps:", exp.steps);
|
|
|
|
// 1. Prefer database steps (Source of Truth) if valid, to ensure we have the latest
|
|
// plugin provenance data (which might be missing from stale visualDesign snapshots).
|
|
// 1. Prefer database steps (Source of Truth) if valid.
|
|
if (Array.isArray(exp.steps) && exp.steps.length > 0) {
|
|
console.log(
|
|
"[adaptExistingDesign] Has steps array, length:",
|
|
exp.steps.length,
|
|
);
|
|
try {
|
|
// Check if steps are already converted (have trigger property) to avoid double-conversion data loss
|
|
const firstStep = exp.steps[0] as any;
|
|
let dbSteps: ExperimentStep[];
|
|
|
|
if (
|
|
firstStep &&
|
|
typeof firstStep === "object" &&
|
|
"trigger" in firstStep
|
|
) {
|
|
// Already converted by server
|
|
dbSteps = exp.steps as ExperimentStep[];
|
|
} else {
|
|
// Raw DB steps, need conversion
|
|
console.log("[adaptExistingDesign] Taking raw DB conversion path");
|
|
dbSteps = convertDatabaseToSteps(exp.steps);
|
|
|
|
// DEBUG: Check children after conversion
|
|
dbSteps.forEach((step) => {
|
|
step.actions.forEach((action) => {
|
|
if (
|
|
["sequence", "parallel", "loop", "branch"].includes(action.type)
|
|
) {
|
|
console.log(
|
|
`[adaptExistingDesign] Post-conversion ${action.type} (${action.name}) children:`,
|
|
action.children,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
return {
|
|
id: exp.id,
|
|
name: exp.name,
|
|
description: exp.description ?? "",
|
|
steps: dbSteps,
|
|
version: 1, // Reset version on re-hydration
|
|
lastSaved: new Date(),
|
|
};
|
|
} catch (err) {
|
|
console.warn(
|
|
"[DesignerRoot] Failed to convert/hydrate steps, falling back to visualDesign:",
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
|
|
// 2. Fallback to visualDesign blob if DB steps unavailable or conversion failed
|
|
if (
|
|
!exp.visualDesign ||
|
|
typeof exp.visualDesign !== "object" ||
|
|
!("steps" in (exp.visualDesign as Record<string, unknown>))
|
|
) {
|
|
return undefined;
|
|
}
|
|
const vd = exp.visualDesign as {
|
|
steps?: ExperimentStep[];
|
|
version?: number;
|
|
lastSaved?: string;
|
|
};
|
|
if (!Array.isArray(vd.steps)) return undefined;
|
|
|
|
return {
|
|
id: exp.id,
|
|
name: exp.name,
|
|
description: exp.description ?? "",
|
|
steps: vd.steps,
|
|
version: vd.version ?? 1,
|
|
lastSaved:
|
|
vd.lastSaved && typeof vd.lastSaved === "string"
|
|
? new Date(vd.lastSaved)
|
|
: new Date(),
|
|
};
|
|
}
|
|
|
|
function buildEmptyDesign(
|
|
experimentId: string,
|
|
name?: string,
|
|
description?: string | null,
|
|
): ExperimentDesign {
|
|
return {
|
|
id: experimentId,
|
|
name: name?.trim().length ? name : "Untitled Experiment",
|
|
description: description ?? "",
|
|
version: 1,
|
|
steps: [],
|
|
lastSaved: new Date(),
|
|
};
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Component */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
export function DesignerRoot({
|
|
experimentId,
|
|
initialDesign,
|
|
autoCompile = true,
|
|
onPersist,
|
|
experiment: experimentMetadata,
|
|
designStats,
|
|
}: DesignerRootProps) {
|
|
// Subscribe to registry updates to ensure re-renders when actions load
|
|
useActionRegistry();
|
|
|
|
const { startTour } = useTour();
|
|
|
|
/* ----------------------------- Remote Experiment ------------------------- */
|
|
const {
|
|
data: experiment,
|
|
isLoading: loadingExperiment,
|
|
refetch: refetchExperiment,
|
|
} = api.experiments.get.useQuery(
|
|
{ id: experimentId },
|
|
{
|
|
// Debug Mode: Disable all caching to ensure fresh data from DB
|
|
refetchOnMount: true,
|
|
refetchOnWindowFocus: true,
|
|
staleTime: 0,
|
|
gcTime: 0, // Garbage collect immediately
|
|
},
|
|
);
|
|
|
|
const updateExperiment = api.experiments.update.useMutation({
|
|
onError: (err) => {
|
|
toast.error(`Save failed: ${err.message}`);
|
|
},
|
|
});
|
|
|
|
const { data: studyPluginsRaw } = api.studies.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,
|
|
actionDefinitions: sp.plugin.actionDefinitions as any[],
|
|
metadata: sp.plugin.metadata as Record<string, any>,
|
|
})),
|
|
[studyPluginsRaw],
|
|
);
|
|
|
|
/* ------------------------------ Store Access ----------------------------- */
|
|
const steps = useDesignerStore((s) => s.steps);
|
|
const setSteps = useDesignerStore((s) => s.setSteps);
|
|
const recomputeHash = useDesignerStore((s) => s.recomputeHash);
|
|
const lastPersistedHash = useDesignerStore((s) => s.lastPersistedHash);
|
|
const currentDesignHash = useDesignerStore((s) => s.currentDesignHash);
|
|
|
|
const setPersistedHash = useDesignerStore((s) => s.setPersistedHash);
|
|
const setValidatedHash = useDesignerStore((s) => s.setValidatedHash);
|
|
const upsertStep = useDesignerStore((s) => s.upsertStep);
|
|
const upsertAction = useDesignerStore((s) => s.upsertAction);
|
|
const selectStep = useDesignerStore((s) => s.selectStep);
|
|
const selectAction = useDesignerStore((s) => s.selectAction);
|
|
const reorderStep = useDesignerStore((s) => s.reorderStep);
|
|
const setValidationIssues = useDesignerStore((s) => s.setValidationIssues);
|
|
const clearAllValidationIssues = useDesignerStore(
|
|
(s) => s.clearAllValidationIssues,
|
|
);
|
|
const selectedStepId = useDesignerStore((s) => s.selectedStepId);
|
|
const selectedActionId = useDesignerStore((s) => s.selectedActionId);
|
|
|
|
const libraryRootRef = useRef<HTMLDivElement | null>(null);
|
|
const toggleLibraryScrollLock = useCallback((lock: boolean) => {
|
|
const viewport = libraryRootRef.current?.querySelector(
|
|
'[data-slot="scroll-area-viewport"]',
|
|
) as HTMLElement | null;
|
|
if (viewport) {
|
|
if (lock) {
|
|
viewport.style.overflowY = "hidden";
|
|
viewport.style.overscrollBehavior = "contain";
|
|
} else {
|
|
viewport.style.overflowY = "";
|
|
viewport.style.overscrollBehavior = "";
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
/* ------------------------------- Local Meta ------------------------------ */
|
|
const [designMeta, setDesignMeta] = useState<{
|
|
name: string;
|
|
description: string;
|
|
version: number;
|
|
}>(() => {
|
|
// Determine initial local meta (prefer server design)
|
|
const existing =
|
|
initialDesign ??
|
|
(experiment
|
|
? adaptExistingDesign(experiment as RawExperiment)
|
|
: undefined);
|
|
const base =
|
|
existing ??
|
|
buildEmptyDesign(
|
|
experimentId,
|
|
experiment?.name,
|
|
experiment?.description ?? "",
|
|
);
|
|
return {
|
|
name: base.name,
|
|
description: base.description,
|
|
version: base.version,
|
|
};
|
|
});
|
|
|
|
const [initialized, setInitialized] = useState(false);
|
|
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<
|
|
"properties" | "issues" | "dependencies"
|
|
>("properties");
|
|
|
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
|
const [rightCollapsed, setRightCollapsed] = useState(false);
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
|
|
// Responsive initialization: Collapse left sidebar on smaller screens (<1280px)
|
|
useEffect(() => {
|
|
const checkWidth = () => {
|
|
if (window.innerWidth < 1280) {
|
|
setLeftCollapsed(true);
|
|
}
|
|
};
|
|
// Check once on mount
|
|
checkWidth();
|
|
// Optional: Add resize listener if we want live responsiveness
|
|
// window.addEventListener('resize', checkWidth);
|
|
// return () => window.removeEventListener('resize', checkWidth);
|
|
}, []);
|
|
/**
|
|
* Active action being dragged from the Action Library (for DragOverlay rendering).
|
|
* Captures a lightweight subset for visual feedback.
|
|
*/
|
|
const [dragOverlayAction, setDragOverlayAction] = useState<{
|
|
id: string;
|
|
name: string;
|
|
category: string;
|
|
description?: string;
|
|
} | null>(null);
|
|
|
|
const [activeSortableItem, setActiveSortableItem] = useState<{
|
|
type: "step" | "action";
|
|
data: any;
|
|
} | null>(null);
|
|
|
|
/* ----------------------------- Initialization ---------------------------- */
|
|
useEffect(() => {
|
|
console.log("[DesignerRoot] useEffect triggered", {
|
|
initialized,
|
|
loadingExperiment,
|
|
hasExperiment: !!experiment,
|
|
hasInitialDesign: !!initialDesign,
|
|
});
|
|
|
|
if (initialized) return;
|
|
if (loadingExperiment && !initialDesign) return;
|
|
|
|
console.log("[DesignerRoot] Proceeding with initialization");
|
|
|
|
const adapted =
|
|
initialDesign ??
|
|
(experiment
|
|
? adaptExistingDesign(experiment as RawExperiment)
|
|
: undefined);
|
|
const resolved =
|
|
adapted ??
|
|
buildEmptyDesign(
|
|
experimentId,
|
|
experiment?.name,
|
|
experiment?.description ?? "",
|
|
);
|
|
setDesignMeta({
|
|
name: resolved.name,
|
|
description: resolved.description,
|
|
version: resolved.version,
|
|
});
|
|
setSteps(resolved.steps);
|
|
if ((experiment as RawExperiment | undefined)?.integrityHash) {
|
|
const ih = (experiment as RawExperiment).integrityHash!;
|
|
setPersistedHash(ih);
|
|
setValidatedHash(ih);
|
|
}
|
|
setInitialized(true);
|
|
// 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,
|
|
experiment,
|
|
initialDesign,
|
|
experimentId,
|
|
setSteps,
|
|
setPersistedHash,
|
|
setValidatedHash,
|
|
recomputeHash,
|
|
]);
|
|
|
|
/* ---------------------------- Action Registry ---------------------------- */
|
|
// Load core actions once
|
|
useEffect(() => {
|
|
actionRegistry
|
|
.loadCoreActions()
|
|
.catch((err) => console.error("Core action load failed:", err));
|
|
}, []);
|
|
|
|
// Load plugin actions only after we have the flattened, processed plugin list
|
|
useEffect(() => {
|
|
if (!experiment?.studyId) return;
|
|
if (!studyPlugins) return;
|
|
|
|
// Pass the flattened plugins which match the structure ActionRegistry expects
|
|
actionRegistry.loadPluginActions(experiment.studyId, studyPlugins);
|
|
}, [experiment?.studyId, studyPlugins]);
|
|
|
|
/* ------------------------- 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);
|
|
}, 150);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [initialized, isReady, studyPlugins]);
|
|
|
|
/* ----------------------- 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');
|
|
|
|
const timeoutId = setTimeout(async () => {
|
|
// console.log('[DesignerRoot] Executing debounced hash recomputation');
|
|
const result = await recomputeHash();
|
|
if (result) {
|
|
// console.log('[DesignerRoot] Hash recomputed:', result.designHash.slice(0, 16));
|
|
}
|
|
}, 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,
|
|
// });
|
|
}, [currentDesignHash, lastPersistedHash, hasUnsavedChanges]);
|
|
|
|
/* ------------------------------- Step Ops -------------------------------- */
|
|
const createNewStep = useCallback(() => {
|
|
const newStep: ExperimentStep = {
|
|
id: `step-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
|
name: `Step ${steps.length + 1}`,
|
|
description: "",
|
|
type: "sequential",
|
|
order: steps.length,
|
|
trigger: {
|
|
type: "trial_start",
|
|
conditions: {},
|
|
},
|
|
actions: [],
|
|
expanded: true,
|
|
};
|
|
upsertStep(newStep);
|
|
selectStep(newStep.id);
|
|
setInspectorTab("properties");
|
|
toast.success(`Created ${newStep.name}`);
|
|
}, [steps.length, upsertStep, selectStep]);
|
|
|
|
/* ------------------------------- Validation ------------------------------ */
|
|
const validateDesign = useCallback(async () => {
|
|
if (!initialized) return;
|
|
setIsValidating(true);
|
|
try {
|
|
const currentSteps = [...steps];
|
|
// Ensure core actions are loaded before validating
|
|
await actionRegistry.loadCoreActions();
|
|
|
|
const result = validateExperimentDesign(currentSteps, {
|
|
steps: currentSteps,
|
|
actionDefinitions: actionRegistry.getAllActions(),
|
|
});
|
|
// Debug: log validation results for troubleshooting
|
|
|
|
// Debug: Improved structured logging for validation results
|
|
console.group("🧪 Experiment Validation Results");
|
|
if (result.valid) {
|
|
console.log(
|
|
`%c✓ VALID (0 errors, ${result.warningCount} warnings, ${result.infoCount} hints)`,
|
|
"color: green; font-weight: bold; font-size: 12px;",
|
|
);
|
|
} else {
|
|
console.log(
|
|
`%c✗ INVALID (${result.errorCount} errors, ${result.warningCount} warnings)`,
|
|
"color: red; font-weight: bold; font-size: 12px;",
|
|
);
|
|
}
|
|
|
|
if (result.issues.length > 0) {
|
|
console.table(
|
|
result.issues.map((i) => ({
|
|
Severity: i.severity.toUpperCase(),
|
|
Category: i.category,
|
|
Message: i.message,
|
|
Suggest: i.suggestion,
|
|
Location: i.actionId
|
|
? `Action ${i.actionId}`
|
|
: i.stepId
|
|
? `Step ${i.stepId}`
|
|
: "Global",
|
|
})),
|
|
);
|
|
} else {
|
|
console.log("No issues found. Design is perfectly compliant.");
|
|
}
|
|
console.groupEnd();
|
|
// Persist issues to store for inspector rendering
|
|
const grouped = groupIssuesByEntity(result.issues);
|
|
clearAllValidationIssues();
|
|
for (const [entityId, arr] of Object.entries(grouped)) {
|
|
setValidationIssues(
|
|
entityId,
|
|
arr.map((i) => ({
|
|
entityId,
|
|
severity: i.severity,
|
|
message: i.message,
|
|
code: undefined,
|
|
})),
|
|
);
|
|
}
|
|
const hash = await computeDesignHash(currentSteps);
|
|
setValidatedHash(hash);
|
|
if (result.valid) {
|
|
toast.success(`Validated • ${hash.slice(0, 10)}… • 0 errors`);
|
|
} else {
|
|
toast.warning(
|
|
`Validated with ${result.errorCount} errors, ${result.warningCount} warnings`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
toast.error(
|
|
`Validation error: ${
|
|
err instanceof Error ? err.message : "Unknown error"
|
|
}`,
|
|
);
|
|
} finally {
|
|
setIsValidating(false);
|
|
}
|
|
}, [
|
|
initialized,
|
|
steps,
|
|
setValidatedHash,
|
|
setValidationIssues,
|
|
clearAllValidationIssues,
|
|
]);
|
|
|
|
// Trigger initial validation when ready (plugins loaded) to ensure no stale errors
|
|
// Trigger initial validation when ready (plugins loaded) to ensure no stale errors
|
|
// DISABLED: User prefers manual validation to avoid noise on improved sequential architecture
|
|
// useEffect(() => {
|
|
// if (isReady) {
|
|
// void validateDesign();
|
|
// }
|
|
// }, [isReady, validateDesign]);
|
|
|
|
/* --------------------------------- 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 = {
|
|
steps,
|
|
version: designMeta.version,
|
|
lastSaved: new Date().toISOString(),
|
|
};
|
|
|
|
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,
|
|
});
|
|
|
|
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");
|
|
|
|
// Auto-validate after save to clear "Modified" (drift) status
|
|
void validateDesign();
|
|
|
|
console.log("[DesignerRoot] 💾 SAVE complete");
|
|
|
|
onPersist?.({
|
|
id: experimentId,
|
|
name: designMeta.name,
|
|
description: designMeta.description,
|
|
steps,
|
|
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,
|
|
recomputeHash,
|
|
currentDesignHash,
|
|
setPersistedHash,
|
|
refetchExperiment,
|
|
onPersist,
|
|
autoCompile,
|
|
]);
|
|
|
|
/* -------------------------------- Export --------------------------------- */
|
|
const handleExport = useCallback(async () => {
|
|
setIsExporting(true);
|
|
try {
|
|
const designHash = currentDesignHash ?? (await computeDesignHash(steps));
|
|
const bundle = {
|
|
format: "hristudio.design.v1",
|
|
exportedAt: new Date().toISOString(),
|
|
experiment: {
|
|
id: experimentId,
|
|
name: designMeta.name,
|
|
version: designMeta.version,
|
|
integrityHash: designHash,
|
|
steps,
|
|
pluginDependencies:
|
|
(experiment as RawExperiment | undefined)?.pluginDependencies
|
|
?.slice()
|
|
.sort() ?? [],
|
|
},
|
|
compiled: null,
|
|
};
|
|
const blob = new Blob([JSON.stringify(bundle, null, 2)], {
|
|
type: "application/json",
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `${designMeta.name
|
|
.replace(/[^a-z0-9-_]+/gi, "_")
|
|
.toLowerCase()}_design.json`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
toast.success("Exported design bundle");
|
|
} catch (err) {
|
|
toast.error(
|
|
`Export failed: ${
|
|
err instanceof Error ? err.message : "Unknown error"
|
|
}`,
|
|
);
|
|
} finally {
|
|
setIsExporting(false);
|
|
}
|
|
}, [currentDesignHash, steps, experimentId, designMeta, experiment]);
|
|
|
|
/* ---------------------------- Incremental Hash --------------------------- */
|
|
// Serialize steps for stable comparison
|
|
const stepsHash = useMemo(() => JSON.stringify(steps), [steps]);
|
|
|
|
// Intentionally removed redundant recomputeHash useEffect that was causing excessive refreshes
|
|
// The debounced useEffect (lines 352-372) handles this correctly.
|
|
|
|
useEffect(() => {
|
|
if (selectedStepId || selectedActionId) {
|
|
setInspectorTab("properties");
|
|
}
|
|
}, [selectedStepId, selectedActionId]);
|
|
|
|
// Auto-open properties tab when a step or action becomes selected
|
|
useEffect(() => {
|
|
if (selectedStepId || selectedActionId) {
|
|
setInspectorTab("properties");
|
|
}
|
|
}, [selectedStepId, selectedActionId]);
|
|
|
|
/* -------------------------- Keyboard Shortcuts --------------------------- */
|
|
const keyHandler = useCallback(
|
|
(e: globalThis.KeyboardEvent) => {
|
|
if (
|
|
(e.metaKey || e.ctrlKey) &&
|
|
e.key.toLowerCase() === "s" &&
|
|
hasUnsavedChanges
|
|
) {
|
|
e.preventDefault();
|
|
void persist();
|
|
}
|
|
// 'v' (validate), 'e' (export), 'Shift+N' (new step) shortcuts removed to prevent accidents
|
|
},
|
|
[hasUnsavedChanges, persist],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const listener = (ev: globalThis.KeyboardEvent) => keyHandler(ev);
|
|
window.addEventListener("keydown", listener, { passive: true });
|
|
return () => {
|
|
window.removeEventListener("keydown", listener);
|
|
};
|
|
}, [keyHandler]);
|
|
|
|
/* ------------------------------ Header Badges ---------------------------- */
|
|
|
|
const sensors = useSensors(
|
|
useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
|
|
useSensor(TouchSensor, {
|
|
activationConstraint: { delay: 150, tolerance: 5 },
|
|
}),
|
|
useSensor(KeyboardSensor),
|
|
);
|
|
|
|
/* ----------------------------- Drag Handlers ----------------------------- */
|
|
/* ----------------------------- Drag Handlers ----------------------------- */
|
|
const handleDragStart = useCallback(
|
|
(event: DragStartEvent) => {
|
|
const { active } = event;
|
|
const activeId = active.id.toString();
|
|
const activeData = active.data.current;
|
|
|
|
console.log("[DesignerRoot] DragStart", { activeId, activeData });
|
|
|
|
if (activeId.startsWith("action-") && activeData?.action) {
|
|
const a = activeData.action as {
|
|
id: string;
|
|
name: string;
|
|
category: string;
|
|
description?: string;
|
|
};
|
|
toggleLibraryScrollLock(true);
|
|
setDragOverlayAction({
|
|
id: a.id,
|
|
// prefer definition name; fallback to id
|
|
name: a.name || a.id,
|
|
category: a.category,
|
|
description: a.description,
|
|
});
|
|
} else if (activeId.startsWith("s-step-")) {
|
|
console.log("[DesignerRoot] Setting active sortable STEP", activeData);
|
|
setActiveSortableItem({
|
|
type: "step",
|
|
data: activeData,
|
|
});
|
|
} else if (activeId.startsWith("s-act-")) {
|
|
console.log(
|
|
"[DesignerRoot] Setting active sortable ACTION",
|
|
activeData,
|
|
);
|
|
setActiveSortableItem({
|
|
type: "action",
|
|
data: activeData,
|
|
});
|
|
}
|
|
},
|
|
[toggleLibraryScrollLock],
|
|
);
|
|
|
|
const handleDragOver = useCallback((event: DragOverEvent) => {
|
|
const { active, over } = event;
|
|
const store = useDesignerStore.getState();
|
|
const activeId = active.id.toString();
|
|
|
|
if (!over) {
|
|
if (store.insertionProjection) {
|
|
store.setInsertionProjection(null);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 3. Library -> Flow Projection (Action)
|
|
if (!activeId.startsWith("action-")) {
|
|
if (store.insertionProjection) {
|
|
store.setInsertionProjection(null);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const overId = over.id.toString();
|
|
const activeDef = active.data.current?.action;
|
|
|
|
if (!activeDef) return;
|
|
|
|
let stepId: string | null = null;
|
|
let parentId: string | null = null;
|
|
let index = 0;
|
|
|
|
// Detect target based on over id
|
|
if (overId.startsWith("s-act-")) {
|
|
const data = over.data.current;
|
|
if (data && data.stepId) {
|
|
stepId = data.stepId;
|
|
parentId = data.parentId ?? null; // Use parentId from the action we are hovering over
|
|
// Use sortable index (insertion point provided by dnd-kit sortable strategy)
|
|
index = data.sortable?.index ?? 0;
|
|
}
|
|
} else if (overId.startsWith("container-")) {
|
|
// Dropping into a container (e.g. Loop)
|
|
const data = over.data.current;
|
|
if (data && data.stepId) {
|
|
stepId = data.stepId;
|
|
parentId = data.parentId ?? overId.slice("container-".length);
|
|
// If dropping into container, appending is a safe default if specific index logic is missing
|
|
// But actually we can find length if we want. For now, 0 or append logic?
|
|
// If container is empty, index 0 is correct.
|
|
// If not empty, we are hitting the container *background*, so append?
|
|
// The projection logic will insert at 'index'. If index is past length, it appends.
|
|
// Let's set a large index to ensure append, or look up length.
|
|
// Lookup requires finding the action in store. Expensive?
|
|
// Let's assume index 0 for now (prepend) or implement lookup.
|
|
// Better: lookup action -> children length.
|
|
const actionId = parentId;
|
|
const step = store.steps.find((s) => s.id === stepId);
|
|
// Find action recursive? Store has `findActionById` helper but it is not exported/accessible easily here?
|
|
// Actually, `store.steps` is available.
|
|
// We can implement a quick BFS/DFS or just assume 0.
|
|
// If dragging over the container *background* (empty space), append is usually expected.
|
|
// Let's try 9999?
|
|
index = 9999;
|
|
}
|
|
} else if (overId.startsWith("s-step-") || overId.startsWith("step-")) {
|
|
// Container drop (Step)
|
|
stepId = overId.startsWith("s-step-")
|
|
? overId.slice("s-step-".length)
|
|
: overId.slice("step-".length);
|
|
const step = store.steps.find((s) => s.id === stepId);
|
|
index = step ? step.actions.length : 0;
|
|
} else if (overId === "projection-placeholder") {
|
|
// Hovering over our own projection placeholder -> keep current state
|
|
return;
|
|
}
|
|
|
|
if (stepId) {
|
|
const current = store.insertionProjection;
|
|
// Optimization: avoid redundant updates if projection matches
|
|
if (
|
|
current &&
|
|
current.stepId === stepId &&
|
|
current.parentId === parentId &&
|
|
current.index === index
|
|
) {
|
|
return;
|
|
}
|
|
|
|
store.setInsertionProjection({
|
|
stepId,
|
|
parentId,
|
|
index,
|
|
action: {
|
|
id: "projection-placeholder",
|
|
type: activeDef.type,
|
|
name: activeDef.name,
|
|
category: activeDef.category,
|
|
description: "Drop here",
|
|
source: activeDef.source || { kind: "library" },
|
|
parameters: {},
|
|
execution: activeDef.execution,
|
|
} as any,
|
|
});
|
|
} else {
|
|
if (store.insertionProjection) store.setInsertionProjection(null);
|
|
}
|
|
}, []);
|
|
|
|
const handleDragEnd = useCallback(
|
|
async (event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
|
|
// Clear overlay immediately
|
|
toggleLibraryScrollLock(false);
|
|
setDragOverlayAction(null);
|
|
setActiveSortableItem(null);
|
|
|
|
// Capture and clear projection
|
|
const store = useDesignerStore.getState();
|
|
const projection = store.insertionProjection;
|
|
store.setInsertionProjection(null);
|
|
|
|
if (!over) {
|
|
return;
|
|
}
|
|
|
|
const activeId = active.id.toString();
|
|
|
|
// Handle Step Reordering (Active is a sortable step)
|
|
if (activeId.startsWith("s-step-")) {
|
|
const overId = over.id.toString();
|
|
// Allow reordering over both sortable steps (s-step-) and drop zones (step-)
|
|
if (!overId.startsWith("s-step-") && !overId.startsWith("step-"))
|
|
return;
|
|
|
|
// Strip prefixes to get raw IDs
|
|
const rawActiveId = activeId.replace(/^s-step-/, "");
|
|
const rawOverId = overId.replace(/^s-step-/, "").replace(/^step-/, "");
|
|
|
|
console.log("[DesignerRoot] DragEnd - Step Sort", {
|
|
activeId,
|
|
overId,
|
|
rawActiveId,
|
|
rawOverId,
|
|
});
|
|
|
|
const oldIndex = steps.findIndex((s) => s.id === rawActiveId);
|
|
const newIndex = steps.findIndex((s) => s.id === rawOverId);
|
|
|
|
console.log("[DesignerRoot] Indices", { oldIndex, newIndex });
|
|
|
|
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
|
console.log("[DesignerRoot] Reordering...");
|
|
reorderStep(oldIndex, newIndex);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 1. Determine Target (Step, Parent, Index)
|
|
let stepId: string | null = null;
|
|
let parentId: string | null = null;
|
|
let index: number | undefined = undefined;
|
|
|
|
if (projection) {
|
|
stepId = projection.stepId;
|
|
parentId = projection.parentId;
|
|
index = projection.index;
|
|
} else {
|
|
// Fallback: resolution from overId (if projection failed or raced)
|
|
const overId = over.id.toString();
|
|
if (overId.startsWith("step-")) {
|
|
stepId = overId.slice("step-".length);
|
|
} else if (overId.startsWith("s-step-")) {
|
|
stepId = overId.slice("s-step-".length);
|
|
} else if (overId.startsWith("s-act-")) {
|
|
// This might fail if s-act-projection, but that should have covered by projection check above
|
|
const actionId = overId.slice("s-act-".length);
|
|
const parent = steps.find((s) =>
|
|
s.actions.some((a) => a.id === actionId),
|
|
);
|
|
stepId = parent?.id ?? null;
|
|
}
|
|
}
|
|
|
|
if (!stepId) return;
|
|
const targetStep = steps.find((s) => s.id === stepId);
|
|
if (!targetStep) return;
|
|
|
|
// 2. Instantiate Action
|
|
if (
|
|
active.id.toString().startsWith("action-") &&
|
|
active.data.current?.action
|
|
) {
|
|
const actionDef = active.data.current.action as {
|
|
id: string; // type
|
|
type: string;
|
|
name: string;
|
|
category: string;
|
|
description?: string;
|
|
source: { kind: string; pluginId?: string; pluginVersion?: string };
|
|
execution?: { transport: string; retryable?: boolean };
|
|
parameters: Array<{ id: string; name: string }>;
|
|
};
|
|
|
|
const fullDef = actionRegistry.getAction(actionDef.type);
|
|
const defaultParams: Record<string, unknown> = {};
|
|
if (fullDef?.parameters) {
|
|
for (const param of fullDef.parameters) {
|
|
if (param.value !== undefined) {
|
|
defaultParams[param.id] = param.value;
|
|
}
|
|
}
|
|
}
|
|
|
|
const execution: ExperimentAction["execution"] =
|
|
actionDef.execution &&
|
|
(actionDef.execution.transport === "internal" ||
|
|
actionDef.execution.transport === "rest" ||
|
|
actionDef.execution.transport === "ros2")
|
|
? {
|
|
transport: actionDef.execution.transport,
|
|
retryable: actionDef.execution.retryable ?? false,
|
|
}
|
|
: undefined;
|
|
|
|
const newId = `action-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`;
|
|
const newAction: ExperimentAction = {
|
|
id: newId,
|
|
type: actionDef.type, // this is the 'type' key
|
|
name: actionDef.name,
|
|
category: actionDef.category as any,
|
|
description: "",
|
|
parameters: defaultParams,
|
|
source: actionDef.source
|
|
? {
|
|
kind: actionDef.source.kind as any,
|
|
pluginId: actionDef.source.pluginId,
|
|
pluginVersion: actionDef.source.pluginVersion,
|
|
baseActionId: actionDef.id,
|
|
}
|
|
: { kind: "core" },
|
|
execution,
|
|
children: [],
|
|
};
|
|
|
|
// 3. Commit
|
|
upsertAction(stepId, newAction, parentId, index);
|
|
|
|
// Auto-select
|
|
selectAction(stepId, newAction.id);
|
|
|
|
void recomputeHash();
|
|
}
|
|
},
|
|
[
|
|
steps,
|
|
upsertAction,
|
|
selectAction,
|
|
recomputeHash,
|
|
toggleLibraryScrollLock,
|
|
reorderStep,
|
|
],
|
|
);
|
|
// validation status badges removed (unused)
|
|
/* ------------------------------- Panels ---------------------------------- */
|
|
const leftPanel = useMemo(
|
|
() => (
|
|
<div
|
|
id="tour-designer-blocks"
|
|
ref={libraryRootRef}
|
|
data-library-root
|
|
className="h-full"
|
|
>
|
|
<ActionLibraryPanel />
|
|
</div>
|
|
),
|
|
[],
|
|
);
|
|
|
|
const centerPanel = useMemo(
|
|
() => (
|
|
<div id="tour-designer-canvas" className="h-full">
|
|
<FlowWorkspace />
|
|
</div>
|
|
),
|
|
[],
|
|
);
|
|
|
|
const rightPanel = useMemo(
|
|
() => (
|
|
<div id="tour-designer-properties" className="h-full">
|
|
<InspectorPanel
|
|
activeTab={inspectorTab}
|
|
onTabChange={setInspectorTab}
|
|
studyPlugins={studyPlugins}
|
|
onClearAll={clearAllValidationIssues}
|
|
/>
|
|
</div>
|
|
),
|
|
[inspectorTab, studyPlugins, clearAllValidationIssues],
|
|
);
|
|
|
|
/* ------------------------------- Render ---------------------------------- */
|
|
if (loadingExperiment && !initialized) {
|
|
return (
|
|
<div className="text-muted-foreground py-24 text-center text-sm">
|
|
Loading experiment design…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const actions = (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{experimentMetadata && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setSettingsOpen(true)}
|
|
title="Experiment Settings"
|
|
>
|
|
<Settings className="h-5 w-5" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => startTour("designer")}
|
|
title="Start Tour"
|
|
>
|
|
<HelpCircle className="h-5 w-5" />
|
|
</Button>
|
|
<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="bg-background relative flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden">
|
|
{/* Subtle Background Gradients */}
|
|
<div className="bg-primary/10 absolute top-0 left-1/2 -z-10 h-[400px] w-[800px] -translate-x-1/2 rounded-full opacity-20 blur-3xl dark:opacity-10" />
|
|
<div className="absolute right-0 bottom-0 -z-10 h-[250px] w-[250px] rounded-full bg-violet-500/5 blur-3xl" />
|
|
<PageHeader
|
|
title={designMeta.name}
|
|
description={designMeta.description || "No description"}
|
|
icon={Play}
|
|
actions={actions}
|
|
className="flex-none pb-4"
|
|
/>
|
|
|
|
{/* Main Grid Container - 2-4-2 Split */}
|
|
{/* Main Grid Container - 2-4-2 Split */}
|
|
<div className="min-h-0 w-full flex-1 overflow-hidden px-2">
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragStart={handleDragStart}
|
|
onDragOver={handleDragOver}
|
|
onDragEnd={handleDragEnd}
|
|
onDragCancel={() => toggleLibraryScrollLock(false)}
|
|
>
|
|
<div className="grid h-full w-full grid-cols-8 gap-4 transition-all duration-300 ease-in-out">
|
|
{/* Left Panel (Library) */}
|
|
{!leftCollapsed && (
|
|
<div
|
|
className={cn(
|
|
"bg-background flex flex-col overflow-hidden rounded-lg border shadow-sm",
|
|
rightCollapsed ? "col-span-3" : "col-span-2",
|
|
)}
|
|
>
|
|
<div className="bg-muted/30 flex items-center justify-between border-b px-3 py-2">
|
|
<span className="text-sm font-medium">Action Library</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6"
|
|
onClick={() => setLeftCollapsed(true)}
|
|
>
|
|
<PanelLeftClose className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<div className="bg-muted/10 min-h-0 flex-1 overflow-hidden">
|
|
{leftPanel}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Center Panel (Workspace) */}
|
|
<div
|
|
className={cn(
|
|
"bg-background flex flex-col overflow-hidden rounded-lg border shadow-sm",
|
|
leftCollapsed && rightCollapsed
|
|
? "col-span-8"
|
|
: leftCollapsed
|
|
? "col-span-6"
|
|
: rightCollapsed
|
|
? "col-span-5"
|
|
: "col-span-4",
|
|
)}
|
|
>
|
|
<div className="bg-muted/30 flex items-center justify-between border-b px-3 py-2">
|
|
{leftCollapsed && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="mr-2 h-6 w-6"
|
|
onClick={() => setLeftCollapsed(false)}
|
|
title="Open Library"
|
|
>
|
|
<PanelLeftOpen className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
<span className="text-sm font-medium">Flow Workspace</span>
|
|
{rightCollapsed && (
|
|
<div className="flex items-center">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => startTour("designer")}
|
|
>
|
|
<HelpCircle className="h-4 w-4" />
|
|
</Button>
|
|
{rightCollapsed && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="ml-2 h-6 w-6"
|
|
onClick={() => setRightCollapsed(false)}
|
|
title="Open Inspector"
|
|
>
|
|
<PanelRightOpen className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="relative min-h-0 flex-1 overflow-hidden">
|
|
{centerPanel}
|
|
</div>
|
|
<div className="border-t">
|
|
<BottomStatusBar
|
|
onSave={() => persist()}
|
|
onValidate={() => validateDesign()}
|
|
onExport={() => handleExport()}
|
|
onRecalculateHash={() => recomputeHash()}
|
|
lastSavedAt={lastSavedAt}
|
|
saving={isSaving}
|
|
validating={isValidating}
|
|
exporting={isExporting}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Panel (Inspector) */}
|
|
{!rightCollapsed && (
|
|
<div
|
|
className={cn(
|
|
"bg-background flex flex-col overflow-hidden rounded-lg border shadow-sm",
|
|
leftCollapsed ? "col-span-2" : "col-span-2",
|
|
)}
|
|
>
|
|
<div className="bg-muted/30 flex items-center justify-between border-b px-3 py-2">
|
|
<span className="text-sm font-medium">Inspector</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6"
|
|
onClick={() => setRightCollapsed(true)}
|
|
>
|
|
<PanelRightClose className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<div className="bg-muted/10 min-h-0 flex-1 overflow-hidden">
|
|
{rightPanel}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DragOverlay dropAnimation={null}>
|
|
{dragOverlayAction ? (
|
|
// Library Item Drag
|
|
<div className="bg-background pointer-events-none flex items-center gap-2 rounded border px-3 py-2 text-xs font-medium shadow-lg ring-2 ring-blue-500/20 select-none">
|
|
<div
|
|
className={cn(
|
|
"flex h-4 w-4 items-center justify-center rounded text-white",
|
|
dragOverlayAction.category === "robot" && "bg-emerald-600",
|
|
dragOverlayAction.category === "control" && "bg-amber-500",
|
|
dragOverlayAction.category === "observation" &&
|
|
"bg-purple-600",
|
|
)}
|
|
/>
|
|
{dragOverlayAction.name}
|
|
</div>
|
|
) : activeSortableItem?.type === "action" ? (
|
|
// Existing Action Sort
|
|
<div className="pointer-events-none w-[300px] opacity-90">
|
|
<SortableActionChip
|
|
stepId={activeSortableItem.data.stepId}
|
|
action={activeSortableItem.data.action}
|
|
parentId={activeSortableItem.data.parentId}
|
|
selectedActionId={selectedActionId}
|
|
onSelectAction={() => {}}
|
|
onDeleteAction={() => {}}
|
|
dragHandle={true}
|
|
/>
|
|
</div>
|
|
) : activeSortableItem?.type === "step" ? (
|
|
// Existing Step Sort
|
|
<div className="pointer-events-none w-[400px] opacity-90">
|
|
<StepCardPreview
|
|
step={activeSortableItem.data.step}
|
|
dragHandle
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
</div>
|
|
|
|
{/* Settings Modal */}
|
|
{experimentMetadata && (
|
|
<SettingsModal
|
|
open={settingsOpen}
|
|
onOpenChange={setSettingsOpen}
|
|
experiment={experimentMetadata}
|
|
designStats={designStats}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default DesignerRoot;
|