From b97a0c10a5217d868fa542a4aee5887065846701 Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Wed, 13 May 2026 14:18:59 +0700 Subject: [PATCH 01/54] feat(header): collapse page header buttons to icons on mobile Add `HeaderButton` component that renders icon + label on >=768px and collapses to an icon-only square on narrower viewports, with the `aria-label` auto-derived from the label so the mobile state stays accessible. Apply it to the action buttons in Flows, Flow, Knowledges, Templates, Resources and the Knowledge save button so mobile page headers stop overflowing. Co-authored-by: Cursor --- .../src/components/shared/header-button.tsx | 37 +++++++++++++++++++ .../features/knowledges/knowledge-form.tsx | 20 +++++----- frontend/src/pages/flows/flow.tsx | 12 +++--- frontend/src/pages/flows/flows.tsx | 11 +++--- frontend/src/pages/knowledges/knowledges.tsx | 11 +++--- frontend/src/pages/resources/resources.tsx | 22 +++++------ frontend/src/pages/templates/templates.tsx | 11 +++--- 7 files changed, 79 insertions(+), 45 deletions(-) create mode 100644 frontend/src/components/shared/header-button.tsx diff --git a/frontend/src/components/shared/header-button.tsx b/frontend/src/components/shared/header-button.tsx new file mode 100644 index 00000000..402b111b --- /dev/null +++ b/frontend/src/components/shared/header-button.tsx @@ -0,0 +1,37 @@ +import { forwardRef, type ReactNode } from 'react'; + +import { Button, type ButtonProps } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +interface HeaderButtonProps extends Omit { + endIcon?: ReactNode; + icon: ReactNode; + label: ReactNode; +} + +// Action button rendered inside a page header. Collapses to an icon-only square +// on viewports narrower than the `md` breakpoint (matches `useBreakpoint`'s +// `mobile` threshold of 768px) and expands to icon + label (and optional +// trailing icon, e.g. a dropdown chevron) on wider screens. `aria-label` is +// auto-derived from `label` when it's a plain string so the icon-only mobile +// state stays accessible without the caller having to remember it. +export const HeaderButton = forwardRef( + ({ 'aria-label': ariaLabel, className, endIcon, icon, label, size = 'sm', ...props }, ref) => { + const accessibleLabel = ariaLabel ?? (typeof label === 'string' ? label : undefined); + + return ( + + ); + }, +); +HeaderButton.displayName = 'HeaderButton'; diff --git a/frontend/src/features/knowledges/knowledge-form.tsx b/frontend/src/features/knowledges/knowledge-form.tsx index 3243c245..a367cd03 100644 --- a/frontend/src/features/knowledges/knowledge-form.tsx +++ b/frontend/src/features/knowledges/knowledge-form.tsx @@ -11,8 +11,8 @@ import type { UpdateKnowledgeDocumentInput, } from '@/graphql/types'; +import { HeaderButton } from '@/components/shared/header-button'; import { UnsavedChangesDialog } from '@/components/shared/unsaved-changes-dialog'; -import { Button } from '@/components/ui/button'; import { Form } from '@/components/ui/form'; import { Spinner } from '@/components/ui/spinner'; import { KnowledgeAnswerType, KnowledgeDocType, KnowledgeGuideType } from '@/graphql/types'; @@ -59,7 +59,9 @@ export const formSchema = z .string() .trim() .min(1, { message: 'Content is required' }) - .max(KNOWLEDGE_LIMITS.content, { message: `Content must be ${KNOWLEDGE_LIMITS.content} characters or fewer` }), + .max(KNOWLEDGE_LIMITS.content, { + message: `Content must be ${KNOWLEDGE_LIMITS.content} characters or fewer`, + }), description: optionalTrimmed(KNOWLEDGE_LIMITS.description, 'Description'), docType: z.nativeEnum(KnowledgeDocType), guideType: z.nativeEnum(KnowledgeGuideType).optional(), @@ -67,7 +69,9 @@ export const formSchema = z .string() .trim() .min(1, { message: 'Question is required' }) - .max(KNOWLEDGE_LIMITS.question, { message: `Question must be ${KNOWLEDGE_LIMITS.question} characters or fewer` }), + .max(KNOWLEDGE_LIMITS.question, { + message: `Question must be ${KNOWLEDGE_LIMITS.question} characters or fewer`, + }), }) .superRefine((value, ctx) => { const requiredByDocType: Partial; message: string }>> = @@ -313,14 +317,12 @@ export const KnowledgeForm = ({ initialValues, isNew, knowledge, knowledgeName, const canSubmit = !isSaving && isValid && (isNew || isDirty); const saveButton = ( - + /> ); return ( diff --git a/frontend/src/pages/flows/flow.tsx b/frontend/src/pages/flows/flow.tsx index 715da82d..056e42a6 100644 --- a/frontend/src/pages/flows/flow.tsx +++ b/frontend/src/pages/flows/flow.tsx @@ -5,6 +5,7 @@ import { toast } from 'sonner'; import { FlowStatusIcon } from '@/components/icons/flow-status-icon'; import { ProviderIcon } from '@/components/icons/provider-icon'; +import { HeaderButton } from '@/components/shared/header-button'; import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb'; import { Button } from '@/components/ui/button'; import { @@ -94,15 +95,14 @@ const FlowReportDropdown = () => { return ( - + /> {
- + />
); diff --git a/frontend/src/pages/knowledges/knowledges.tsx b/frontend/src/pages/knowledges/knowledges.tsx index c4aa3537..0730fd31 100644 --- a/frontend/src/pages/knowledges/knowledges.tsx +++ b/frontend/src/pages/knowledges/knowledges.tsx @@ -7,6 +7,7 @@ import { useNavigate } from 'react-router-dom'; import type { BadgeVariant } from '@/components/ui/badge'; import ConfirmationDialog from '@/components/shared/confirmation-dialog'; +import { HeaderButton } from '@/components/shared/header-button'; import { Badge } from '@/components/ui/badge'; import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb'; import { Button } from '@/components/ui/button'; @@ -311,14 +312,12 @@ const Knowledges = () => {
- + />
); diff --git a/frontend/src/pages/resources/resources.tsx b/frontend/src/pages/resources/resources.tsx index fe7c6296..e83449a6 100644 --- a/frontend/src/pages/resources/resources.tsx +++ b/frontend/src/pages/resources/resources.tsx @@ -23,6 +23,7 @@ import { formatModifiedAbsolute, formatModifiedRelative, } from '@/components/shared/file-manager'; +import { HeaderButton } from '@/components/shared/header-button'; import { OverwriteConfirmDialog } from '@/components/shared/overwrite-confirm-dialog'; import { useOverwriteAction } from '@/components/shared/use-overwrite-action'; import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb'; @@ -397,24 +398,21 @@ const Resources = () => {
- - + />
); diff --git a/frontend/src/pages/templates/templates.tsx b/frontend/src/pages/templates/templates.tsx index dd89f2f3..867bbd02 100644 --- a/frontend/src/pages/templates/templates.tsx +++ b/frontend/src/pages/templates/templates.tsx @@ -5,6 +5,7 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; 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'; import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu'; @@ -195,14 +196,12 @@ const Templates = () => {
- + />
); From 465ea406f44a565e2c0754810f2c35d8618e3170 Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Wed, 13 May 2026 20:52:35 +0700 Subject: [PATCH 02/54] 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 --- frontend/src/pages/flows/flow.tsx | 274 ++++++++++++++++++++++++++++- frontend/src/pages/flows/flows.tsx | 17 +- 2 files changed, 281 insertions(+), 10 deletions(-) diff --git a/frontend/src/pages/flows/flow.tsx b/frontend/src/pages/flows/flow.tsx index 056e42a6..1d7e12be 100644 --- a/frontend/src/pages/flows/flow.tsx +++ b/frontend/src/pages/flows/flow.tsx @@ -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(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('terminal'); @@ -189,20 +319,74 @@ const Flow = () => { - {flowData?.flow && ( + {flow && ( <> )} - {flowData?.flow?.title || 'Select a flow'} + {isEditingTitle && flow ? ( + + { + if (event.key === 'Enter') { + event.preventDefault(); + handleFlowRenameSave(); + + return; + } + + if (event.key === 'Escape') { + event.preventDefault(); + handleFlowRenameCancel(); + } + }} + placeholder="Flow title" + ref={editingInputRef} + /> + + handleFlowRenameSave()} + > + {isRenameLoading ? : } + + handleFlowRenameCancel()} + > + + + + + ) : flow ? ( + + + + {flowTitle || 'Select a flow'} + + + Double-click to rename + + ) : ( + {flowTitle || 'Select a flow'} + )} @@ -219,6 +403,71 @@ const Flow = () => { )} {!!(flowData?.tasks ?? [])?.length && } + {flow && ( + + + + + { + // 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(); + } + }} + > + + + Rename + + {isFlowRunning && ( + handleFlowFinish()} + > + {isFinishing ? ( + <> + + Finishing... + + ) : ( + <> + + Finish + + )} + + )} + + setIsDeleteDialogOpen(true)} + > + {isDeleting ? ( + <> + + Deleting... + + ) : ( + <> + + Delete + + )} + + + + )} @@ -257,6 +506,15 @@ const Flow = () => { tabsCard )} + ); }; diff --git a/frontend/src/pages/flows/flows.tsx b/frontend/src/pages/flows/flows.tsx index 71cd9320..a48e2a69 100644 --- a/frontend/src/pages/flows/flows.tsx +++ b/frontend/src/pages/flows/flows.tsx @@ -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
handleFlowRenameStart(flow)}> - + Rename {isRunning && ( From bce4d79069622aacbb4180d7720cb2318063cf0b Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Wed, 13 May 2026 20:52:42 +0700 Subject: [PATCH 03/54] feat(templates): inline rename and delete actions in template header and list Adds an actions dropdown to the template detail header with inline rename (double-click or "Rename") and delete with a confirmation dialog. The listing learns the same: a "Rename" item in the row dropdown and context menu opens an in-row editor, and clicking the row no longer navigates while a rename is in progress. Co-authored-by: Cursor --- frontend/src/pages/templates/template.tsx | 259 +++++++++++++++++++-- frontend/src/pages/templates/templates.tsx | 149 +++++++++++- 2 files changed, 384 insertions(+), 24 deletions(-) diff --git a/frontend/src/pages/templates/template.tsx b/frontend/src/pages/templates/template.tsx index d4156cc6..4e02da4d 100644 --- a/frontend/src/pages/templates/template.tsx +++ b/frontend/src/pages/templates/template.tsx @@ -1,8 +1,21 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import { ChevronDown, FileSymlink, PanelRightClose, PanelRightOpen, Save } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Check, + ChevronDown, + Ellipsis, + FileSymlink, + Loader2, + PanelRightClose, + PanelRightOpen, + Pencil, + Save, + Trash, + X, +} from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { useNavigate, useParams } from 'react-router-dom'; +import { toast } from 'sonner'; import { z } from 'zod'; import ConfirmationDialog from '@/components/shared/confirmation-dialog'; @@ -10,13 +23,27 @@ import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/co import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Form, FormControl, FormField, FormItem } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; -import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextareaAutosize } from '@/components/ui/input-group'; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, + InputGroupTextareaAutosize, +} from '@/components/ui/input-group'; import { Separator } from '@/components/ui/separator'; import { Sheet, SheetContent } from '@/components/ui/sheet'; import { SidebarTrigger } from '@/components/ui/sidebar'; import { Spinner } from '@/components/ui/spinner'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useFlowTemplateQuery } from '@/graphql/types'; import { useBreakpoint } from '@/hooks/use-breakpoint'; import { cn } from '@/lib/utils'; @@ -209,7 +236,7 @@ Action plan: const Template = () => { const navigate = useNavigate(); const { templateId } = useParams<{ templateId?: string }>(); - const { createTemplate, updateTemplate } = useTemplates(); + const { createTemplate, deleteTemplate, updateTemplate } = useTemplates(); const { isMobile } = useBreakpoint(); const isNew = templateId === 'new'; @@ -218,6 +245,11 @@ const Template = () => { const [isReplaceConfirmOpen, setIsReplaceConfirmOpen] = useState(false); const [isSaving, setIsSaving] = useState(false); const [pendingPreset, setPendingPreset] = useState(null); + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [isRenaming, setIsRenaming] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const editingInputRef = useRef(null); // Fetch template data when editing const { data: templateData, loading: isLoadingTemplate } = useFlowTemplateQuery({ @@ -243,10 +275,96 @@ const Template = () => { reset({ text, title }, { keepDefaultValues: false }); }, [templateData, isNew, reset]); + // Reset inline-edit state when navigating between templates so the input + // doesn't carry over a stale draft from a previous template. + useEffect(() => { + setIsEditingTitle(false); + }, [templateId]); + + // 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]); + // Check if form has unsaved changes const hasUnsavedChanges = formState.isDirty; const templateName = templateData?.flowTemplate?.title ?? null; + const handleTemplateRenameStart = useCallback(() => { + setIsEditingTitle(true); + }, []); + + const handleTemplateRenameCancel = useCallback(() => { + setIsEditingTitle(false); + }, []); + + const handleTemplateRenameSave = useCallback(async () => { + const newTitle = editingInputRef.current?.value.trim(); + const template = templateData?.flowTemplate; + + if (!templateId || !newTitle || !template) { + return; + } + + if (newTitle === template.title) { + setIsEditingTitle(false); + + return; + } + + setIsRenaming(true); + + try { + // Preserve the original `text` from the server so that an inline + // rename never overwrites unsaved edits in the form below. + await updateTemplate(templateId, { text: template.text, title: newTitle }); + toast.success('Template renamed successfully'); + setIsEditingTitle(false); + } catch { + // Error already handled in provider with toast + } finally { + setIsRenaming(false); + } + }, [templateId, templateData?.flowTemplate, updateTemplate]); + + const handleTemplateDelete = useCallback(async () => { + if (!templateId) { + return; + } + + setIsDeleting(true); + + try { + await deleteTemplate(templateId); + navigate('/templates', { replace: true }); + } catch { + // Error already handled in provider with toast + } finally { + setIsDeleting(false); + } + }, [templateId, deleteTemplate, navigate]); + const handleSubmit = async (values: FormValues) => { if (isSaving) { return; @@ -304,6 +422,8 @@ const Template = () => { } }, [pendingPreset, setValue]); + const canShowActions = !isNew && !!templateData?.flowTemplate; + const pageHeader = (
@@ -313,19 +433,121 @@ const Template = () => { /> - - {isNew ? 'New template' : (templateName ?? 'Template')} + + {isEditingTitle && canShowActions ? ( + + { + if (event.key === 'Enter') { + event.preventDefault(); + handleTemplateRenameSave(); + + return; + } + + if (event.key === 'Escape') { + event.preventDefault(); + handleTemplateRenameCancel(); + } + }} + placeholder="Template title" + ref={editingInputRef} + /> + + handleTemplateRenameSave()} + > + {isRenaming ? : } + + handleTemplateRenameCancel()} + > + + + + + ) : canShowActions ? ( + + + + {templateName ?? 'Template'} + + + Double-click to rename + + ) : ( + {isNew ? 'New template' : (templateName ?? 'Template')} + )} - +
+ + {canShowActions && ( + + + + + { + // Radix returns focus to the trigger on close. When the + // selected action mounts the rename input, prevent that + // restore so our deferred focus actually wins. + if (isEditingTitle) { + event.preventDefault(); + } + }} + > + + + Rename + + + setIsDeleteDialogOpen(true)} + > + {isDeleting ? ( + <> + + Deleting... + + ) : ( + <> + + Delete + + )} + + + + )} +
); @@ -544,6 +766,15 @@ const Template = () => { isOpen={isReplaceConfirmOpen} title="Replace content?" /> + ); }; diff --git a/frontend/src/pages/templates/templates.tsx b/frontend/src/pages/templates/templates.tsx index 867bbd02..00d109ed 100644 --- a/frontend/src/pages/templates/templates.tsx +++ b/frontend/src/pages/templates/templates.tsx @@ -1,8 +1,21 @@ import type { ColumnDef } from '@tanstack/react-table'; -import { ArrowDown, ArrowUp, Ellipsis, FileText, Loader2, Pencil, Plus, Trash } from 'lucide-react'; -import { useState } from 'react'; +import { + ArrowDown, + ArrowUp, + Check, + Ellipsis, + FileText, + Loader2, + Pencil, + PencilLine, + Plus, + Trash, + X, +} from 'lucide-react'; +import { useCallback, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { toast } from 'sonner'; import ConfirmationDialog from '@/components/shared/confirmation-dialog'; import { HeaderButton } from '@/components/shared/header-button'; @@ -14,8 +27,10 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group'; import { Separator } from '@/components/ui/separator'; import { SidebarTrigger } from '@/components/ui/sidebar'; import { StatusCard } from '@/components/ui/status-card'; @@ -24,19 +39,65 @@ import { type Template, useTemplates } from '@/providers/templates-provider'; const Templates = () => { const navigate = useNavigate(); - const { deleteTemplate, templates } = useTemplates(); + const { deleteTemplate, templates, updateTemplate } = useTemplates(); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [deletingTemplate, setDeletingTemplate] = useState(null); const [deletingIds, setDeletingIds] = useState>(new Set()); + const [editingTemplateId, setEditingTemplateId] = useState(null); + const [isRenameLoading, setIsRenameLoading] = useState(false); + const editingInputRef = useRef(null); - const handleTemplateOpen = (templateId: string) => { - navigate(`/templates/${templateId}`); - }; + const handleTemplateOpen = useCallback( + (templateId: string) => { + navigate(`/templates/${templateId}`); + }, + [navigate], + ); - const handleDeleteDialogOpen = (template: Template) => { + const handleDeleteDialogOpen = useCallback((template: Template) => { setDeletingTemplate(template); setIsDeleteDialogOpen(true); - }; + }, []); + + const handleTemplateRenameStart = useCallback((template: Template) => { + setEditingTemplateId(template.id); + }, []); + + const handleTemplateRenameCancel = useCallback(() => { + setEditingTemplateId(null); + }, []); + + const handleTemplateRenameSave = useCallback(async () => { + const newTitle = editingInputRef.current?.value.trim(); + + if (!editingTemplateId || !newTitle) { + return; + } + + const template = templates.find((t) => t.id === editingTemplateId); + + if (!template) { + return; + } + + if (newTitle === template.title) { + setEditingTemplateId(null); + + return; + } + + setIsRenameLoading(true); + + try { + await updateTemplate(editingTemplateId, { text: template.text, title: newTitle }); + toast.success('Template renamed successfully'); + setEditingTemplateId(null); + } catch { + // Error already handled in provider with toast + } finally { + setIsRenameLoading(false); + } + }, [editingTemplateId, templates, updateTemplate]); const handleDelete = async () => { if (!deletingTemplate) { @@ -63,7 +124,60 @@ const Templates = () => { const columns: ColumnDef