From 75948053b937172ba30f52ad45358815a61ce108 Mon Sep 17 00:00:00 2001 From: Celal Zamanoglu <95054566+celalzamanoglu@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:37:18 +0300 Subject: [PATCH] Folders, Persistent Import Progress Tracking & UX Enhancements (#3841) Co-authored-by: Jonathan Dobson --- ..._30_1414-541870962332_add_folders_table.py | 90 +++ ...49aa4b_add_import_tracking_to_workflows.py | 30 + ...1523-2c34dee3304e_merge_migration_heads.py | 23 + .../routes/workflows/ImportWorkflowButton.tsx | 118 ++-- .../src/routes/workflows/WorkflowActions.tsx | 3 + .../src/routes/workflows/Workflows.tsx | 630 ++++++++++++++---- .../components/CreateFolderDialog.tsx | 101 +++ .../components/DeleteFolderButton.tsx | 118 ++++ .../workflows/components/EditFolderDialog.tsx | 110 +++ .../workflows/components/FolderCard.tsx | 89 +++ .../workflows/components/HighlightText.tsx | 32 + .../components/ViewAllFoldersDialog.tsx | 147 ++++ .../components/WorkflowFolderSelector.tsx | 172 +++++ .../components/WorkflowParametersDialog.tsx | 179 ----- .../hooks/useActiveImportsPolling.ts | 128 ++++ .../workflows/hooks/useActiveImportsQuery.ts | 38 ++ .../workflows/hooks/useFolderMutations.ts | 127 ++++ .../routes/workflows/hooks/useFoldersQuery.ts | 38 ++ .../hooks/useInfiniteFoldersQuery.ts | 45 ++ .../src/routes/workflows/types/folderTypes.ts | 23 + .../routes/workflows/types/workflowTypes.ts | 2 + skyvern-frontend/src/util/utils.ts | 28 + skyvern/forge/sdk/db/client.py | 403 ++++++++++- skyvern/forge/sdk/db/id.py | 6 + skyvern/forge/sdk/db/models.py | 26 + skyvern/forge/sdk/db/utils.py | 2 + skyvern/forge/sdk/routes/agent_protocol.py | 427 ++++++++++-- skyvern/forge/sdk/workflow/models/workflow.py | 2 + skyvern/forge/sdk/workflow/service.py | 8 +- skyvern/schemas/folders.py | 41 ++ skyvern/schemas/workflows.py | 2 + skyvern/services/pdf_import_service.py | 236 +++---- 32 files changed, 2886 insertions(+), 538 deletions(-) create mode 100644 alembic/versions/2025_10_30_1414-541870962332_add_folders_table.py create mode 100644 alembic/versions/2025_10_30_1425-b61cf349aa4b_add_import_tracking_to_workflows.py create mode 100644 alembic/versions/2025_11_05_1523-2c34dee3304e_merge_migration_heads.py create mode 100644 skyvern-frontend/src/routes/workflows/components/CreateFolderDialog.tsx create mode 100644 skyvern-frontend/src/routes/workflows/components/DeleteFolderButton.tsx create mode 100644 skyvern-frontend/src/routes/workflows/components/EditFolderDialog.tsx create mode 100644 skyvern-frontend/src/routes/workflows/components/FolderCard.tsx create mode 100644 skyvern-frontend/src/routes/workflows/components/HighlightText.tsx create mode 100644 skyvern-frontend/src/routes/workflows/components/ViewAllFoldersDialog.tsx create mode 100644 skyvern-frontend/src/routes/workflows/components/WorkflowFolderSelector.tsx delete mode 100644 skyvern-frontend/src/routes/workflows/components/WorkflowParametersDialog.tsx create mode 100644 skyvern-frontend/src/routes/workflows/hooks/useActiveImportsPolling.ts create mode 100644 skyvern-frontend/src/routes/workflows/hooks/useActiveImportsQuery.ts create mode 100644 skyvern-frontend/src/routes/workflows/hooks/useFolderMutations.ts create mode 100644 skyvern-frontend/src/routes/workflows/hooks/useFoldersQuery.ts create mode 100644 skyvern-frontend/src/routes/workflows/hooks/useInfiniteFoldersQuery.ts create mode 100644 skyvern-frontend/src/routes/workflows/types/folderTypes.ts create mode 100644 skyvern/schemas/folders.py diff --git a/alembic/versions/2025_10_30_1414-541870962332_add_folders_table.py b/alembic/versions/2025_10_30_1414-541870962332_add_folders_table.py new file mode 100644 index 00000000..1f29b704 --- /dev/null +++ b/alembic/versions/2025_10_30_1414-541870962332_add_folders_table.py @@ -0,0 +1,90 @@ +"""add_folders_table + +Revision ID: 541870962332 +Revises: d135e472622c +Create Date: 2025-10-30 14:14:37.593691+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "541870962332" +down_revision: Union[str, None] = "d135e472622c" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + # Step 1: Create folders table + op.create_table( + "folders", + sa.Column("folder_id", sa.String(), nullable=False), + sa.Column("organization_id", sa.String(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("modified_at", sa.DateTime(), nullable=False), + sa.Column("deleted_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("folder_id"), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organizations.organization_id"], + ondelete="CASCADE", + ), + ) + + # Create indexes on folders table + op.create_index( + "folder_organization_id_idx", + "folders", + ["organization_id"], + unique=False, + ) + op.create_index( + "folder_organization_title_idx", + "folders", + ["organization_id", "title"], + unique=False, + ) + + # Step 2: Add folder_id to workflows table + op.add_column("workflows", sa.Column("folder_id", sa.String(), nullable=True)) + + # Create index on workflows.folder_id + op.create_index( + "workflow_folder_id_idx", + "workflows", + ["folder_id"], + unique=False, + ) + + # Create foreign key constraint + op.create_foreign_key( + "fk_workflows_folder_id", + "workflows", + "folders", + ["folder_id"], + ["folder_id"], + ondelete="SET NULL", + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + # Step 1: Remove folder_id from workflows table (must be done before dropping folders table) + op.drop_constraint("fk_workflows_folder_id", "workflows", type_="foreignkey") + op.drop_index("workflow_folder_id_idx", table_name="workflows") + op.drop_column("workflows", "folder_id") + + # Step 2: Drop folders table + op.drop_index("folder_organization_title_idx", table_name="folders") + op.drop_index("folder_organization_id_idx", table_name="folders") + op.drop_table("folders") + # ### end Alembic commands ### diff --git a/alembic/versions/2025_10_30_1425-b61cf349aa4b_add_import_tracking_to_workflows.py b/alembic/versions/2025_10_30_1425-b61cf349aa4b_add_import_tracking_to_workflows.py new file mode 100644 index 00000000..baf8e969 --- /dev/null +++ b/alembic/versions/2025_10_30_1425-b61cf349aa4b_add_import_tracking_to_workflows.py @@ -0,0 +1,30 @@ +"""add_import_tracking_to_workflows + +Revision ID: b61cf349aa4b +Revises: 541870962332 +Create Date: 2025-10-30 14:25:37.010446+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "b61cf349aa4b" +down_revision: Union[str, None] = "541870962332" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add import_error column to workflows table for tracking import failures + # Note: status column is a String, not an enum, so no schema changes needed for new status values + op.add_column("workflows", sa.Column("import_error", sa.String(), nullable=True)) + + +def downgrade() -> None: + # Remove import_error column from workflows table + op.drop_column("workflows", "import_error") diff --git a/alembic/versions/2025_11_05_1523-2c34dee3304e_merge_migration_heads.py b/alembic/versions/2025_11_05_1523-2c34dee3304e_merge_migration_heads.py new file mode 100644 index 00000000..527c9867 --- /dev/null +++ b/alembic/versions/2025_11_05_1523-2c34dee3304e_merge_migration_heads.py @@ -0,0 +1,23 @@ +"""merge migration heads + +Revision ID: 2c34dee3304e +Revises: b61cf349aa4b, 7fbf463be9a7 +Create Date: 2025-11-05 15:23:24.380086+00:00 + +""" + +from typing import Sequence, Union + +# revision identifiers, used by Alembic. +revision: str = "2c34dee3304e" +down_revision: Union[str, None] = ("b61cf349aa4b", "7fbf463be9a7") +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/skyvern-frontend/src/routes/workflows/ImportWorkflowButton.tsx b/skyvern-frontend/src/routes/workflows/ImportWorkflowButton.tsx index 9e7c40e9..3e542da2 100644 --- a/skyvern-frontend/src/routes/workflows/ImportWorkflowButton.tsx +++ b/skyvern-frontend/src/routes/workflows/ImportWorkflowButton.tsx @@ -1,13 +1,11 @@ import { getClient } from "@/api/AxiosClient"; import { Label } from "@/components/ui/label"; -import { ReloadIcon, UploadIcon } from "@radix-ui/react-icons"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { UploadIcon } from "@radix-ui/react-icons"; +import { useQueryClient } from "@tanstack/react-query"; import { useId } from "react"; import { stringify as convertToYAML } from "yaml"; import { WorkflowApiResponse } from "./types/workflowTypes"; import { useCredentialGetter } from "@/hooks/useCredentialGetter"; -import { useNavigate } from "react-router-dom"; -import { AxiosError } from "axios"; import { toast } from "@/components/ui/use-toast"; import { Tooltip, @@ -15,6 +13,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { AxiosError } from "axios"; function isJsonString(str: string): boolean { try { @@ -25,16 +24,28 @@ function isJsonString(str: string): boolean { return true; } -function ImportWorkflowButton() { +function getErrorMessage(error: unknown, fallback: string): string { + if (error instanceof AxiosError) { + return error.response?.data?.detail || error.message || fallback; + } else if (error instanceof Error) { + return error.message; + } + return fallback; +} + +interface ImportWorkflowButtonProps { + onImportStart?: () => void; +} + +function ImportWorkflowButton({ onImportStart }: ImportWorkflowButtonProps) { const inputId = useId(); const credentialGetter = useCredentialGetter(); const queryClient = useQueryClient(); - const navigate = useNavigate(); - const createWorkflowFromYamlMutation = useMutation({ - mutationFn: async (yaml: string) => { + const createWorkflowFromYamlMutation = async (yaml: string) => { + try { const client = await getClient(credentialGetter); - return client.post( + await client.post( "/workflows", yaml, { @@ -43,21 +54,51 @@ function ImportWorkflowButton() { }, }, ); - }, - onSuccess: (response) => { + queryClient.invalidateQueries({ queryKey: ["workflows"], }); - navigate(`/workflows/${response.data.workflow_permanent_id}/debug`); - }, - onError: (error: AxiosError) => { + toast({ + variant: "success", + title: "Workflow imported", + description: "Successfully imported workflow", + }); + } catch (error) { toast({ variant: "destructive", title: "Error importing workflow", - description: error.message || "An error occurred", + description: getErrorMessage(error, "Failed to import workflow"), }); - }, - }); + } + }; + + const createWorkflowFromPdfMutation = async (file: File) => { + try { + const formData = new FormData(); + formData.append("file", file); + + const client = await getClient(credentialGetter); + await client.post("/workflows/import-pdf", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + + // Notify parent to start polling + onImportStart?.(); + + toast({ + title: "Import started", + description: `Importing ${file.name}...`, + }); + } catch (error) { + toast({ + title: "Import Failed", + description: getErrorMessage(error, "Failed to import PDF"), + variant: "destructive", + }); + } + }; return ( @@ -75,38 +116,8 @@ function ImportWorkflowButton() { const fileName = file.name.toLowerCase(); if (fileName.endsWith(".pdf")) { - // Handle PDF file - send as FormData to new endpoint - const formData = new FormData(); - formData.append("file", file); - - const client = await getClient(credentialGetter); - try { - const response = await client.post( - "/workflows/import-pdf", - formData, - { - headers: { - "Content-Type": "multipart/form-data", - }, - }, - ); - - queryClient.invalidateQueries({ - queryKey: ["workflows"], - }); - navigate( - `/workflows/${response.data.workflow_permanent_id}/debug`, - ); - } catch (error) { - toast({ - title: "Import Failed", - description: - error instanceof Error - ? error.message - : "Failed to import PDF", - variant: "destructive", - }); - } + // Handle PDF file + await createWorkflowFromPdfMutation(file); } else { // Non-pdf files like yaml, json const fileTextContent = await file.text(); @@ -114,17 +125,14 @@ function ImportWorkflowButton() { const content = isJson ? convertToYAML(JSON.parse(fileTextContent)) : fileTextContent; - createWorkflowFromYamlMutation.mutate(content); + + await createWorkflowFromYamlMutation(content); } } }} />
- {createWorkflowFromYamlMutation.isPending ? ( - - ) : ( - - )} + Import
diff --git a/skyvern-frontend/src/routes/workflows/WorkflowActions.tsx b/skyvern-frontend/src/routes/workflows/WorkflowActions.tsx index f5673622..51dab535 100644 --- a/skyvern-frontend/src/routes/workflows/WorkflowActions.tsx +++ b/skyvern-frontend/src/routes/workflows/WorkflowActions.tsx @@ -84,6 +84,9 @@ function WorkflowActions({ workflow, onSuccessfullyDeleted }: Props) { queryClient.invalidateQueries({ queryKey: ["workflows"], }); + queryClient.invalidateQueries({ + queryKey: ["folders"], + }); onSuccessfullyDeleted?.(); }, onError: (error: AxiosError) => { diff --git a/skyvern-frontend/src/routes/workflows/Workflows.tsx b/skyvern-frontend/src/routes/workflows/Workflows.tsx index bf8b9a9b..77c9c0cc 100644 --- a/skyvern-frontend/src/routes/workflows/Workflows.tsx +++ b/skyvern-frontend/src/routes/workflows/Workflows.tsx @@ -27,26 +27,34 @@ import { useCredentialGetter } from "@/hooks/useCredentialGetter"; import { basicLocalTimeFormat, basicTimeFormat } from "@/util/timeFormat"; import { cn } from "@/util/utils"; import { + DotsHorizontalIcon, + FileIcon, LightningBoltIcon, MagnifyingGlassIcon, MixerHorizontalIcon, - Pencil2Icon, PlayIcon, PlusIcon, ReloadIcon, } from "@radix-ui/react-icons"; import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useDebounce } from "use-debounce"; import { NarrativeCard } from "./components/header/NarrativeCard"; -import { WorkflowParametersDialog } from "./components/WorkflowParametersDialog"; +import { FolderCard } from "./components/FolderCard"; +import { CreateFolderDialog } from "./components/CreateFolderDialog"; +import { ViewAllFoldersDialog } from "./components/ViewAllFoldersDialog"; +import { WorkflowFolderSelector } from "./components/WorkflowFolderSelector"; +import { HighlightText } from "./components/HighlightText"; import { useCreateWorkflowMutation } from "./hooks/useCreateWorkflowMutation"; +import { useFoldersQuery } from "./hooks/useFoldersQuery"; +import { useActiveImportsPolling } from "./hooks/useActiveImportsPolling"; import { ImportWorkflowButton } from "./ImportWorkflowButton"; -import { WorkflowApiResponse } from "./types/workflowTypes"; +import { Parameter, WorkflowApiResponse } from "./types/workflowTypes"; import { WorkflowCreateYAMLRequest } from "./types/workflowYamlTypes"; import { WorkflowActions } from "./WorkflowActions"; import { WorkflowTemplates } from "../discover/WorkflowTemplates"; +import { Skeleton } from "@/components/ui/skeleton"; const emptyWorkflowRequest: WorkflowCreateYAMLRequest = { title: "New Workflow", @@ -65,17 +73,55 @@ function Workflows() { const createWorkflowMutation = useCreateWorkflowMutation(); const [searchParams, setSearchParams] = useSearchParams(); const [search, setSearch] = useState(""); - const [openWorkflowId, setOpenWorkflowId] = useState(null); - const [debouncedSearch] = useDebounce(search, 500); + const [debouncedSearch] = useDebounce(search, 250); const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1; const itemsPerPage = searchParams.get("page_size") ? Number(searchParams.get("page_size")) : 10; - const { data: workflows = [], isLoading } = useQuery< - Array - >({ - queryKey: ["workflows", debouncedSearch, page, itemsPerPage], + // Folder state + const [selectedFolderId, setSelectedFolderId] = useState(null); + const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false); + const [isViewAllFoldersOpen, setIsViewAllFoldersOpen] = useState(false); + + // Parameter expansion state + const [manuallyExpandedRows, setManuallyExpandedRows] = useState>( + new Set(), + ); + + // Poll for active imports + const { activeImports, startPolling } = useActiveImportsPolling(); + + // Fetch folders + const { data: allFolders = [] } = useFoldersQuery({ page_size: 10 }); + + // Create folders map for O(1) lookup + const foldersMap = useMemo(() => { + return new Map(allFolders.map((f) => [f.folder_id, f])); + }, [allFolders]); + + // Sort folders by modified date (most recent first) and get top 5 + const recentFolders = useMemo(() => { + return [...allFolders] + .sort( + (a, b) => + new Date(b.modified_at).getTime() - new Date(a.modified_at).getTime(), + ) + .slice(0, 5); + }, [allFolders]); + + const { + data: workflows = [], + isFetching, + isPlaceholderData, + } = useQuery>({ + queryKey: [ + "workflows", + debouncedSearch, + page, + itemsPerPage, + selectedFolderId, + ], queryFn: async () => { const client = await getClient(credentialGetter); const params = new URLSearchParams(); @@ -85,16 +131,26 @@ function Workflows() { if (debouncedSearch) { params.append("search_key", debouncedSearch); } + if (selectedFolderId) { + params.append("folder_id", selectedFolderId); + } return client .get(`/workflows`, { params, }) .then((response) => response.data); }, + placeholderData: (previousData) => previousData, }); const { data: nextPageWorkflows } = useQuery>({ - queryKey: ["workflows", debouncedSearch, page + 1, itemsPerPage], + queryKey: [ + "workflows", + debouncedSearch, + page + 1, + itemsPerPage, + selectedFolderId, + ], queryFn: async () => { const client = await getClient(credentialGetter); const params = new URLSearchParams(); @@ -104,6 +160,9 @@ function Workflows() { if (debouncedSearch) { params.append("search_key", debouncedSearch); } + if (selectedFolderId) { + params.append("folder_id", selectedFolderId); + } return client .get(`/workflows`, { params, @@ -114,7 +173,61 @@ function Workflows() { }); const isNextDisabled = - isLoading || !nextPageWorkflows || nextPageWorkflows.length === 0; + isFetching || !nextPageWorkflows || nextPageWorkflows.length === 0; + + // Check if a specific parameter matches the search + const parameterMatchesSearch = useCallback( + (param: Parameter): boolean => { + if (!debouncedSearch.trim()) return false; + const lowerQuery = debouncedSearch.toLowerCase(); + + const keyMatch = param.key?.toLowerCase().includes(lowerQuery) ?? false; + const descMatch = + param.description?.toLowerCase().includes(lowerQuery) ?? false; + const valueMatch = Boolean( + param.parameter_type === "workflow" && + param.default_value && + String(param.default_value).toLowerCase().includes(lowerQuery), + ); + + return keyMatch || descMatch || valueMatch; + }, + [debouncedSearch], + ); + + // Auto-expand rows when parameters match search + const autoExpandedRows = useMemo(() => { + if (!debouncedSearch.trim()) return new Set(); + + const expanded = new Set(); + + workflows.forEach((workflow) => { + const hasParameterMatch = workflow.workflow_definition.parameters?.some( + (param) => parameterMatchesSearch(param), + ); + + if (hasParameterMatch) { + expanded.add(workflow.workflow_permanent_id); + } + }); + + return expanded; + }, [workflows, debouncedSearch, parameterMatchesSearch]); + + // Combine manual and auto-expanded rows + const expandedRows = useMemo(() => { + return new Set([...manuallyExpandedRows, ...autoExpandedRows]); + }, [manuallyExpandedRows, autoExpandedRows]); + + const toggleParametersExpanded = (workflowId: string) => { + const newExpanded = new Set(manuallyExpandedRows); + if (newExpanded.has(workflowId)) { + newExpanded.delete(workflowId); + } else { + newExpanded.add(workflowId); + } + setManuallyExpandedRows(newExpanded); + }; function handleRowClick( event: React.MouseEvent, @@ -162,6 +275,18 @@ function Workflows() { setParamPatch({ page: String(page + 1) }); } + // Show importing workflows from polling hook (only on page 1) + const displayWorkflows = useMemo(() => { + const importingOnly = activeImports.filter( + (imp) => imp.status === "importing", + ); + + if (page === 1 && importingOnly.length > 0) { + return [...importingOnly, ...workflows]; + } + return workflows; + }, [activeImports, workflows, page]); + return (
@@ -191,8 +316,88 @@ function Workflows() {
-
+ {/* Folders Section */} +
+
+
+

Folders

+ +
+ {allFolders.length > 5 && ( + + )} +
+ + {recentFolders.length > 0 ? ( +
+ {recentFolders.map((folder) => ( + + setSelectedFolderId( + selectedFolderId === folder.folder_id + ? null + : folder.folder_id, + ) + } + /> + ))} +
+ ) : ( +
+
+ +

+ Organize Your Workflows with Folders +

+

+ Keep your workflows organized by creating folders. Group + related workflows together by project, team, or workflow type + for easier management. +

+ +
+
+ )} +
+ + {/* Workflows Section */} +

My Flows

+ {selectedFolderId && ( + + )}
@@ -210,7 +415,7 @@ function Workflows() { />
- +
diff --git a/skyvern-frontend/src/routes/workflows/components/CreateFolderDialog.tsx b/skyvern-frontend/src/routes/workflows/components/CreateFolderDialog.tsx new file mode 100644 index 00000000..f94a534d --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/components/CreateFolderDialog.tsx @@ -0,0 +1,101 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { useCreateFolderMutation } from "../hooks/useFolderMutations"; + +interface CreateFolderDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function CreateFolderDialog({ open, onOpenChange }: CreateFolderDialogProps) { + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const createFolderMutation = useCreateFolderMutation(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!title.trim()) return; + + await createFolderMutation.mutateAsync({ + title: title.trim(), + description: description.trim() || null, + }); + + setTitle(""); + setDescription(""); + onOpenChange(false); + }; + + const handleOpenChange = (open: boolean) => { + onOpenChange(open); + if (!open) { + setTitle(""); + setDescription(""); + } + }; + + return ( + + + + Create New Folder + + Create a folder to organize your workflows. + + +
+
+
+ + setTitle(e.target.value)} + placeholder="e.g., Production Workflows" + autoFocus + /> +
+
+ +