mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(tasks): add rename to task context menu (#3442)
This commit is contained in:
49
apps/sim/app/api/copilot/chat/rename/route.ts
Normal file
49
apps/sim/app/api/copilot/chat/rename/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user