feat(tasks): add rename to task context menu (#3442)

This commit is contained in:
Waleed
2026-03-06 12:49:32 -08:00
committed by GitHub
parent a0be3ff414
commit 82ba3d7dd1
4 changed files with 180 additions and 1 deletions

View File

@@ -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 })
}
}

View File

@@ -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
</PopoverItem>
{onRename && (
<PopoverItem
onClick={() => {
onRename()
onClose()
}}
>
Rename
</PopoverItem>
)}
{onDelete && (
<PopoverItem
onClick={() => {

View File

@@ -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<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const renameInputRef = useRef<HTMLInputElement>(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 (
<div
key={task.id}
className='mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] bg-[var(--surface-active)] px-[8px] text-[14px]'
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-primary)]' />
<input
ref={renameInputRef}
value={renameValue}
onChange={(e) => 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'
/>
</div>
)
}
return (
<Link
@@ -905,6 +982,7 @@ export const Sidebar = memo(function Sidebar() {
onClose={handleNavContextMenuClose}
onOpenInNewTab={handleNavOpenInNewTab}
onCopyLink={handleNavCopyLink}
onRename={activeTaskId && activeTaskId !== 'new' ? handleStartTaskRename : undefined}
onDelete={activeTaskId ? handleDeleteTask : undefined}
/>
</>

View File

@@ -129,3 +129,43 @@ export function useDeleteTask(workspaceId?: string) {
},
})
}
async function renameTask({ chatId, title }: { chatId: string; title: string }): Promise<void> {
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<TaskMetadata[]>(taskKeys.list(workspaceId))
queryClient.setQueryData<TaskMetadata[]>(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) })
},
})
}