Task management

This commit is contained in:
Siddharth Ganesan
2026-03-03 12:00:03 -08:00
parent fe5ab8aee8
commit 7fafc00a07
13 changed files with 13218 additions and 40 deletions

View File

@@ -103,6 +103,7 @@ export async function POST(req: NextRequest) {
userId: authenticatedUserId,
workspaceId,
model: 'claude-opus-4-5',
type: 'mothership',
})
currentChat = chatResult.chat
actualChatId = chatResult.chatId || chatId

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, isNull } from 'drizzle-orm'
import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import {
@@ -40,7 +40,7 @@ export async function GET(request: NextRequest) {
and(
eq(copilotChats.userId, userId),
eq(copilotChats.workspaceId, workspaceId),
isNull(copilotChats.workflowId)
eq(copilotChats.type, 'mothership')
)
)
.orderBy(desc(copilotChats.updatedAt))
@@ -75,6 +75,7 @@ export async function POST(request: NextRequest) {
.values({
userId,
workspaceId,
type: 'mothership',
title: null,
model: 'claude-opus-4-5',
messages: [],

View File

@@ -16,18 +16,15 @@ export function Home({ chatId, streamId }: HomeProps = {}) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const router = useRouter()
const [inputValue, setInputValue] = useState('')
const { messages, isSending, sendMessage, stopGeneration, chatBottomRef } = useChat(
workspaceId,
chatId,
streamId
)
const { messages, isSending, currentChatId, sendMessage, stopGeneration, chatBottomRef } =
useChat(workspaceId, chatId, streamId)
const handleSubmit = useCallback(async () => {
const trimmed = inputValue.trim()
if (!trimmed) return
setInputValue('')
if (chatId) {
if (chatId || currentChatId) {
sendMessage(trimmed)
return
}
@@ -54,7 +51,7 @@ export function Home({ chatId, streamId }: HomeProps = {}) {
} catch {
setInputValue(trimmed)
}
}, [inputValue, chatId, sendMessage, workspaceId, router])
}, [inputValue, chatId, currentChatId, sendMessage, workspaceId, router])
const hasMessages = messages.length > 0

View File

@@ -21,6 +21,7 @@ export interface UseChatReturn {
messages: ChatMessage[]
isSending: boolean
error: string | null
currentChatId: string | undefined
sendMessage: (message: string) => Promise<void>
stopGeneration: () => void
chatBottomRef: React.RefObject<HTMLDivElement | null>
@@ -384,5 +385,13 @@ export function useChat(
setIsSending(false)
}, [])
return { messages, isSending, error, sendMessage, stopGeneration, chatBottomRef }
return {
messages,
isSending,
error,
currentChatId: chatIdRef.current,
sendMessage,
stopGeneration,
chatBottomRef,
}
}

View File

@@ -3,36 +3,15 @@
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
interface NavItemContextMenuProps {
/**
* Whether the context menu is open
*/
isOpen: boolean
/**
* Position of the context menu
*/
position: { x: number; y: number }
/**
* Ref for the menu element
*/
menuRef: React.RefObject<HTMLDivElement | null>
/**
* Callback when menu should close
*/
onClose: () => void
/**
* Callback when open in new tab is clicked
*/
onOpenInNewTab: () => void
/**
* Callback when copy link is clicked
*/
onCopyLink: () => void
onDelete?: () => void
}
/**
* Context menu component for sidebar navigation items.
* Displays navigation-appropriate options (open in new tab, copy link) in a popover at the right-click position.
*/
export function NavItemContextMenu({
isOpen,
position,
@@ -40,6 +19,7 @@ export function NavItemContextMenu({
onClose,
onOpenInNewTab,
onCopyLink,
onDelete,
}: NavItemContextMenuProps) {
return (
<Popover
@@ -75,6 +55,17 @@ export function NavItemContextMenu({
>
Copy link
</PopoverItem>
{onDelete && (
<PopoverItem
onClick={() => {
onDelete()
onClose()
}}
className='text-[var(--color-error)]'
>
Delete
</PopoverItem>
)}
</PopoverContent>
</Popover>
)

View File

@@ -46,7 +46,7 @@ import {
useImportWorkflow,
useImportWorkspace,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { useTasks } from '@/hooks/queries/tasks'
import { useDeleteTask, useTasks } from '@/hooks/queries/tasks'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { SIDEBAR_WIDTH } from '@/stores/constants'
import { useFolderStore } from '@/stores/folders/store'
@@ -183,6 +183,7 @@ export const Sidebar = memo(function Sidebar() {
})
const [activeNavItemHref, setActiveNavItemHref] = useState<string | null>(null)
const [activeTaskId, setActiveTaskId] = useState<string | null>(null)
const {
isOpen: isNavContextMenuOpen,
position: navContextMenuPosition,
@@ -191,9 +192,21 @@ export const Sidebar = memo(function Sidebar() {
closeMenu: closeNavContextMenu,
} = useContextMenu()
const deleteTaskMutation = useDeleteTask(workspaceId)
const handleNavItemContextMenu = useCallback(
(e: React.MouseEvent, href: string) => {
setActiveNavItemHref(href)
setActiveTaskId(null)
handleNavContextMenuBase(e)
},
[handleNavContextMenuBase]
)
const handleTaskContextMenu = useCallback(
(e: React.MouseEvent, href: string, taskId: string) => {
setActiveNavItemHref(href)
setActiveTaskId(taskId)
handleNavContextMenuBase(e)
},
[handleNavContextMenuBase]
@@ -202,6 +215,7 @@ export const Sidebar = memo(function Sidebar() {
const handleNavContextMenuClose = useCallback(() => {
closeNavContextMenu()
setActiveNavItemHref(null)
setActiveTaskId(null)
}, [closeNavContextMenu])
const handleNavOpenInNewTab = useCallback(() => {
@@ -221,6 +235,18 @@ export const Sidebar = memo(function Sidebar() {
}
}, [activeNavItemHref])
const handleDeleteTask = useCallback(() => {
if (!activeTaskId) return
const isViewingDeletedTask = pathname === `/workspace/${workspaceId}/task/${activeTaskId}`
deleteTaskMutation.mutate(activeTaskId, {
onSuccess: () => {
if (isViewingDeletedTask) {
router.push(`/workspace/${workspaceId}/home`)
}
},
})
}, [activeTaskId, pathname, workspaceId, deleteTaskMutation, router])
const { handleDuplicateWorkspace: duplicateWorkspace } = useDuplicateWorkspace({
workspaceId,
})
@@ -687,7 +713,7 @@ export const Sidebar = memo(function Sidebar() {
key={task.id}
href={task.href}
className={`mx-[2px] flex h-[28px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)] ${active ? 'bg-[var(--surface-6)] dark:bg-[var(--surface-5)]' : ''}`}
onContextMenu={(e) => handleNavItemContextMenu(e, task.href)}
onContextMenu={(e) => handleTaskContextMenu(e, task.href, task.id)}
>
<Blimp className={`h-[14px] w-[14px] flex-shrink-0 ${textColor}`} />
<div className={`min-w-0 truncate font-medium ${textColor}`}>{task.name}</div>
@@ -813,6 +839,7 @@ export const Sidebar = memo(function Sidebar() {
onClose={handleNavContextMenuClose}
onOpenInNewTab={handleNavOpenInNewTab}
onCopyLink={handleNavCopyLink}
onDelete={activeTaskId ? handleDeleteTask : undefined}
/>
</div>

View File

@@ -1,4 +1,4 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
export interface TaskMetadata {
id: string
@@ -105,3 +105,27 @@ export function useChatHistory(chatId: string | undefined) {
staleTime: 30 * 1000,
})
}
async function deleteTask(chatId: string): Promise<void> {
const response = await fetch('/api/copilot/chat/delete', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chatId }),
})
if (!response.ok) {
throw new Error('Failed to delete task')
}
}
/**
* Deletes a mothership chat task and invalidates the task list.
*/
export function useDeleteTask(workspaceId?: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteTask,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
},
})
}

View File

@@ -23,8 +23,9 @@ export async function resolveOrCreateChat(params: {
workflowId?: string
workspaceId?: string
model: string
type?: 'mothership' | 'copilot'
}): Promise<ChatLoadResult> {
const { chatId, userId, workflowId, workspaceId, model } = params
const { chatId, userId, workflowId, workspaceId, model, type } = params
if (chatId) {
const [chat] = await db
@@ -47,6 +48,7 @@ export async function resolveOrCreateChat(params: {
userId,
...(workflowId ? { workflowId } : {}),
...(workspaceId ? { workspaceId } : {}),
type: type ?? 'copilot',
title: null,
model,
messages: [],

View File

@@ -0,0 +1,7 @@
DO $$ BEGIN
CREATE TYPE "public"."chat_type" AS ENUM('mothership', 'copilot');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
ALTER TABLE "copilot_chats" ADD COLUMN "type" "public"."chat_type" DEFAULT 'copilot' NOT NULL;

View File

@@ -0,0 +1,2 @@
CREATE TYPE "public"."chat_type" AS ENUM('mothership', 'copilot');--> statement-breakpoint
ALTER TABLE "copilot_chats" ADD COLUMN "type" "chat_type" DEFAULT 'copilot' NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -1135,6 +1135,13 @@
"when": 1772482049606,
"tag": "0162_early_bloodscream",
"breakpoints": true
},
{
"idx": 163,
"version": "7",
"when": 1772567614060,
"tag": "0163_pink_gambit",
"breakpoints": true
}
]
}
}

View File

@@ -1489,6 +1489,8 @@ export const docsEmbeddings = pgTable(
})
)
export const chatTypeEnum = pgEnum('chat_type', ['mothership', 'copilot'])
export const copilotChats = pgTable(
'copilot_chats',
{
@@ -1498,13 +1500,14 @@ export const copilotChats = pgTable(
.references(() => user.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }),
type: chatTypeEnum('type').notNull().default('copilot'),
title: text('title'),
messages: jsonb('messages').notNull().default('[]'),
model: text('model').notNull().default('claude-3-7-sonnet-latest'),
conversationId: text('conversation_id'),
previewYaml: text('preview_yaml'), // YAML content for pending workflow preview
planArtifact: text('plan_artifact'), // Plan/design document artifact for the chat
config: jsonb('config'), // JSON config storing model and mode settings { model, mode }
previewYaml: text('preview_yaml'),
planArtifact: text('plan_artifact'),
config: jsonb('config'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},