feat(flows): inline rename and finish/delete actions in flow header

Brings the flow detail page in line with the actions row already available
on the listing: double-click or pick "Rename" from the new actions menu to
edit the title in place, "Finish" to gracefully stop a running flow, and
"Delete" with a confirmation dialog. The listing keeps a `PencilLine`
icon for "Rename" so the action looks consistent across surfaces.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sergey Kozyrenko
2026-05-13 20:52:35 +07:00
co-authored by Cursor
parent b97a0c10a5
commit 465ea406f4
2 changed files with 281 additions and 10 deletions
+266 -8
View File
@@ -1,10 +1,26 @@
import { ChevronDown, Copy, Download, ExternalLink, GripVertical, Loader2, NotepadText, Star } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
Check,
ChevronDown,
Copy,
Download,
Ellipsis,
ExternalLink,
GripVertical,
Loader2,
NotepadText,
Pause,
PencilLine,
Star,
Trash,
X,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { FlowStatusIcon } from '@/components/icons/flow-status-icon';
import { ProviderIcon } from '@/components/icons/provider-icon';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { HeaderButton } from '@/components/shared/header-button';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
@@ -12,13 +28,17 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import FlowCentralTabs from '@/features/flows/flow-central-tabs';
import FlowTabs from '@/features/flows/flow-tabs';
import { ResultType, StatusType, useRenameFlowMutation } from '@/graphql/types';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { useFlowTabDetection } from '@/hooks/use-flow-tab-detection';
import { Log } from '@/lib/log';
@@ -26,6 +46,7 @@ import { copyToClipboard, downloadTextFile, generateFileName, generateReport } f
import { formatName } from '@/lib/utils/format';
import { useFavorites } from '@/providers/favorites-provider';
import { useFlow } from '@/providers/flow-provider';
import { useFlows } from '@/providers/flows-provider';
const FlowReportDropdown = () => {
const { flowData, flowId } = useFlow();
@@ -147,8 +168,52 @@ const Flow = () => {
const navigate = useNavigate();
const { flowData, flowError, flowId, isLoading: isFlowLoading } = useFlow();
const { deleteFlow, finishFlow } = useFlows();
const { isFavoriteFlow, toggleFavoriteFlow } = useFavorites();
const flow = flowData?.flow;
const flowTitle = flow?.title ?? '';
const isFlowRunning = flow ? ![StatusType.Failed, StatusType.Finished].includes(flow.status) : false;
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [isFinishing, setIsFinishing] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const editingInputRef = useRef<HTMLInputElement>(null);
const [renameFlowMutation, { loading: isRenameLoading }] = useRenameFlowMutation();
// Reset inline-edit state when navigating between flows so the input doesn't
// carry over a stale draft from a previous flow.
useEffect(() => {
setIsEditingTitle(false);
}, [flowId]);
// Focus and select the rename input when the inline editor opens. We can't
// rely on `autoFocus` here: the input mounts inside the same render cycle
// that closes the Radix DropdownMenu, and the dropdown's own focus restore
// (which it schedules via `requestAnimationFrame`) wins the race against
// React's autoFocus effect. Defer our focus to the next frame so it lands
// *after* Radix has finished its restore. Selecting the text lets the user
// overwrite the title in one keystroke.
useEffect(() => {
if (!isEditingTitle) {
return;
}
const id = requestAnimationFrame(() => {
const input = editingInputRef.current;
if (!input) {
return;
}
input.focus();
input.select();
});
return () => cancelAnimationFrame(id);
}, [isEditingTitle]);
// Redirect to flows list if there's an error loading flow data or flow not found
useEffect(() => {
if (flowError || (!isFlowLoading && !flowData?.flow)) {
@@ -156,6 +221,71 @@ const Flow = () => {
}
}, [flowError, flowData, isFlowLoading, navigate]);
const handleFlowRenameStart = useCallback(() => {
setIsEditingTitle(true);
}, []);
const handleFlowRenameCancel = useCallback(() => {
setIsEditingTitle(false);
}, []);
const handleFlowRenameSave = useCallback(async () => {
const newTitle = editingInputRef.current?.value.trim();
if (!flowId || !newTitle) {
return;
}
try {
const { data } = await renameFlowMutation({
variables: {
flowId,
title: newTitle,
},
});
if (data?.renameFlow === ResultType.Success) {
toast.success('Flow renamed successfully');
setIsEditingTitle(false);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to rename flow';
toast.error(errorMessage);
}
}, [flowId, renameFlowMutation]);
const handleFlowFinish = useCallback(async () => {
if (!flow) {
return;
}
setIsFinishing(true);
try {
await finishFlow(flow);
} finally {
setIsFinishing(false);
}
}, [flow, finishFlow]);
const handleFlowDelete = useCallback(async () => {
if (!flow) {
return;
}
setIsDeleting(true);
try {
const success = await deleteFlow(flow);
if (success) {
navigate('/flows', { replace: true });
}
} finally {
setIsDeleting(false);
}
}, [flow, deleteFlow, navigate]);
// Desktop: side panel defaults to 'terminal'
const [desktopTabsTab, setDesktopTabsTab] = useState<string>('terminal');
@@ -189,20 +319,74 @@ const Flow = () => {
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="gap-2">
{flowData?.flow && (
{flow && (
<>
<FlowStatusIcon
status={flowData.flow.status}
tooltip={formatName(flowData.flow.status)}
status={flow.status}
tooltip={formatName(flow.status)}
/>
<ProviderIcon
provider={flowData.flow.provider}
tooltip={formatName(flowData.flow.provider.name)}
provider={flow.provider}
tooltip={formatName(flow.provider.name)}
/>
</>
)}
<BreadcrumbPage>{flowData?.flow?.title || 'Select a flow'}</BreadcrumbPage>
{isEditingTitle && flow ? (
<InputGroup className="h-8 w-64 max-w-full">
<InputGroupInput
className="text-foreground"
defaultValue={flowTitle}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
handleFlowRenameSave();
return;
}
if (event.key === 'Escape') {
event.preventDefault();
handleFlowRenameCancel();
}
}}
placeholder="Flow title"
ref={editingInputRef}
/>
<InputGroupAddon
align="inline-end"
className="gap-0 pr-2"
>
<InputGroupButton
aria-label="Save"
disabled={isRenameLoading}
onClick={() => handleFlowRenameSave()}
>
{isRenameLoading ? <Loader2 className="animate-spin" /> : <Check />}
</InputGroupButton>
<InputGroupButton
aria-label="Cancel"
onClick={() => handleFlowRenameCancel()}
>
<X />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
) : flow ? (
<Tooltip>
<TooltipTrigger asChild>
<BreadcrumbPage
className="cursor-text select-none"
onDoubleClick={handleFlowRenameStart}
>
{flowTitle || 'Select a flow'}
</BreadcrumbPage>
</TooltipTrigger>
<TooltipContent>Double-click to rename</TooltipContent>
</Tooltip>
) : (
<BreadcrumbPage>{flowTitle || 'Select a flow'}</BreadcrumbPage>
)}
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
@@ -219,6 +403,71 @@ const Flow = () => {
</Button>
)}
{!!(flowData?.tasks ?? [])?.length && <FlowReportDropdown />}
{flow && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Flow actions"
className="size-8 p-0"
variant="ghost"
>
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="min-w-24"
onCloseAutoFocus={(event) => {
// Radix returns focus to the trigger on close. When the
// selected action mounts the rename input, prevent that
// restore so the input's `autoFocus` actually wins.
if (isEditingTitle) {
event.preventDefault();
}
}}
>
<DropdownMenuItem onClick={handleFlowRenameStart}>
<PencilLine className="size-3" />
Rename
</DropdownMenuItem>
{isFlowRunning && (
<DropdownMenuItem
disabled={isFinishing}
onClick={() => handleFlowFinish()}
>
{isFinishing ? (
<>
<Loader2 className="animate-spin" />
Finishing...
</>
) : (
<>
<Pause />
Finish
</>
)}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={isDeleting}
onClick={() => setIsDeleteDialogOpen(true)}
>
{isDeleting ? (
<>
<Loader2 className="size-4 animate-spin" />
Deleting...
</>
) : (
<>
<Trash className="size-4" />
Delete
</>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</header>
@@ -257,6 +506,15 @@ const Flow = () => {
tabsCard
)}
</div>
<ConfirmationDialog
cancelText="Cancel"
confirmText="Delete"
handleConfirm={handleFlowDelete}
handleOpenChange={setIsDeleteDialogOpen}
isOpen={isDeleteDialogOpen}
itemName={flow?.title}
itemType="flow"
/>
</>
);
};
+15 -2
View File
@@ -2,7 +2,20 @@ import type { ColumnDef } from '@tanstack/react-table';
import { format, isToday } from 'date-fns';
import { enUS } from 'date-fns/locale';
import { ArrowDown, ArrowUp, Ellipsis, Eye, GitFork, Loader2, Pause, Pencil, Plus, Star, Trash } from 'lucide-react';
import {
ArrowDown,
ArrowUp,
Ellipsis,
Eye,
GitFork,
Loader2,
Pause,
Pencil,
PencilLine,
Plus,
Star,
Trash,
} from 'lucide-react';
import { Check, CheckCircle2, X, XCircle } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
@@ -602,7 +615,7 @@ const Flows = () => {
View
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleFlowRenameStart(flow)}>
<Pencil className="size-3" />
<PencilLine className="size-3" />
Rename
</DropdownMenuItem>
{isRunning && (