diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts index bc694d1d9fe..eaac1a4001f 100644 --- a/apps/sim/app/api/mothership/chats/route.ts +++ b/apps/sim/app/api/mothership/chats/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, desc, eq } from 'drizzle-orm' +import { and, desc, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { @@ -41,6 +41,9 @@ export async function GET(request: NextRequest) { updatedAt: copilotChats.updatedAt, conversationId: copilotChats.conversationId, lastSeenAt: copilotChats.lastSeenAt, + messageCount: sql`jsonb_array_length(${copilotChats.messages})` + .mapWith(Number) + .as('message_count'), }) .from(copilotChats) .where( diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 132d87b2a9e..754b92ff524 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -142,6 +142,7 @@ export function Home({ chatId }: HomeProps = {}) { const { messages, + isHistoryReady, isSending, isReconnecting, sendMessage, @@ -317,7 +318,7 @@ export function Home({ chatId }: HomeProps = {}) { return () => ro.disconnect() }, [hasMessages]) - if (!hasMessages && !chatId) { + if (!hasMessages && isHistoryReady) { return (
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index ac5bf1ab167..dac0cf4d8d8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' -import { usePathname } from 'next/navigation' +import { useRouter } from 'next/navigation' import { cancelRunToolExecution, executeRunToolOnClient, @@ -65,6 +65,7 @@ import type { WorkflowMetadata } from '@/stores/workflows/registry/types' export interface UseChatReturn { messages: ChatMessage[] + isHistoryReady: boolean isSending: boolean isReconnecting: boolean error: string | null @@ -410,7 +411,7 @@ export function useChat( initialChatId?: string, options?: UseChatOptions ): UseChatReturn { - const pathname = usePathname() + const router = useRouter() const queryClient = useQueryClient() const [messages, setMessages] = useState([]) const [isSending, setIsSending] = useState(false) @@ -506,7 +507,6 @@ export function useChat( const streamingBlocksRef = useRef([]) const clientExecutionStartedRef = useRef>(new Set()) const executionStream = useExecutionStream() - const isHomePage = pathname.endsWith('/home') const { data: chatHistory } = useChatHistory(initialChatId) @@ -595,32 +595,6 @@ export function useChat( setPendingRecoveryMessage(null) }, [initialChatId, queryClient]) - useEffect(() => { - if (workflowIdRef.current) return - if (!isHomePage || !chatIdRef.current) return - streamGenRef.current++ - chatIdRef.current = undefined - setResolvedChatId(undefined) - appliedChatIdRef.current = undefined - abortControllerRef.current = null - sendingRef.current = false - setMessages([]) - setError(null) - setIsSending(false) - setIsReconnecting(false) - setResources([]) - setActiveResourceId(null) - setStreamingFile(null) - streamingFileRef.current = null - genericResourceDataRef.current = { entries: [] } - setGenericResourceData({ entries: [] }) - setMessageQueue([]) - lastEventIdRef.current = 0 - clientExecutionStartedRef.current.clear() - pendingRecoveryMessageRef.current = null - setPendingRecoveryMessage(null) - }, [isHomePage]) - const fetchStreamBatch = useCallback( async ( streamId: string, @@ -895,7 +869,10 @@ export function useChat( if (isNewChat) { applyChatHistorySnapshot(chatHistory, { preserveActiveStreamingMessage: true }) - } else if (!activeStreamId || sendingRef.current) { + } else if (sendingRef.current) { + return + } else if (!activeStreamId) { + applyChatHistorySnapshot(chatHistory) return } @@ -1119,11 +1096,9 @@ export function useChat( }) } if (!workflowIdRef.current) { - window.history.replaceState( - null, - '', - `/workspace/${workspaceId}/task/${parsed.chatId}` - ) + router.replace(`/workspace/${workspaceId}/task/${parsed.chatId}`) + abortControllerRef.current?.abort() + streamGenRef.current++ } } } @@ -2106,6 +2081,7 @@ export function useChat( [ workspaceId, queryClient, + router, processSSEStream, finalize, resumeOrFinalize, @@ -2282,8 +2258,11 @@ export function useChat( } }, []) + const isHistoryReady = !initialChatId || chatHistory !== undefined + return { messages, + isHistoryReady, isSending, isReconnecting, error, 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 aec38cbc1b3..24b24460e2a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -88,6 +88,7 @@ import { useFolderMap, useFolders } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' import { useTablesList } from '@/hooks/queries/tables' import { + useCreateTask, useDeleteTask, useDeleteTasks, useMarkTaskRead, @@ -197,7 +198,6 @@ const SidebarTaskItem = memo(function SidebarTaskItem({ (isCurrentRoute || isSelected || isMenuOpen) && 'bg-[var(--surface-active)]' )} onClick={(e) => { - if (task.id === 'new') return if (e.metaKey || e.ctrlKey) return if (e.shiftKey) { e.preventDefault() @@ -209,42 +209,40 @@ const SidebarTaskItem = memo(function SidebarTaskItem({ }) } }} - onContextMenu={task.id !== 'new' ? (e) => onContextMenu(e, task.id) : undefined} - draggable={task.id !== 'new'} - onDragStart={task.id !== 'new' ? handleDragStart : undefined} - onDragEnd={task.id !== 'new' ? handleDragEnd : undefined} + onContextMenu={(e) => onContextMenu(e, task.id)} + draggable + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} >
{task.name}
- {task.id !== 'new' && ( -
- {isActive && !isCurrentRoute && !isMenuOpen && ( - - )} - {isActive && !isCurrentRoute && !isMenuOpen && ( - - )} - {!isActive && isUnread && !isCurrentRoute && !isMenuOpen && ( - +
+ {isActive && !isCurrentRoute && !isMenuOpen && ( + + )} + {isActive && !isCurrentRoute && !isMenuOpen && ( + + )} + {!isActive && isUnread && !isCurrentRoute && !isMenuOpen && ( + + )} + -
- )} + > + + +
) @@ -524,6 +522,7 @@ export const Sidebar = memo(function Sidebar() { } }, [activeNavItemHref]) + const createTaskMutation = useCreateTask() const deleteTaskMutation = useDeleteTask(workspaceId) const deleteTasksMutation = useDeleteTasks(workspaceId) const markTaskReadMutation = useMarkTaskRead(workspaceId) @@ -727,20 +726,10 @@ export const Sidebar = memo(function Sidebar() { const tasks = useMemo( () => - fetchedTasks.length > 0 - ? fetchedTasks.map((t) => ({ - ...t, - href: `/workspace/${workspaceId}/task/${t.id}`, - })) - : [ - { - id: 'new', - name: 'New task', - href: `/workspace/${workspaceId}/home`, - isActive: false, - isUnread: false, - }, - ], + fetchedTasks.map((t) => ({ + ...t, + href: `/workspace/${workspaceId}/task/${t.id}`, + })), [fetchedTasks, workspaceId] ) @@ -784,7 +773,7 @@ export const Sidebar = memo(function Sidebar() { [fetchedKnowledgeBases, workspaceId, permissionConfig.hideKnowledgeBaseTab] ) - const taskIds = useMemo(() => tasks.map((t) => t.id).filter((id) => id !== 'new'), [tasks]) + const taskIds = useMemo(() => tasks.map((t) => t.id), [tasks]) const { selectedTasks, handleTaskClick } = useTaskSelection({ taskIds }) @@ -1085,12 +1074,27 @@ export const Sidebar = memo(function Sidebar() { [workflowIconStyle] ) + const handleNewTask = useCallback(async () => { + const existingEmpty = fetchedTasks.find((t) => t.isEmpty) + if (existingEmpty) { + router.push(`/workspace/${workspaceId}/task/${existingEmpty.id}`) + return + } + try { + const { id } = await createTaskMutation.mutateAsync({ workspaceId }) + router.push(`/workspace/${workspaceId}/task/${id}`) + } catch (err) { + logger.error('Failed to create task', err) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [router, workspaceId, fetchedTasks]) + const tasksPrimaryAction = useMemo( () => ({ label: 'New task', - onSelect: () => navigateToPage(`/workspace/${workspaceId}/home`), + onSelect: handleNewTask, }), - [navigateToPage, workspaceId] + [handleNewTask] ) const workflowsPrimaryAction = useMemo( @@ -1109,11 +1113,6 @@ export const Sidebar = memo(function Sidebar() { [toggleCollapsed] ) - const handleNewTask = useCallback( - () => navigateToPage(`/workspace/${workspaceId}/home`), - [navigateToPage, workspaceId] - ) - const handleSeeMoreTasks = useCallback(() => setVisibleTaskCount((prev) => prev + 5), []) const handleCloseTaskDeleteModal = useCallback(() => setIsTaskDeleteModalOpen(false), []) @@ -1428,7 +1427,7 @@ export const Sidebar = memo(function Sidebar() { {tasks.slice(0, visibleTaskCount).map((task) => { - const isCurrentRoute = task.id !== 'new' && pathname === task.href + const isCurrentRoute = pathname === task.href const isRenaming = taskFlyoutRename.editingId === task.id - const isSelected = task.id !== 'new' && selectedTasks.has(task.id) + const isSelected = selectedTasks.has(task.id) if (isRenaming) { return ( diff --git a/apps/sim/hooks/queries/tasks.ts b/apps/sim/hooks/queries/tasks.ts index 9cd1eab999a..a80adbace7d 100644 --- a/apps/sim/hooks/queries/tasks.ts +++ b/apps/sim/hooks/queries/tasks.ts @@ -7,6 +7,7 @@ export interface TaskMetadata { updatedAt: Date isActive: boolean isUnread: boolean + isEmpty: boolean } export interface StreamSnapshot { @@ -91,6 +92,7 @@ interface TaskResponse { updatedAt: string conversationId: string | null lastSeenAt: string | null + messageCount: number } function mapTask(chat: TaskResponse): TaskMetadata { @@ -103,6 +105,7 @@ function mapTask(chat: TaskResponse): TaskMetadata { isUnread: chat.conversationId === null && (chat.lastSeenAt === null || updatedAt > new Date(chat.lastSeenAt)), + isEmpty: chat.messageCount === 0, } } @@ -181,6 +184,39 @@ export function useChatHistory(chatId: string | undefined) { }) } +async function createTask(workspaceId: string): Promise<{ id: string }> { + const response = await fetch('/api/mothership/chats', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspaceId }), + }) + if (!response.ok) { + throw new Error('Failed to create task') + } + const { id } = (await response.json()) as { id: string } + return { id } +} + +/** + * Pre-warms the detail cache so navigation to the new task renders without a loading flash. + */ +export function useCreateTask() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ workspaceId }: { workspaceId: string }) => createTask(workspaceId), + onSuccess: ({ id }, { workspaceId }) => { + queryClient.setQueryData(taskKeys.detail(id), { + id, + title: null, + messages: [], + activeStreamId: null, + resources: [], + }) + queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) }) + }, + }) +} + async function deleteTask(chatId: string): Promise { const response = await fetch(`/api/mothership/chats/${chatId}`, { method: 'DELETE',