Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions apps/sim/app/api/copilot/chat/rename/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface NavItemContextMenuProps {
onClose: () => void
onOpenInNewTab: () => void
onCopyLink: () => void
onRename?: () => void
onDelete?: () => void
}

Expand All @@ -19,6 +20,7 @@ export function NavItemContextMenu({
onClose,
onOpenInNewTab,
onCopyLink,
onRename,
onDelete,
}: NavItemContextMenuProps) {
return (
Expand Down Expand Up @@ -55,6 +57,16 @@ export function NavItemContextMenu({
>
Copy link
</PopoverItem>
{onRename && (
<PopoverItem
onClick={() => {
onRename()
onClose()
}}
>
Rename
</PopoverItem>
)}
{onDelete && (
<PopoverItem
onClick={() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
/>
</>
Expand Down
40 changes: 40 additions & 0 deletions apps/sim/hooks/queries/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) })
},
})
}