From 67717aa9870e4bed09bfc45238e81c27e0b253eb Mon Sep 17 00:00:00 2001 From: Jonathan Dobson Date: Fri, 1 Aug 2025 09:02:56 -0400 Subject: [PATCH] automagic workflow titling SKY-5011 (#3081) --- .../components/WorkflowBlockInputTextarea.tsx | 25 +++++- .../routes/workflows/editor/FlowRenderer.tsx | 13 +-- .../workflows/editor/WorkflowHeader.tsx | 13 +-- .../editor/nodes/ActionNode/ActionNode.tsx | 1 + .../FileDownloadNode/FileDownloadNode.tsx | 1 + .../nodes/HttpRequestNode/HttpRequestNode.tsx | 1 + .../editor/nodes/LoginNode/LoginNode.tsx | 1 + .../nodes/NavigationNode/NavigationNode.tsx | 1 + .../editor/nodes/TaskNode/TaskNode.tsx | 1 + .../editor/nodes/Taskv2Node/Taskv2Node.tsx | 1 + .../editor/nodes/URLNode/URLNode.tsx | 1 + .../src/store/WorkflowTitleStore.ts | 90 +++++++++++++++++++ 12 files changed, 134 insertions(+), 15 deletions(-) create mode 100644 skyvern-frontend/src/store/WorkflowTitleStore.ts diff --git a/skyvern-frontend/src/components/WorkflowBlockInputTextarea.tsx b/skyvern-frontend/src/components/WorkflowBlockInputTextarea.tsx index ef3da7f6..1b267de7 100644 --- a/skyvern-frontend/src/components/WorkflowBlockInputTextarea.tsx +++ b/skyvern-frontend/src/components/WorkflowBlockInputTextarea.tsx @@ -3,24 +3,40 @@ import { cn } from "@/util/utils"; import { AutoResizingTextarea } from "./AutoResizingTextarea/AutoResizingTextarea"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { WorkflowBlockParameterSelect } from "@/routes/workflows/editor/nodes/WorkflowBlockParameterSelect"; +import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore"; import { useRef, useState } from "react"; type Props = Omit< React.ComponentProps, "onChange" > & { + canWriteTitle?: boolean; onChange: (value: string) => void; nodeId: string; }; function WorkflowBlockInputTextarea(props: Props) { - const { nodeId, onChange, ...textAreaProps } = props; + const { maybeAcceptTitle, maybeWriteTitle } = useWorkflowTitleStore(); + const { nodeId, onChange, canWriteTitle = false, ...textAreaProps } = props; const textareaRef = useRef(null); const [cursorPosition, setCursorPosition] = useState<{ start: number; end: number; } | null>(null); + const handleOnBlur = () => { + if (canWriteTitle) { + maybeAcceptTitle(); + } + }; + + const handleOnChange = (value: string) => { + onChange(value); + if (canWriteTitle) { + maybeWriteTitle(value); + } + }; + const handleTextareaSelect = () => { if (textareaRef.current) { setCursorPosition({ @@ -39,7 +55,7 @@ function WorkflowBlockInputTextarea(props: Props) { const newValue = value.substring(0, start) + parameterText + value.substring(end); - onChange(newValue); + handleOnChange(newValue); setTimeout(() => { if (textareaRef.current) { @@ -49,7 +65,7 @@ function WorkflowBlockInputTextarea(props: Props) { } }, 0); } else { - onChange(`${value}${parameterText}`); + handleOnChange(`${value}${parameterText}`); } }; @@ -58,8 +74,9 @@ function WorkflowBlockInputTextarea(props: Props) { { - onChange(event.target.value); + handleOnChange(event.target.value); handleTextareaSelect(); }} onClick={handleTextareaSelect} diff --git a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx index 75ea27be..02ce47bd 100644 --- a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx @@ -16,6 +16,7 @@ import { DeleteNodeCallbackContext } from "@/store/DeleteNodeCallbackContext"; import { useDebugStore } from "@/store/useDebugStore"; import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore"; import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore"; +import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore"; import { ReloadIcon } from "@radix-ui/react-icons"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { @@ -269,11 +270,11 @@ function FlowRenderer({ const queryClient = useQueryClient(); const { workflowPanelState, setWorkflowPanelState, closeWorkflowPanel } = useWorkflowPanelStore(); + const { title, initializeTitle } = useWorkflowTitleStore(); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); const [parameters, setParameters] = useState(initialParameters); - const [title, setTitle] = useState(initialTitle); const [debuggableBlockCount, setDebuggableBlockCount] = useState(0); const nodesInitialized = useNodesInitialized(); const [shouldConstrainPan, setShouldConstrainPan] = useState(false); @@ -283,6 +284,11 @@ function FlowRenderer({ setShouldConstrainPan(true); } }, [nodesInitialized]); + + useEffect(() => { + initializeTitle(initialTitle); + }, [initialTitle, initializeTitle]); + const { hasChanges, setHasChanges } = useWorkflowHasChangesStore(); useShouldNotifyWhenClosingTab(hasChanges); const blocker = useBlocker(({ currentLocation, nextLocation }) => { @@ -799,12 +805,7 @@ function FlowRenderer({ { - setTitle(newTitle); - setHasChanges(true); - }} parametersPanelOpen={ workflowPanelState.active && workflowPanelState.content === "parameters" diff --git a/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx b/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx index 3a0db6c7..11b0aea3 100644 --- a/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx @@ -20,27 +20,27 @@ import { EditableNodeTitle } from "./nodes/components/EditableNodeTitle"; import { useCreateWorkflowMutation } from "../hooks/useCreateWorkflowMutation"; import { convert } from "./workflowEditorUtils"; import { useDebugStore } from "@/store/useDebugStore"; +import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore"; +import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore"; import { cn } from "@/util/utils"; type Props = { debuggableBlockCount: number; - title: string; parametersPanelOpen: boolean; onParametersClick: () => void; onSave: () => void; - onTitleChange: (title: string) => void; saving: boolean; }; function WorkflowHeader({ debuggableBlockCount, - title, parametersPanelOpen, onParametersClick, onSave, - onTitleChange, saving, }: Props) { + const { title, setTitle } = useWorkflowTitleStore(); + const { setHasChanges } = useWorkflowHasChangesStore(); const { blockLabel: urlBlockLabel, workflowPermanentId } = useParams(); const { data: globalWorkflows } = useGlobalWorkflowsQuery(); const navigate = useNavigate(); @@ -66,7 +66,10 @@ function WorkflowHeader({
{ + setTitle(newTitle); + setHasChanges(true); + }} value={title} titleClassName="text-3xl" inputClassName="text-3xl" diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx index dac8cb12..e5a4ba73 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx @@ -129,6 +129,7 @@ function ActionNode({ id, data, type }: NodeProps) {
{ handleChange("url", value); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/FileDownloadNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/FileDownloadNode.tsx index e615a638..73bc8e52 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/FileDownloadNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/FileDownloadNode.tsx @@ -121,6 +121,7 @@ function FileDownloadNode({ id, data }: NodeProps) { ) : null} { handleChange("url", value); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/HttpRequestNode/HttpRequestNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/HttpRequestNode/HttpRequestNode.tsx index c8e3c918..29622aa8 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/HttpRequestNode/HttpRequestNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/HttpRequestNode/HttpRequestNode.tsx @@ -242,6 +242,7 @@ function HttpRequestNode({ id, data }: NodeProps) { ) : null} { handleChange("url", value); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginNode.tsx index 6ad04fdb..055f626c 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginNode.tsx @@ -119,6 +119,7 @@ function LoginNode({ id, data, type }: NodeProps) { { handleChange("url", value); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/NavigationNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/NavigationNode.tsx index 95e5ee6d..0b7f6500 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/NavigationNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/NavigationNode.tsx @@ -127,6 +127,7 @@ function NavigationNode({ id, data, type }: NodeProps) { { handleChange("url", value); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx index 275013d3..89a25e31 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx @@ -128,6 +128,7 @@ function TaskNode({ id, data, type }: NodeProps) { ) : null} { handleChange("url", value); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/Taskv2Node.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/Taskv2Node.tsx index 8eebbc3a..bdd0a4dd 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/Taskv2Node.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/Taskv2Node.tsx @@ -102,6 +102,7 @@ function Taskv2Node({ id, data, type }: NodeProps) {
{ handleChange("url", value); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/URLNode/URLNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/URLNode/URLNode.tsx index 76667059..be057278 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/URLNode/URLNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/URLNode/URLNode.tsx @@ -75,6 +75,7 @@ function URLNode({ id, data, type }: NodeProps) { ) : null}
{ handleChange("url", value); diff --git a/skyvern-frontend/src/store/WorkflowTitleStore.ts b/skyvern-frontend/src/store/WorkflowTitleStore.ts new file mode 100644 index 00000000..aa1ebe03 --- /dev/null +++ b/skyvern-frontend/src/store/WorkflowTitleStore.ts @@ -0,0 +1,90 @@ +/** + * Context: new workflows begin with a default title. If the user edits a URL + * field in a workflow block, and the title is deemed "new", we want to + * automagically update the title to the text of the URL. That way, they don't + * have to manually update the title themselves, if they deem the automagic + * title to be appropriate. + */ +import { create } from "zustand"; + +const DEFAULT_WORKFLOW_TITLE = "New Workflow" as const; +const DELIMITER_OPEN = "[["; +const DELIMITER_CLOSE = "]]"; + +type WorkflowTitleStore = { + title: string; + /** + * If the title is deemed to be new, accept it, and prevent further + * `maybeWriteTitle` updates. + */ + maybeAcceptTitle: () => void; + /** + * Maybe update the title - if it's empty, or deemed to be new and unedited. + */ + maybeWriteTitle: (title: string) => void; + setTitle: (title: string) => void; + initializeTitle: (title: string) => void; + resetTitle: () => void; +}; +/** + * If the title appears to be a URL, let's trim it down to the domain and path. + */ +const formatURL = (url: string) => { + try { + const urlObj = new URL(url); + return urlObj.hostname + urlObj.pathname; + } catch { + return url; + } +}; + +/** + * If the title begins and ends with squackets, remove them. + */ +const formatAcceptedTitle = (title: string) => { + if (title.startsWith(DELIMITER_OPEN) && title.endsWith(DELIMITER_CLOSE)) { + const trimmed = title.slice(DELIMITER_OPEN.length, -DELIMITER_CLOSE.length); + + return formatURL(trimmed); + } + + return title; +}; + +const formatNewTitle = (title: string) => + title.trim().length + ? `${DELIMITER_OPEN}${title}${DELIMITER_CLOSE}` + : DEFAULT_WORKFLOW_TITLE; + +const isNewTitle = (title: string) => + title === DEFAULT_WORKFLOW_TITLE || + (title.startsWith(DELIMITER_OPEN) && title.endsWith(DELIMITER_CLOSE)); + +const useWorkflowTitleStore = create((set, get) => { + return { + title: "", + maybeAcceptTitle: () => { + const { title: currentTitle } = get(); + if (isNewTitle(currentTitle)) { + set({ title: formatAcceptedTitle(currentTitle) }); + } + }, + maybeWriteTitle: (title: string) => { + const { title: currentTitle } = get(); + if (isNewTitle(currentTitle)) { + set({ title: formatNewTitle(title.trim()) }); + } + }, + setTitle: (title: string) => { + set({ title: title.trim() }); + }, + initializeTitle: (title: string) => { + set({ title: title.trim() }); + }, + resetTitle: () => { + set({ title: "" }); + }, + }; +}); + +export { useWorkflowTitleStore };