From 364bd0a1303915b35a052291907d9f919c0a73e8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 6 Mar 2026 12:30:07 -0800 Subject: [PATCH] feat(tasks): add rename to task context menu --- apps/sim/app/api/copilot/chat/rename/route.ts | 49 ++++++++++++ .../nav-item-context-menu.tsx | 12 +++ .../w/components/sidebar/sidebar.tsx | 80 ++++++++++++++++++- apps/sim/hooks/queries/tasks.ts | 40 ++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 apps/sim/app/api/copilot/chat/rename/route.ts diff --git a/apps/sim/app/api/copilot/chat/rename/route.ts b/apps/sim/app/api/copilot/chat/rename/route.ts new file mode 100644 index 00000000000..48de430bed6 --- /dev/null +++ b/apps/sim/app/api/copilot/chat/rename/route.ts @@ -0,0 +1,49 @@ +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' + +const logger = createLogger('RenameChatAPI') + +const RenameChatSchema = z.object({ + chatId: z.string().min(1), + title: z.string().min(1).max(200), +}) + +export async function PATCH(request: NextRequest) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { chatId, title } = RenameChatSchema.parse(body) + + const [updated] = await db + .update(copilotChats) + .set({ title, updatedAt: new Date() }) + .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id))) + .returning({ id: copilotChats.id }) + + if (!updated) { + return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) + } + + logger.info('Chat renamed', { chatId, title }) + + return NextResponse.json({ success: true }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { success: false, error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + logger.error('Error renaming chat:', error) + return NextResponse.json({ success: false, error: 'Failed to rename chat' }, { status: 500 }) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/nav-item-context-menu/nav-item-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/nav-item-context-menu/nav-item-context-menu.tsx index b31ac144cd5..1c622b13e20 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/nav-item-context-menu/nav-item-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/nav-item-context-menu/nav-item-context-menu.tsx @@ -9,6 +9,7 @@ interface NavItemContextMenuProps { onClose: () => void onOpenInNewTab: () => void onCopyLink: () => void + onRename?: () => void onDelete?: () => void } @@ -19,6 +20,7 @@ export function NavItemContextMenu({ onClose, onOpenInNewTab, onCopyLink, + onRename, onDelete, }: NavItemContextMenuProps) { return ( @@ -55,6 +57,16 @@ export function NavItemContextMenu({ > Copy link + {onRename && ( + { + onRename() + onClose() + }} + > + Rename + + )} {onDelete && ( { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 02e09ac0750..d99d43e0262 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -55,7 +55,7 @@ import { useImportWorkflow, useImportWorkspace, } from '@/app/workspace/[workspaceId]/w/hooks' -import { useDeleteTask, useTasks } from '@/hooks/queries/tasks' +import { useDeleteTask, useRenameTask, useTasks } from '@/hooks/queries/tasks' import { usePermissionConfig } from '@/hooks/use-permission-config' import { SIDEBAR_WIDTH } from '@/stores/constants' import { useFolderStore } from '@/stores/folders/store' @@ -214,6 +214,7 @@ export const Sidebar = memo(function Sidebar() { } = useContextMenu() const deleteTaskMutation = useDeleteTask(workspaceId) + const renameTaskMutation = useRenameTask(workspaceId) const handleNavItemContextMenu = useCallback( (e: React.MouseEvent, href: string) => { @@ -389,6 +390,62 @@ export const Sidebar = memo(function Sidebar() { [fetchedTasks, workspaceId] ) + const [renamingTaskId, setRenamingTaskId] = useState(null) + const [renameValue, setRenameValue] = useState('') + const renameInputRef = useRef(null) + const renameCanceledRef = useRef(false) + + useEffect(() => { + if (renamingTaskId && renameInputRef.current) { + renameInputRef.current.focus() + renameInputRef.current.select() + } + }, [renamingTaskId]) + + const handleStartTaskRename = useCallback(() => { + if (!activeTaskId || activeTaskId === 'new') return + const task = tasks.find((t) => t.id === activeTaskId) + if (!task) return + renameCanceledRef.current = false + setRenamingTaskId(activeTaskId) + setRenameValue(task.name) + }, [activeTaskId, tasks]) + + const handleSaveTaskRename = useCallback(() => { + if (renameCanceledRef.current) { + renameCanceledRef.current = false + return + } + const trimmed = renameValue.trim() + if (!renamingTaskId || !trimmed) { + setRenamingTaskId(null) + return + } + const task = tasks.find((t) => t.id === renamingTaskId) + if (task && trimmed !== task.name) { + renameTaskMutation.mutate({ chatId: renamingTaskId, title: trimmed }) + } + setRenamingTaskId(null) + }, [renamingTaskId, renameValue, tasks, renameTaskMutation]) + + const handleCancelTaskRename = useCallback(() => { + renameCanceledRef.current = true + setRenamingTaskId(null) + }, []) + + const handleRenameKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + handleSaveTaskRename() + } else if (e.key === 'Escape') { + e.preventDefault() + handleCancelTaskRename() + } + }, + [handleSaveTaskRename, handleCancelTaskRename] + ) + const [hasOverflowBottom, setHasOverflowBottom] = useState(false) useEffect(() => { @@ -748,6 +805,26 @@ export const Sidebar = memo(function Sidebar() { const iconColor = active ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]' + const isRenaming = renamingTaskId === task.id + + if (isRenaming) { + return ( +
+ + setRenameValue(e.target.value)} + onKeyDown={handleRenameKeyDown} + onBlur={handleSaveTaskRename} + className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-primary)] outline-none' + /> +
+ ) + } return ( diff --git a/apps/sim/hooks/queries/tasks.ts b/apps/sim/hooks/queries/tasks.ts index 55d9625f0cd..051d0f4684e 100644 --- a/apps/sim/hooks/queries/tasks.ts +++ b/apps/sim/hooks/queries/tasks.ts @@ -129,3 +129,43 @@ export function useDeleteTask(workspaceId?: string) { }, }) } + +async function renameTask({ chatId, title }: { chatId: string; title: string }): Promise { + const response = await fetch('/api/copilot/chat/rename', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chatId, title }), + }) + if (!response.ok) { + throw new Error('Failed to rename task') + } +} + +/** + * Renames a mothership chat task with optimistic update. + */ +export function useRenameTask(workspaceId?: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: renameTask, + onMutate: async ({ chatId, title }) => { + await queryClient.cancelQueries({ queryKey: taskKeys.list(workspaceId) }) + + const previousTasks = queryClient.getQueryData(taskKeys.list(workspaceId)) + + queryClient.setQueryData(taskKeys.list(workspaceId), (old) => + old?.map((task) => (task.id === chatId ? { ...task, name: title } : task)) + ) + + return { previousTasks } + }, + onError: (_err, _variables, context) => { + if (context?.previousTasks) { + queryClient.setQueryData(taskKeys.list(workspaceId), context.previousTasks) + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) }) + }, + }) +}