diff --git a/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx b/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx index c2a2311..afc91be 100644 --- a/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx +++ b/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx @@ -92,6 +92,8 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial setIsAddNodePanelOpen, handleNodeSelect, saveWorkflow, + workflowConfigurations, + saveWorkflowConfigurations, onConnect, onEdgesChange, onNodesChange, @@ -312,6 +314,17 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial } }, [saveWorkflow, isViewingHistoricalVersion, fetchVersions]); + const renameWorkflow = useCallback(async (newName: string) => { + // The header doesn't render the pencil until the page has mounted with + // initial data, so workflowConfigurations is non-null by the time this + // runs. Throw rather than silently sending DEFAULT_WORKFLOW_CONFIGURATIONS, + // which would overwrite the saved server-side config. + if (!workflowConfigurations) { + throw new Error("Workflow configurations not loaded"); + } + await saveWorkflowConfigurations(workflowConfigurations, newName); + }, [saveWorkflowConfigurations, workflowConfigurations]); + // Memoize the context value to prevent unnecessary re-renders const workflowContextValue = useMemo(() => ({ saveWorkflow: guardedSaveWorkflow, @@ -342,6 +355,7 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial onBackToDraft={handleBackToDraft} hasDraft={hasDraft} onPublished={handlePublished} + renameWorkflow={renameWorkflow} /> {/* Workflow Canvas */} diff --git a/ui/src/app/workflow/[workflowId]/components/WorkflowEditorHeader.tsx b/ui/src/app/workflow/[workflowId]/components/WorkflowEditorHeader.tsx index 364196c..c2b7a68 100644 --- a/ui/src/app/workflow/[workflowId]/components/WorkflowEditorHeader.tsx +++ b/ui/src/app/workflow/[workflowId]/components/WorkflowEditorHeader.tsx @@ -1,10 +1,10 @@ "use client"; import { ReactFlowInstance } from "@xyflow/react"; -import { AlertCircle, ArrowLeft, ChevronDown, Clipboard, Copy, Download, Eye, History, LoaderCircle, Menu, MoreVertical, Phone, Rocket } from "lucide-react"; +import { AlertCircle, ArrowLeft, ChevronDown, Clipboard, Copy, Download, Eye, History, LoaderCircle, Menu, MoreVertical, Pencil, Phone, Rocket } from "lucide-react"; import { useRouter } from "next/navigation"; import posthog from "posthog-js"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { toast } from "sonner"; import { @@ -21,6 +21,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, @@ -47,6 +48,7 @@ interface WorkflowEditorHeaderProps { onBackToDraft: () => void; hasDraft: boolean; onPublished: () => void; + renameWorkflow: (newName: string) => Promise; } export const WorkflowEditorHeader = ({ @@ -65,12 +67,25 @@ export const WorkflowEditorHeader = ({ onPublished, workflowId, workflowUuid, + renameWorkflow, }: WorkflowEditorHeaderProps) => { const router = useRouter(); const { toggleSidebar } = useSidebar(); const [savingWorkflow, setSavingWorkflow] = useState(false); const [duplicating, setDuplicating] = useState(false); const [publishing, setPublishing] = useState(false); + // One discriminated-union state instead of (isEditingName, nameDraft, + // nameError, isRenaming): they're not independent — error and saving are + // mutually exclusive, and both are meaningless in the display state. The + // union makes the bad combinations unrepresentable and structurally + // prevents the Enter→disable-input→blur→re-fire race. + type RenameState = + | { kind: "display" } + | { kind: "editing"; draft: string; error: string | null } + | { kind: "saving"; draft: string }; + const [rename, setRename] = useState({ kind: "display" }); + const nameInputRef = useRef(null); + const renameButtonRef = useRef(null); const hasValidationErrors = workflowValidationErrors.length > 0; const isCallDisabled = isDirty || hasValidationErrors; @@ -158,6 +173,66 @@ export const WorkflowEditorHeader = ({ URL.revokeObjectURL(url); }; + const enterEditMode = () => { + setRename({ kind: "editing", draft: workflowName, error: null }); + }; + + const exitEditMode = () => { + setRename({ kind: "display" }); + // Return focus to the pencil button so keyboard users aren't stranded. + // Defer to next tick so React commits the input unmount first. + setTimeout(() => renameButtonRef.current?.focus(), 0); + }; + + const attemptSave = async () => { + // Only "editing" can initiate a save. This also guards against the + // blur fired when disabling the input transitions us to "saving". + if (rename.kind !== "editing") return; + const trimmed = rename.draft.trim(); + if (trimmed.length === 0) { + setRename({ ...rename, error: "Name cannot be empty" }); + return; + } + if (trimmed === workflowName) { + // No-op: exit cleanly with no API call. + exitEditMode(); + return; + } + setRename({ kind: "saving", draft: rename.draft }); + try { + await renameWorkflow(trimmed); + // Success: store update already propagated workflowName. Exit edit mode. + exitEditMode(); + } catch { + // Roll back: keep user's typed value, reopen the input, focus it, + // surface a sonner toast (matches existing duplicate/publish failure pattern). + toast.error("Failed to rename workflow"); + setRename({ kind: "editing", draft: trimmed, error: null }); + setTimeout(() => nameInputRef.current?.focus(), 0); + } + }; + + const handleRenameKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + void attemptSave(); + } else if (event.key === "Escape") { + event.preventDefault(); + exitEditMode(); + } + }; + + const handleRenameBlur = () => { + // Ignore the blur fired when the input is disabled during save. + if (rename.kind !== "editing") return; + // On blur with empty/whitespace, revert silently to display mode so the user is never trapped. + if (rename.draft.trim().length === 0) { + exitEditMode(); + return; + } + void attemptSave(); + }; + return (
{/* Left section: Mobile menu + Back button + Workflow name */} @@ -177,12 +252,52 @@ export const WorkflowEditorHeader = ({
-

- - {workflowName.length > 8 ? `${workflowName.slice(0, 8)}…` : workflowName} - - {workflowName} -

+ {rename.kind !== "display" ? ( +
+ { + // onChange can't fire while disabled (kind === "saving"), + // but the type guard is needed for the discriminated union. + if (rename.kind === "editing") { + setRename({ ...rename, draft: e.target.value, error: null }); + } + }} + onKeyDown={handleRenameKeyDown} + onBlur={handleRenameBlur} + disabled={rename.kind === "saving"} + autoFocus + onFocus={(e) => e.currentTarget.select()} + aria-label="Workflow name" + aria-invalid={rename.kind === "editing" && rename.error !== null} + className="h-8 max-w-xs bg-[#2a2a2a] border-[#3a3a3a] text-white text-base font-medium" + /> + {rename.kind === "editing" && rename.error && ( + {rename.error} + )} +
+ ) : ( + <> +

+ + {workflowName.length > 8 ? `${workflowName.slice(0, 8)}…` : workflowName} + + {workflowName} +

+ {!isViewingHistoricalVersion && ( + + )} + + )}