refactor(mothership-chats): rename task feature to chat, move route, add redirect (#4910)

* refactor(mothership-chats): rename task feature to chat, move route, add redirect

- Move workspace route /task/[taskId] -> /chat/[chatId] and add a permanent
  redirect in next.config so existing bookmarks/deeplinks keep working
- Rename client hooks queries/tasks.ts -> queries/mothership-chats.ts with the
  mothership-chat-prefixed family (distinct from the existing deployed-chat
  hooks in queries/chats.ts to avoid query-key collisions)
- Rename contract mothership-tasks.ts -> mothership-chats.ts (MothershipChat)
- Rename lib/copilot/tasks.ts -> chat-status.ts (chatPubSub, ChatStatusEvent)
- Rename use-task-events -> use-mothership-chat-events, use-task-selection ->
  use-chat-selection, and folder-store chat-selection methods
- Update server-generated deeplinks (oauth callback, inbox response, inbox list)
- Preserve wire/persisted/analytics values that cross process or deploy
  boundaries: Redis channel task:status_changed, SSE event task_status,
  MothershipResourceType 'task', posthog event names, drag/itemType
  discriminants. Unrelated scheduled-tasks and email-inbox tasks untouched

* refactor(mothership-chats): use skipToken in useMothershipChatHistory

Drop the enabled + chatId! non-null assertion in favor of the skipToken
pattern, matching useMothershipChats. Behavior-preserving.
This commit is contained in:
Waleed
2026-06-08 13:16:32 -07:00
committed by GitHub
parent 2c7b1ca54d
commit d526b23841
57 changed files with 804 additions and 745 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ import { settings, user } from '@sim/db/schema'
import { getErrorMessage } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { adminMothershipQuerySchema } from '@/lib/api/contracts/mothership-tasks'
import { adminMothershipQuerySchema } from '@/lib/api/contracts/mothership-chats'
import { mothershipEnvironmentSchema } from '@/lib/api/contracts/user'
import { searchParamsToObject, validationErrorResponse } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
@@ -19,8 +19,8 @@ vi.mock('@/lib/copilot/chat/lifecycle', () => ({
getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChatAuth,
}))
vi.mock('@/lib/copilot/tasks', () => ({
taskPubSub: { publishStatusChanged: vi.fn() },
vi.mock('@/lib/copilot/chat-status', () => ({
chatPubSub: { publishStatusChanged: vi.fn() },
}))
import { DELETE } from './route'
@@ -7,7 +7,7 @@ import { deleteCopilotChatContract } from '@/lib/api/contracts/copilot'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle'
import { taskPubSub } from '@/lib/copilot/tasks'
import { chatPubSub } from '@/lib/copilot/chat-status'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('DeleteChatAPI')
@@ -47,7 +47,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => {
logger.info('Chat deleted', { chatId: parsed.chatId })
if (deleted.workspaceId) {
taskPubSub?.publishStatusChanged({
chatPubSub?.publishStatusChanged({
workspaceId: deleted.workspaceId,
chatId: parsed.chatId,
type: 'deleted',
@@ -7,7 +7,7 @@ import { renameCopilotChatContract } from '@/lib/api/contracts/copilot'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle'
import { taskPubSub } from '@/lib/copilot/tasks'
import { chatPubSub } from '@/lib/copilot/chat-status'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('RenameChatAPI')
@@ -49,7 +49,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest) => {
logger.info('Chat renamed', { chatId, title })
if (updated.workspaceId) {
taskPubSub?.publishStatusChanged({
chatPubSub?.publishStatusChanged({
workspaceId: updated.workspaceId,
chatId,
type: 'renamed',
@@ -16,8 +16,8 @@ vi.mock('@/lib/copilot/chat/messages-store', () => ({
appendCopilotChatMessages: mockAppendCopilotChatMessages,
}))
vi.mock('@/lib/copilot/tasks', () => ({
taskPubSub: {
vi.mock('@/lib/copilot/chat-status', () => ({
chatPubSub: {
publishStatusChanged: mockPublishStatusChanged,
},
}))
+2 -2
View File
@@ -10,6 +10,7 @@ import {
withStoppedContentBlock,
} from '@/lib/copilot/chat/persisted-message'
import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state'
import { chatPubSub } from '@/lib/copilot/chat-status'
import {
CopilotChatFinalizeOutcome,
CopilotStopOutcome,
@@ -17,7 +18,6 @@ import {
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { withIncomingGoSpan } from '@/lib/copilot/request/otel'
import { taskPubSub } from '@/lib/copilot/tasks'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('CopilotChatStopAPI')
@@ -83,7 +83,7 @@ export const POST = withRouteHandler((req: NextRequest) =>
result.updated || result.outcome === CopilotChatFinalizeOutcome.AssistantAlreadyPersisted
if (shouldPublishCompleted && result.workspaceId) {
taskPubSub?.publishStatusChanged({
chatPubSub?.publishStatusChanged({
workspaceId: result.workspaceId,
chatId,
type: 'completed',
+2 -2
View File
@@ -7,6 +7,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { createWorkflowCopilotChatContract } from '@/lib/api/contracts/copilot'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle'
import { chatPubSub } from '@/lib/copilot/chat-status'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
@@ -14,7 +15,6 @@ import {
createInternalServerErrorResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import { taskPubSub } from '@/lib/copilot/tasks'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
assertActiveWorkspaceAccess,
@@ -138,7 +138,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return createInternalServerErrorResponse('Failed to create chat')
}
taskPubSub?.publishStatusChanged({ workspaceId, chatId: result.chatId, type: 'created' })
chatPubSub?.publishStatusChanged({ workspaceId, chatId: result.chatId, type: 'created' })
return NextResponse.json({ success: true, id: result.chatId })
} catch (error) {
@@ -1,5 +1,5 @@
import type { NextRequest } from 'next/server'
import { mothershipChatAbortEnvelopeSchema } from '@/lib/api/contracts/mothership-tasks'
import { mothershipChatAbortEnvelopeSchema } from '@/lib/api/contracts/mothership-chats'
import { validationErrorResponse } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { POST as copilotAbortPost } from '@/app/api/copilot/chat/abort/route'
@@ -1,5 +1,5 @@
import type { NextRequest, NextResponse } from 'next/server'
import { mothershipChatResourceEnvelopeSchema } from '@/lib/api/contracts/mothership-tasks'
import { mothershipChatResourceEnvelopeSchema } from '@/lib/api/contracts/mothership-chats'
import { validationErrorResponse } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
+1 -1
View File
@@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import {
mothershipChatGetQuerySchema,
mothershipChatPostEnvelopeSchema,
} from '@/lib/api/contracts/mothership-tasks'
} from '@/lib/api/contracts/mothership-chats'
import { validationErrorResponse } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { handleUnifiedChatPost, maxDuration } from '@/lib/copilot/chat/post'
@@ -1,5 +1,5 @@
import type { NextRequest } from 'next/server'
import { mothershipChatStopEnvelopeSchema } from '@/lib/api/contracts/mothership-tasks'
import { mothershipChatStopEnvelopeSchema } from '@/lib/api/contracts/mothership-chats'
import { validationErrorResponse } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { POST as copilotStopPost } from '@/app/api/copilot/chat/stop/route'
@@ -1,5 +1,5 @@
import type { NextRequest } from 'next/server'
import { mothershipChatStreamQuerySchema } from '@/lib/api/contracts/mothership-tasks'
import { mothershipChatStreamQuerySchema } from '@/lib/api/contracts/mothership-chats'
import { validationErrorResponse } from '@/lib/api/server'
import { GET as copilotStreamGet, maxDuration } from '@/app/api/copilot/chat/stream/route'
@@ -4,10 +4,11 @@ import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { forkMothershipChatContract } from '@/lib/api/contracts/mothership-tasks'
import { forkMothershipChatContract } from '@/lib/api/contracts/mothership-chats'
import { parseRequest } from '@/lib/api/server'
import { loadCopilotChatMessages } from '@/lib/copilot/chat/lifecycle'
import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store'
import { chatPubSub } from '@/lib/copilot/chat-status'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
import {
authenticateCopilotRequestSessionOnly,
@@ -19,7 +20,6 @@ import {
} from '@/lib/copilot/request/http'
import type { MothershipResource } from '@/lib/copilot/resources/types'
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
import { taskPubSub } from '@/lib/copilot/tasks'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
@@ -154,7 +154,7 @@ export const POST = withRouteHandler(
}
if (newChat.workspaceId) {
taskPubSub?.publishStatusChanged({
chatPubSub?.publishStatusChanged({
workspaceId: newChat.workspaceId,
chatId: newId,
type: 'created',
@@ -79,8 +79,8 @@ vi.mock('@/lib/copilot/chat/persisted-message', () => ({
normalizeMessage: (m: unknown) => m,
}))
vi.mock('@/lib/copilot/tasks', () => ({
taskPubSub: { publishStatusChanged: vi.fn() },
vi.mock('@/lib/copilot/chat-status', () => ({
chatPubSub: { publishStatusChanged: vi.fn() },
}))
vi.mock('@/lib/posthog/server', () => ({
@@ -8,7 +8,7 @@ import {
deleteMothershipChatContract,
getMothershipChatContract,
updateMothershipChatContract,
} from '@/lib/api/contracts/mothership-tasks'
} from '@/lib/api/contracts/mothership-chats'
import { parseRequest } from '@/lib/api/server'
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
import { buildEffectiveChatTranscript } from '@/lib/copilot/chat/effective-transcript'
@@ -18,6 +18,7 @@ import {
} from '@/lib/copilot/chat/lifecycle'
import { normalizeMessage } from '@/lib/copilot/chat/persisted-message'
import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness'
import { chatPubSub } from '@/lib/copilot/chat-status'
import {
authenticateCopilotRequestSessionOnly,
createInternalServerErrorResponse,
@@ -27,7 +28,6 @@ import type { FilePreviewSession } from '@/lib/copilot/request/session'
import { readEvents } from '@/lib/copilot/request/session/buffer'
import { readFilePreviewSessions } from '@/lib/copilot/request/session/file-preview-session'
import { type StreamBatchEvent, toStreamBatchEvent } from '@/lib/copilot/request/session/types'
import { taskPubSub } from '@/lib/copilot/tasks'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
@@ -185,7 +185,7 @@ export const PATCH = withRouteHandler(
if (updatedChat.workspaceId) {
if (title !== undefined) {
taskPubSub?.publishStatusChanged({
chatPubSub?.publishStatusChanged({
workspaceId: updatedChat.workspaceId,
chatId,
type: 'renamed',
@@ -264,7 +264,7 @@ export const DELETE = withRouteHandler(
}
if (deletedChat.workspaceId) {
taskPubSub?.publishStatusChanged({
chatPubSub?.publishStatusChanged({
workspaceId: deletedChat.workspaceId,
chatId,
type: 'deleted',
@@ -3,7 +3,7 @@ import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { markMothershipChatReadContract } from '@/lib/api/contracts/mothership-tasks'
import { markMothershipChatReadContract } from '@/lib/api/contracts/mothership-chats'
import { parseRequest } from '@/lib/api/server'
import {
authenticateCopilotRequestSessionOnly,
@@ -47,8 +47,8 @@ vi.mock('@/lib/copilot/chat/stream-liveness', () => ({
reconcileChatStreamMarkers: mockReconcileChatStreamMarkers,
}))
vi.mock('@/lib/copilot/tasks', () => ({
taskPubSub: { publishStatusChanged: vi.fn() },
vi.mock('@/lib/copilot/chat-status', () => ({
chatPubSub: { publishStatusChanged: vi.fn() },
}))
vi.mock('@/lib/posthog/server', () => ({
+3 -3
View File
@@ -6,16 +6,16 @@ import { type NextRequest, NextResponse } from 'next/server'
import {
createMothershipChatContract,
listMothershipChatsContract,
} from '@/lib/api/contracts/mothership-tasks'
} from '@/lib/api/contracts/mothership-chats'
import { parseRequest } from '@/lib/api/server'
import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness'
import { chatPubSub } from '@/lib/copilot/chat-status'
import {
authenticateCopilotRequestSessionOnly,
createForbiddenResponse,
createInternalServerErrorResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import { taskPubSub } from '@/lib/copilot/tasks'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import {
@@ -111,7 +111,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
.returning({ id: copilotChats.id })
taskPubSub?.publishStatusChanged({ workspaceId, chatId: chat.id, type: 'created' })
chatPubSub?.publishStatusChanged({ workspaceId, chatId: chat.id, type: 'created' })
captureServerEvent(
userId,
+4 -4
View File
@@ -8,9 +8,9 @@
*/
import type { NextRequest } from 'next/server'
import { mothershipEventsQuerySchema } from '@/lib/api/contracts/mothership-tasks'
import { mothershipEventsQuerySchema } from '@/lib/api/contracts/mothership-chats'
import { validationErrorResponse } from '@/lib/api/server'
import { taskPubSub } from '@/lib/copilot/tasks'
import { chatPubSub } from '@/lib/copilot/chat-status'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createWorkspaceSSE } from '@/lib/events/sse-endpoint'
@@ -21,8 +21,8 @@ const mothershipEventsHandler = createWorkspaceSSE({
subscriptions: [
{
subscribe: (workspaceId, send) => {
if (!taskPubSub) return () => {}
return taskPubSub.onStatusChanged((event) => {
if (!chatPubSub) return () => {}
return chatPubSub.onStatusChanged((event) => {
if (event.workspaceId !== workspaceId) return
send('task_status', {
chatId: event.chatId,
+1 -1
View File
@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { mothershipExecuteContract } from '@/lib/api/contracts/mothership-tasks'
import { mothershipExecuteContract } from '@/lib/api/contracts/mothership-chats'
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload'
@@ -1,3 +1,3 @@
export default function TaskLayout({ children }: { children: React.ReactNode }) {
export default function ChatLayout({ children }: { children: React.ReactNode }) {
return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
}
@@ -6,24 +6,24 @@ export const metadata: Metadata = {
title: 'Chat',
}
interface TaskPageProps {
interface ChatPageProps {
params: Promise<{
workspaceId: string
taskId: string
chatId: string
}>
searchParams: Promise<{ resource?: string }>
}
export default async function TaskPage({ params, searchParams }: TaskPageProps) {
const [{ taskId }, { resource }, session] = await Promise.all([
export default async function ChatPage({ params, searchParams }: ChatPageProps) {
const [{ chatId }, { resource }, session] = await Promise.all([
params,
searchParams,
getSession(),
])
return (
<Home
key={taskId}
chatId={taskId}
key={chatId}
chatId={chatId}
userName={session?.user?.name}
userId={session?.user?.id}
initialResourceId={resource ?? null}
@@ -19,7 +19,7 @@ import {
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
import { useForkTask } from '@/hooks/queries/tasks'
import { useForkMothershipChat } from '@/hooks/queries/mothership-chats'
import { useFolderStore } from '@/stores/folders/store'
const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file'
@@ -72,7 +72,7 @@ export const MessageActions = memo(function MessageActions({
const resetTimeoutRef = useRef<number | null>(null)
const requestIdTimeoutRef = useRef<number | null>(null)
const submitFeedback = useSubmitCopilotFeedback()
const forkTask = useForkTask(params.workspaceId)
const forkChat = useForkMothershipChat(params.workspaceId)
useEffect(() => {
return () => {
@@ -151,11 +151,11 @@ export const MessageActions = memo(function MessageActions({
}
const handleFork = async () => {
if (!chatId || !messageId || forkTask.isPending) return
if (!chatId || !messageId || forkChat.isPending) return
try {
const result = await forkTask.mutateAsync({ chatId, upToMessageId: messageId })
useFolderStore.getState().clearTaskSelection()
router.push(`/workspace/${params.workspaceId}/task/${result.id}`)
const result = await forkChat.mutateAsync({ chatId, upToMessageId: messageId })
useFolderStore.getState().clearChatSelection()
router.push(`/workspace/${params.workspaceId}/chat/${result.id}`)
} catch {
toast.error('Failed to fork chat')
}
@@ -223,8 +223,8 @@ export const MessageActions = memo(function MessageActions({
type='button'
aria-label='Fork from here'
onClick={handleFork}
disabled={forkTask.isPending}
className={cn(BUTTON_CLASS, forkTask.isPending && 'cursor-not-allowed opacity-50')}
disabled={forkChat.isPending}
className={cn(BUTTON_CLASS, forkChat.isPending && 'cursor-not-allowed opacity-50')}
>
<GitBranch className={ICON_CLASS} />
</button>
@@ -29,8 +29,8 @@ import { listIntegrations } from '@/blocks/integration-matcher'
import { useFolders } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useLogsList } from '@/hooks/queries/logs'
import { useMothershipChats } from '@/hooks/queries/mothership-chats'
import { useTablesList } from '@/hooks/queries/tables'
import { useTasks } from '@/hooks/queries/tasks'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
@@ -76,7 +76,7 @@ export function useAvailableResources(
const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId)
const { data: folders = [] } = useFolders(workspaceId)
const { data: fileFolders = [] } = useWorkspaceFileFolders(workspaceId)
const { data: tasks = [] } = useTasks(workspaceId)
const { data: tasks = [] } = useMothershipChats(workspaceId)
const { data: logsData } = useLogsList(workspaceId, LOG_DROPDOWN_FILTERS)
const logs = useMemo(() => (logsData?.pages ?? []).flatMap((page) => page.logs), [logsData])
@@ -22,8 +22,8 @@ import type {
import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color'
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
import { logKeys } from '@/hooks/queries/logs'
import { mothershipChatKeys } from '@/hooks/queries/mothership-chats'
import { tableKeys } from '@/hooks/queries/tables'
import { taskKeys } from '@/hooks/queries/tasks'
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders'
@@ -239,7 +239,7 @@ const RESOURCE_INVALIDATORS: Record<
qc.invalidateQueries({ queryKey: workspaceFileFolderKeys.workspaceLists(wId) })
},
task: (qc, wId) => {
qc.invalidateQueries({ queryKey: taskKeys.list(wId) })
qc.invalidateQueries({ queryKey: mothershipChatKeys.list(wId) })
},
log: (qc, _wId, id) => {
qc.invalidateQueries({ queryKey: logKeys.details() })
@@ -29,12 +29,12 @@ import type {
} from '@/app/workspace/[workspaceId]/home/types'
import { useFolders } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useTablesList } from '@/hooks/queries/tables'
import {
useAddChatResource,
useRemoveChatResource,
useReorderChatResources,
} from '@/hooks/queries/tasks'
} from '@/hooks/queries/mothership-chats'
import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
@@ -19,7 +19,10 @@ import {
} from '@/lib/mothership/events'
import { captureEvent } from '@/lib/posthog/client'
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
import {
useMarkMothershipChatRead,
useMothershipChatHistory,
} from '@/hooks/queries/mothership-chats'
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
import type { ChatContext } from '@/stores/panel'
import {
@@ -113,8 +116,8 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom
const wasSendingRef = useRef(false)
const { isPending: isChatHistoryPending } = useChatHistory(chatId)
const { mutate: markRead } = useMarkTaskRead(workspaceId)
const { isPending: isChatHistoryPending } = useMothershipChatHistory(chatId)
const { mutate: markRead } = useMarkMothershipChatRead(workspaceId)
const { mothershipRef, handleResizePointerDown, clearWidth } = useMothershipResize()
@@ -10,7 +10,7 @@ import {
addMothershipChatResourceContract,
removeMothershipChatResourceContract,
reorderMothershipChatResourcesContract,
} from '@/lib/api/contracts/mothership-tasks'
} from '@/lib/api/contracts/mothership-chats'
import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows'
import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview'
import { toDisplayMessage } from '@/lib/copilot/chat/display-message'
@@ -118,11 +118,11 @@ import {
} from '@/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions'
import { deploymentKeys } from '@/hooks/queries/deployments'
import {
fetchChatHistory,
type TaskChatHistory,
taskKeys,
useChatHistory,
} from '@/hooks/queries/tasks'
fetchMothershipChatHistory,
type MothershipChatHistory,
mothershipChatKeys,
useMothershipChatHistory,
} from '@/hooks/queries/mothership-chats'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
import { invalidateWorkflowSelectors } from '@/hooks/queries/utils/invalidate-workflow-lists'
@@ -1095,7 +1095,7 @@ function markMessageStopped(message: PersistedMessage): PersistedMessage {
})
}
function buildChatHistoryHydrationKey(chatHistory: TaskChatHistory): string {
function buildChatHistoryHydrationKey(chatHistory: MothershipChatHistory): string {
const resourceKey = chatHistory.resources
.map((resource) => `${resource.type}:${resource.id}:${resource.title}`)
.join('|')
@@ -1604,18 +1604,21 @@ export function useChat(
[queryClient, workspaceId]
)
const upsertTaskChatHistory = useCallback(
(chatId: string, updater: (current: TaskChatHistory) => TaskChatHistory) => {
queryClient.setQueryData<TaskChatHistory>(taskKeys.detail(chatId), (current) => {
const base: TaskChatHistory = current ?? {
id: chatId,
title: null,
messages: [],
activeStreamId: null,
resources: resourcesRef.current,
const upsertChatHistory = useCallback(
(chatId: string, updater: (current: MothershipChatHistory) => MothershipChatHistory) => {
queryClient.setQueryData<MothershipChatHistory>(
mothershipChatKeys.detail(chatId),
(current) => {
const base: MothershipChatHistory = current ?? {
id: chatId,
title: null,
messages: [],
activeStreamId: null,
resources: resourcesRef.current,
}
return updater(base)
}
return updater(base)
})
)
},
[queryClient]
)
@@ -1851,7 +1854,7 @@ export function useChat(
const abortControllerRef = useRef<AbortController | null>(null)
const streamReaderRef = useRef<ReadableStreamDefaultReader<Uint8Array> | null>(null)
const chatIdRef = useRef<string | undefined>(initialChatId)
/** Panel/task selection — drives createNewChat + request chatId; may differ from chatIdRef while a stream is still finishing. */
/** Panel/chat selection — drives createNewChat + request chatId; may differ from chatIdRef while a stream is still finishing. */
const selectedChatIdRef = useRef<string | undefined>(initialChatId)
selectedChatIdRef.current = initialChatId
const appliedChatHistoryKeyRef = useRef<string | undefined>(undefined)
@@ -1923,12 +1926,14 @@ export function useChat(
streamId: string,
assistantId: string,
afterCursor: string,
options?: { targetChatId?: string; chatHistory?: TaskChatHistory }
options?: { targetChatId?: string; chatHistory?: MothershipChatHistory }
): ReconnectReplaySelection => {
const cachedHistory =
options?.chatHistory ??
(options?.targetChatId
? queryClient.getQueryData<TaskChatHistory>(taskKeys.detail(options.targetChatId))
? queryClient.getQueryData<MothershipChatHistory>(
mothershipChatKeys.detail(options.targetChatId)
)
: undefined)
const cachedLiveAssistant = cachedHistory?.messages.find(
(message) => message.id === assistantId
@@ -2065,17 +2070,17 @@ export function useChat(
!workflowIdRef.current &&
typeof window !== 'undefined'
) {
window.history.replaceState(null, '', `/workspace/${workspaceId}/task/${chatId}`)
window.history.replaceState(null, '', `/workspace/${workspaceId}/chat/${chatId}`)
}
if (options?.invalidateList) {
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
}
flushPendingResources(chatId)
},
[flushPendingResources, queryClient, workspaceId]
)
const { data: chatHistory } = useChatHistory(resolvedChatId)
const { data: chatHistory } = useMothershipChatHistory(resolvedChatId)
const messages = useMemo(() => {
const source = chatHistory?.messages.map(toDisplayMessage) ?? pendingMessages
return source.map((m) => restoreRevealedSimKeysForMessage(m, revealedSimKeysRef.current))
@@ -2290,7 +2295,7 @@ export function useChat(
clearActiveTurn()
setTransportIdle()
if (abandonedChatId) {
queryClient.invalidateQueries({ queryKey: taskKeys.detail(abandonedChatId) })
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(abandonedChatId) })
}
} else {
setResolvedChatId(initialChatId)
@@ -2750,7 +2755,7 @@ export function useChat(
contentBlocks: blocks,
...(streamRequestId ? { requestId: streamRequestId } : {}),
})
upsertTaskChatHistory(activeChatId, (current) => {
upsertChatHistory(activeChatId, (current) => {
const streamId = streamIdRef.current ?? current.activeStreamId ?? assistantId
const terminalPersistedAssistantExists =
current.activeStreamId !== streamId &&
@@ -2877,7 +2882,7 @@ export function useChat(
setResolvedChatId(payloadChatId)
}
queryClient.invalidateQueries({
queryKey: taskKeys.list(workspaceId),
queryKey: mothershipChatKeys.list(workspaceId),
})
if (isNewChat) {
const userMsg = pendingUserMsgRef.current
@@ -2891,27 +2896,30 @@ export function useChat(
contentBlocks: streamingBlocksRef.current,
})
const seededMessages = [userMsg, assistantMessage]
queryClient.setQueryData<TaskChatHistory>(taskKeys.detail(payloadChatId), {
id: payloadChatId,
title: null,
messages: seededMessages,
activeStreamId,
resources: resourcesRef.current,
})
queryClient.setQueryData<MothershipChatHistory>(
mothershipChatKeys.detail(payloadChatId),
{
id: payloadChatId,
title: null,
messages: seededMessages,
activeStreamId,
resources: resourcesRef.current,
}
)
}
setPendingMessages([])
if (!workflowIdRef.current) {
window.history.replaceState(
null,
'',
`/workspace/${workspaceId}/task/${payloadChatId}`
`/workspace/${workspaceId}/chat/${payloadChatId}`
)
}
}
}
if (payload.kind === MothershipStreamV1SessionKind.title) {
queryClient.invalidateQueries({
queryKey: taskKeys.list(workspaceId),
queryKey: mothershipChatKeys.list(workspaceId),
})
onTitleUpdateRef.current?.()
}
@@ -3641,7 +3649,7 @@ export function useChat(
workspaceId,
router,
queryClient,
upsertTaskChatHistory,
upsertChatHistory,
addResource,
removeResource,
applyPreviewSessionUpdate,
@@ -3657,16 +3665,18 @@ export function useChat(
chatId: string,
signal?: AbortSignal
): Promise<{ loaded: boolean; streamId: string | null }> => {
const cached = queryClient.getQueryData<TaskChatHistory>(taskKeys.detail(chatId))
const cached = queryClient.getQueryData<MothershipChatHistory>(
mothershipChatKeys.detail(chatId)
)
try {
const fetchSignal = combineAbortSignals(
signal,
createTimeoutSignal(CHAT_HISTORY_RECOVERY_TIMEOUT_MS)
)
const history = await fetchChatHistory(chatId, fetchSignal)
const history = await fetchMothershipChatHistory(chatId, fetchSignal)
if (signal?.aborted || fetchSignal?.aborted) return { loaded: false, streamId: null }
queryClient.setQueryData(taskKeys.detail(chatId), history)
queryClient.setQueryData(mothershipChatKeys.detail(chatId), history)
return { loaded: true, streamId: history.activeStreamId ?? null }
} catch (error) {
logger.warn('Failed to load chat history while recovering stream', {
@@ -4185,7 +4195,9 @@ export function useChat(
selectedChatIdRef.current === startingSelectedChatId &&
!recoveryController.signal.aborted
const cached = queryClient.getQueryData<TaskChatHistory>(taskKeys.detail(chatId))
const cached = queryClient.getQueryData<MothershipChatHistory>(
mothershipChatKeys.detail(chatId)
)
const fallbackStreamId =
streamIdRef.current ?? activeTurnRef.current?.userMessageId ?? cached?.activeStreamId
const loadedStream = await getActiveStreamIdForChat(chatId, recoveryController.signal)
@@ -4428,10 +4440,10 @@ export function useChat(
const activeChatId = options?.targetChatId ?? chatIdRef.current
if (options?.includeDetail !== false && activeChatId) {
queryClient.invalidateQueries({
queryKey: taskKeys.detail(activeChatId),
queryKey: mothershipChatKeys.detail(activeChatId),
})
}
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
},
[workspaceId, queryClient]
)
@@ -4475,7 +4487,8 @@ export function useChat(
const id = generateId()
const handoffChatId = selectedChatIdRef.current ?? chatIdRef.current
const cachedActiveStreamId = handoffChatId
? queryClient.getQueryData<TaskChatHistory>(taskKeys.detail(handoffChatId))?.activeStreamId
? queryClient.getQueryData<MothershipChatHistory>(mothershipChatKeys.detail(handoffChatId))
?.activeStreamId
: undefined
const supersededStreamId =
streamIdRef.current ||
@@ -4631,7 +4644,7 @@ export function useChat(
}
if (requestChatId) {
await queryClient.cancelQueries({ queryKey: taskKeys.detail(requestChatId) })
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.detail(requestChatId) })
}
const applyOptimisticSend = () => {
@@ -4641,7 +4654,7 @@ export function useChat(
contentBlocks: [],
})
if (requestChatId) {
upsertTaskChatHistory(requestChatId, (current) => ({
upsertChatHistory(requestChatId, (current) => ({
...current,
resources: current.resources.filter((resource) => resource.id !== 'streaming-file'),
messages: [
@@ -4664,7 +4677,7 @@ export function useChat(
const rollbackOptimisticSend = () => {
if (requestChatId) {
upsertTaskChatHistory(requestChatId, (current) => ({
upsertChatHistory(requestChatId, (current) => ({
...current,
messages: current.messages.filter(
(persistedMessage) =>
@@ -4725,7 +4738,9 @@ export function useChat(
throw new Error('Queued message was restored because the selected chat changed.')
}
if (requestChatId) {
await queryClient.cancelQueries({ queryKey: taskKeys.detail(requestChatId) })
await queryClient.cancelQueries({
queryKey: mothershipChatKeys.detail(requestChatId),
})
}
applyOptimisticSend()
} catch (err) {
@@ -4921,7 +4936,7 @@ export function useChat(
[
workspaceId,
queryClient,
upsertTaskChatHistory,
upsertChatHistory,
processSSEStream,
finalize,
resumeOrFinalize,
@@ -5237,7 +5252,8 @@ export function useChat(
streamIdRef.current ||
activeTurnRef.current?.userMessageId ||
(activeChatId
? queryClient.getQueryData<TaskChatHistory>(taskKeys.detail(activeChatId))?.activeStreamId
? queryClient.getQueryData<MothershipChatHistory>(mothershipChatKeys.detail(activeChatId))
?.activeStreamId
: undefined) ||
undefined
@@ -5283,8 +5299,8 @@ export function useChat(
try {
if (activeChatId) {
await queryClient.cancelQueries({ queryKey: taskKeys.detail(activeChatId) })
upsertTaskChatHistory(activeChatId, (current) => ({
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.detail(activeChatId) })
upsertChatHistory(activeChatId, (current) => ({
...current,
messages: current.messages.map((message) =>
activeAssistantMessageId && message.id === activeAssistantMessageId
@@ -5482,7 +5498,7 @@ export function useChat(
queryClient,
resolveChatIdForStream,
resetEphemeralPreviewState,
upsertTaskChatHistory,
upsertChatHistory,
adoptResolvedChatId,
clearActiveTurn,
setTransportIdle,
@@ -5653,8 +5669,9 @@ export function useChat(
? (() => {
const handoffChatId = selectedChatIdRef.current ?? chatIdRef.current
const cachedActiveStreamId = handoffChatId
? queryClient.getQueryData<TaskChatHistory>(taskKeys.detail(handoffChatId))
?.activeStreamId
? queryClient.getQueryData<MothershipChatHistory>(
mothershipChatKeys.detail(handoffChatId)
)?.activeStreamId
: undefined
return {
id: msg.id,
@@ -58,7 +58,7 @@ export function InboxTaskList() {
const handleTaskClick = useCallback(
(task: InboxTaskItem) => {
if (task.chatId && (task.status === 'completed' || task.status === 'failed')) {
router.push(`/workspace/${workspaceId}/task/${task.chatId}`)
router.push(`/workspace/${workspaceId}/chat/${task.chatId}`)
}
},
[workspaceId, router]
@@ -130,8 +130,8 @@ interface CollapsedSidebarMenuProps {
}
}
interface CollapsedTaskFlyoutItemProps {
task: { id: string; href: string; name: string; isActive?: boolean; isUnread?: boolean }
interface CollapsedChatFlyoutItemProps {
chat: { id: string; href: string; name: string; isActive?: boolean; isUnread?: boolean }
isCurrentRoute: boolean
isMenuOpen?: boolean
isEditing?: boolean
@@ -141,9 +141,9 @@ interface CollapsedTaskFlyoutItemProps {
onEditValueChange?: (value: string) => void
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
onEditBlur?: () => void
onContextMenu?: (e: ReactMouseEvent, taskId: string) => void
onContextMenu?: (e: ReactMouseEvent, chatId: string) => void
onMorePointerDown?: () => void
onMoreClick?: (e: ReactMouseEvent<HTMLButtonElement>, taskId: string) => void
onMoreClick?: (e: ReactMouseEvent<HTMLButtonElement>, chatId: string) => void
}
interface CollapsedWorkflowFlyoutItemProps {
@@ -213,8 +213,8 @@ export function CollapsedSidebarMenu({
)
}
export function CollapsedTaskFlyoutItem({
task,
export function CollapsedChatFlyoutItem({
chat,
isCurrentRoute,
isMenuOpen = false,
isEditing = false,
@@ -227,16 +227,16 @@ export function CollapsedTaskFlyoutItem({
onContextMenu,
onMorePointerDown,
onMoreClick,
}: CollapsedTaskFlyoutItemProps) {
const showActions = task.id !== 'new' && onMoreClick
}: CollapsedChatFlyoutItemProps) {
const showActions = chat.id !== 'new' && onMoreClick
if (isEditing) {
return (
<div className={EDIT_ROW_CLASS}>
<input
aria-label={`Rename chat ${task.name}`}
aria-label={`Rename chat ${chat.name}`}
ref={inputRef}
value={editValue ?? task.name}
value={editValue ?? chat.name}
onChange={(e) => onEditValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={onEditBlur}
@@ -265,7 +265,7 @@ export function CollapsedTaskFlyoutItem({
<DropdownMenuItemAction
aria-label='Chat options'
onPointerDown={onMorePointerDown}
onClick={(e) => onMoreClick?.(e, task.id)}
onClick={(e) => onMoreClick?.(e, chat.id)}
className={cn(isMenuOpen && 'opacity-100')}
>
<MoreHorizontal />
@@ -274,15 +274,15 @@ export function CollapsedTaskFlyoutItem({
}
>
<Link
href={task.href}
href={chat.href}
onContextMenu={
task.id !== 'new' && onContextMenu ? (e) => onContextMenu(e, task.id) : undefined
chat.id !== 'new' && onContextMenu ? (e) => onContextMenu(e, chat.id) : undefined
}
>
<ConversationListItem
title={task.name}
isActive={!!task.isActive}
isUnread={!!task.isUnread}
title={chat.name}
isActive={!!chat.isActive}
isUnread={!!chat.isUnread}
/>
</Link>
</DropdownMenuItem>
@@ -1,7 +1,7 @@
export {
CollapsedChatFlyoutItem,
CollapsedFileFolderItems,
CollapsedFolderItems,
CollapsedSidebarMenu,
CollapsedTaskFlyoutItem,
CollapsedWorkflowFlyoutItem,
} from './collapsed-sidebar-menu'
@@ -1,8 +1,8 @@
export {
CollapsedChatFlyoutItem,
CollapsedFileFolderItems,
CollapsedFolderItems,
CollapsedSidebarMenu,
CollapsedTaskFlyoutItem,
CollapsedWorkflowFlyoutItem,
} from './collapsed-sidebar-menu'
export { FileList } from './file-list'
@@ -9,6 +9,7 @@ export {
} from './command-items'
export {
BlocksGroup,
ChatsGroup,
ConnectedAccountsGroup,
DocsGroup,
FilesGroup,
@@ -16,7 +17,6 @@ export {
KnowledgeBasesGroup,
PagesGroup,
TablesGroup,
TasksGroup,
ToolOpsGroup,
ToolsGroup,
TriggersGroup,
@@ -1,5 +1,6 @@
export {
BlocksGroup,
ChatsGroup,
ConnectedAccountsGroup,
DocsGroup,
FilesGroup,
@@ -7,7 +8,6 @@ export {
KnowledgeBasesGroup,
PagesGroup,
TablesGroup,
TasksGroup,
ToolOpsGroup,
ToolsGroup,
TriggersGroup,
@@ -182,7 +182,7 @@ export const WorkflowsGroup = memo(function WorkflowsGroup({
)
})
export const TasksGroup = memo(function TasksGroup({
export const ChatsGroup = memo(function ChatsGroup({
items,
onSelect,
}: {
@@ -35,6 +35,7 @@ import type {
} from '@/stores/modals/search/types'
import {
BlocksGroup,
ChatsGroup,
ConnectedAccountsGroup,
DocsGroup,
FilesGroup,
@@ -42,7 +43,6 @@ import {
KnowledgeBasesGroup,
PagesGroup,
TablesGroup,
TasksGroup,
ToolOpsGroup,
ToolsGroup,
TriggersGroup,
@@ -67,7 +67,7 @@ export function SearchModal({
onOpenChange,
workflows = [],
workspaces = [],
tasks = [],
chats = [],
tables = [],
files = [],
knowledgeBases = [],
@@ -298,9 +298,9 @@ export function SearchModal({
[workspaceId]
)
const handleTaskSelect = useCallback(
(task: TaskItem) => {
routerRef.current.push(task.href)
const handleChatSelect = useCallback(
(chat: TaskItem) => {
routerRef.current.push(chat.href)
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'task',
query_length: deferredSearchRef.current.length,
@@ -481,9 +481,9 @@ export function SearchModal({
() => filterAndSort(workflows, (w) => `${w.name} workflow-${w.id}`, deferredSearch),
[workflows, deferredSearch]
)
const filteredTasks = useMemo(
() => filterAndSort(tasks, (t) => `${t.name} task-${t.id}`, deferredSearch),
[tasks, deferredSearch]
const filteredChats = useMemo(
() => filterAndSort(chats, (t) => `${t.name} task-${t.id}`, deferredSearch),
[chats, deferredSearch]
)
const filteredWorkspaces = useMemo(
() => filterAndSort(workspaces, (w) => `${w.name} workspace-${w.id}`, deferredSearch),
@@ -569,7 +569,7 @@ export function SearchModal({
<BlocksGroup items={filteredBlocks} onSelect={handleBlockSelectAsBlock} />
<ToolsGroup items={filteredTools} onSelect={handleBlockSelectAsTool} />
<TriggersGroup items={filteredTriggers} onSelect={handleBlockSelectAsTrigger} />
<TasksGroup items={filteredTasks} onSelect={handleTaskSelect} />
<ChatsGroup items={filteredChats} onSelect={handleChatSelect} />
<WorkflowsGroup items={filteredWorkflows} onSelect={handleWorkflowSelect} />
<TablesGroup items={filteredTables} onSelect={handleTableSelect} />
<FilesGroup items={filteredFiles} onSelect={handleFileSelect} />
@@ -51,7 +51,7 @@ export interface SearchModalProps {
onOpenChange: (open: boolean) => void
workflows?: WorkflowItem[]
workspaces?: WorkspaceItem[]
tasks?: TaskItem[]
chats?: TaskItem[]
tables?: TaskItem[]
files?: FileItem[]
knowledgeBases?: TaskItem[]
@@ -1,4 +1,5 @@
export { useAutoScroll } from './use-auto-scroll'
export { useChatSelection } from './use-chat-selection'
export { useContextMenu } from './use-context-menu'
export { type DropIndicator, useDragDrop } from './use-drag-drop'
export { useFlyoutInlineRename } from './use-flyout-inline-rename'
@@ -14,7 +15,6 @@ export {
useSidebarDragContextValue,
} from './use-sidebar-drag-context'
export { useSidebarResize } from './use-sidebar-resize'
export { useTaskSelection } from './use-task-selection'
export { useWorkflowOperations } from './use-workflow-operations'
export { useWorkflowSelection } from './use-workflow-selection'
export { useWorkspaceLogoUpload } from './use-workspace-logo-upload'
@@ -0,0 +1,44 @@
import { useCallback } from 'react'
import { useFolderStore } from '@/stores/folders/store'
interface UseChatSelectionProps {
/**
* Flat array of all chat IDs in display order
*/
chatIds: string[]
}
/**
* Hook for managing chat selection with support for single and range selection.
* Handles shift-click for range selection.
* cmd/ctrl+click is handled by the browser (opens in new tab) and never reaches this handler.
* Uses the last selected chat as the anchor point for range selections.
* Selecting chats clears workflow/folder selections and vice versa.
*/
export function useChatSelection({ chatIds }: UseChatSelectionProps) {
const selectedChats = useFolderStore((s) => s.selectedChats)
const handleChatClick = useCallback(
(chatId: string, shiftKey: boolean) => {
const {
selectChatOnly,
selectChatRange,
toggleChatSelection,
lastSelectedChatId: anchor,
} = useFolderStore.getState()
if (shiftKey && anchor && anchor !== chatId) {
selectChatRange(chatIds, anchor, chatId)
} else if (shiftKey) {
toggleChatSelection(chatId)
} else {
selectChatOnly(chatId)
}
},
[chatIds]
)
return {
selectedChats,
handleChatClick,
}
}
@@ -1,44 +0,0 @@
import { useCallback } from 'react'
import { useFolderStore } from '@/stores/folders/store'
interface UseTaskSelectionProps {
/**
* Flat array of all task IDs in display order
*/
taskIds: string[]
}
/**
* Hook for managing task selection with support for single and range selection.
* Handles shift-click for range selection.
* cmd/ctrl+click is handled by the browser (opens in new tab) and never reaches this handler.
* Uses the last selected task as the anchor point for range selections.
* Selecting tasks clears workflow/folder selections and vice versa.
*/
export function useTaskSelection({ taskIds }: UseTaskSelectionProps) {
const selectedTasks = useFolderStore((s) => s.selectedTasks)
const handleTaskClick = useCallback(
(taskId: string, shiftKey: boolean) => {
const {
selectTaskOnly,
selectTaskRange,
toggleTaskSelection,
lastSelectedTaskId: anchor,
} = useFolderStore.getState()
if (shiftKey && anchor && anchor !== taskId) {
selectTaskRange(taskIds, anchor, taskId)
} else if (shiftKey) {
toggleTaskSelection(taskId)
} else {
selectTaskOnly(taskId)
}
},
[taskIds]
)
return {
selectedTasks,
handleTaskClick,
}
}
@@ -48,9 +48,9 @@ import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/provide
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import {
CollapsedChatFlyoutItem,
CollapsedFolderItems,
CollapsedSidebarMenu,
CollapsedTaskFlyoutItem,
CollapsedWorkflowFlyoutItem,
HelpModal,
NavItemContextMenu,
@@ -70,12 +70,12 @@ import {
SIDEBAR_SECTION_GAP_CLASS,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
import {
useChatSelection,
useContextMenu,
useFlyoutInlineRename,
useFolderOperations,
useHoverMenu,
useSidebarResize,
useTaskSelection,
useWorkflowOperations,
useWorkspaceLogoUpload,
useWorkspaceManagement,
@@ -89,23 +89,23 @@ import { useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
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,
useMarkTaskUnread,
useRenameTask,
useSetTaskPinned,
useTasks,
} from '@/hooks/queries/tasks'
useCreateMothershipChat,
useDeleteMothershipChat,
useDeleteMothershipChats,
useMarkMothershipChatRead,
useMarkMothershipChatUnread,
useMothershipChats,
useRenameMothershipChat,
useSetMothershipChatPinned,
} from '@/hooks/queries/mothership-chats'
import { useTablesList } from '@/hooks/queries/tables'
import { useUpdateWorkflow } from '@/hooks/queries/workflows'
import type { Workspace } from '@/hooks/queries/workspace'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { useMothershipChatEvents } from '@/hooks/use-mothership-chat-events'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useTaskEvents } from '@/hooks/use-task-events'
import { SIDEBAR_WIDTH } from '@/stores/constants'
import { useFolderStore } from '@/stores/folders/store'
import { useSearchModalStore } from '@/stores/modals/search/store'
@@ -145,8 +145,8 @@ function SidebarItemSkeleton() {
)
}
const SidebarTaskItem = memo(function SidebarTaskItem({
task,
const SidebarChatItem = memo(function SidebarChatItem({
chat,
isCurrentRoute,
isSelected,
isActive,
@@ -159,7 +159,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
onMorePointerDown,
onMoreClick,
}: {
task: { id: string; href: string; name: string }
chat: { id: string; href: string; name: string }
isCurrentRoute: boolean
isSelected: boolean
isActive: boolean
@@ -167,10 +167,10 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
isPinned: boolean
isMenuOpen: boolean
showCollapsedTooltips: boolean
onMultiSelectClick: (taskId: string, shiftKey: boolean) => void
onContextMenu: (e: React.MouseEvent, taskId: string) => void
onMultiSelectClick: (chatId: string, shiftKey: boolean) => void
onContextMenu: (e: React.MouseEvent, chatId: string) => void
onMorePointerDown: () => void
onMoreClick: (e: React.MouseEvent<HTMLButtonElement>, taskId: string) => void
onMoreClick: (e: React.MouseEvent<HTMLButtonElement>, chatId: string) => void
}) {
const dragGhostRef = useRef<HTMLElement | null>(null)
@@ -178,9 +178,9 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
e.dataTransfer.effectAllowed = 'copyMove'
e.dataTransfer.setData(
SIM_RESOURCES_DRAG_TYPE,
JSON.stringify([{ type: 'task', id: task.id, title: task.name }])
JSON.stringify([{ type: 'task', id: chat.id, title: chat.name }])
)
const ghost = createSidebarDragGhost(task.name, { kind: 'task' })
const ghost = createSidebarDragGhost(chat.name, { kind: 'task' })
void ghost.offsetHeight
e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2)
dragGhostRef.current = ghost
@@ -194,9 +194,9 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
}
return (
<SidebarTooltip label={task.name} enabled={showCollapsedTooltips}>
<SidebarTooltip label={chat.name} enabled={showCollapsedTooltips}>
<Link
href={task.href}
href={chat.href}
className={chipVariants({
active: isCurrentRoute || isSelected || isMenuOpen,
fullWidth: true,
@@ -205,18 +205,18 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
if (e.metaKey || e.ctrlKey) return
if (e.shiftKey) {
e.preventDefault()
onMultiSelectClick(task.id, true)
onMultiSelectClick(chat.id, true)
} else {
useFolderStore.getState().selectTaskOnly(task.id)
useFolderStore.getState().selectChatOnly(chat.id)
}
}}
onContextMenu={(e) => onContextMenu(e, task.id)}
onContextMenu={(e) => onContextMenu(e, chat.id)}
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className='min-w-0 flex-1 truncate text-[var(--text-body)]'>{task.name}</div>
{task.id !== 'new' && (
<div className='min-w-0 flex-1 truncate text-[var(--text-body)]'>{chat.name}</div>
{chat.id !== 'new' && (
<div className='relative flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center'>
{(isActive || (!isCurrentRoute && isUnread)) && (
<span
@@ -243,7 +243,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onMoreClick(e, task.id)
onMoreClick(e, chat.id)
}}
className={cn(
'absolute inset-0 flex items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100',
@@ -589,77 +589,77 @@ export const Sidebar = memo(function Sidebar() {
}
}, [activeNavItemHref])
const createTaskMutation = useCreateTask(workspaceId)
const deleteTaskMutation = useDeleteTask(workspaceId)
const deleteTasksMutation = useDeleteTasks(workspaceId)
const markTaskReadMutation = useMarkTaskRead(workspaceId)
const markTaskUnreadMutation = useMarkTaskUnread(workspaceId)
const renameTaskMutation = useRenameTask(workspaceId)
const setTaskPinnedMutation = useSetTaskPinned(workspaceId)
const tasksHover = useHoverMenu()
const createChatMutation = useCreateMothershipChat(workspaceId)
const deleteChatMutation = useDeleteMothershipChat(workspaceId)
const deleteChatsMutation = useDeleteMothershipChats(workspaceId)
const markChatReadMutation = useMarkMothershipChatRead(workspaceId)
const markChatUnreadMutation = useMarkMothershipChatUnread(workspaceId)
const renameChatMutation = useRenameMothershipChat(workspaceId)
const setChatPinnedMutation = useSetMothershipChatPinned(workspaceId)
const chatsHover = useHoverMenu()
const workflowsHover = useHoverMenu()
const {
isOpen: isTaskContextMenuOpen,
position: taskContextMenuPosition,
menuRef: taskMenuRef,
handleContextMenu: handleTaskContextMenuBase,
closeMenu: closeTaskContextMenu,
preventDismiss: preventTaskDismiss,
isOpen: isChatContextMenuOpen,
position: chatContextMenuPosition,
menuRef: chatMenuRef,
handleContextMenu: handleChatContextMenuBase,
closeMenu: closeChatContextMenu,
preventDismiss: preventChatDismiss,
} = useContextMenu()
const isCreatingTaskRef = useRef(false)
const contextMenuSelectionRef = useRef<{ taskIds: string[]; names: string[] }>({
taskIds: [],
const isCreatingChatRef = useRef(false)
const contextMenuSelectionRef = useRef<{ chatIds: string[]; names: string[] }>({
chatIds: [],
names: [],
})
const [menuOpenTaskId, setMenuOpenTaskId] = useState<string | null>(null)
const [menuOpenChatId, setMenuOpenChatId] = useState<string | null>(null)
useEffect(() => {
if (!isTaskContextMenuOpen) setMenuOpenTaskId(null)
}, [isTaskContextMenuOpen])
if (!isChatContextMenuOpen) setMenuOpenChatId(null)
}, [isChatContextMenuOpen])
const captureTaskSelection = useCallback((taskId: string) => {
const { selectedTasks, selectTaskOnly } = useFolderStore.getState()
if (selectedTasks.size > 0 && selectedTasks.has(taskId)) {
const captureChatSelection = useCallback((chatId: string) => {
const { selectedChats, selectChatOnly } = useFolderStore.getState()
if (selectedChats.size > 0 && selectedChats.has(chatId)) {
contextMenuSelectionRef.current = {
taskIds: Array.from(selectedTasks),
chatIds: Array.from(selectedChats),
names: [],
}
} else {
selectTaskOnly(taskId)
contextMenuSelectionRef.current = { taskIds: [taskId], names: [] }
selectChatOnly(chatId)
contextMenuSelectionRef.current = { chatIds: [chatId], names: [] }
}
}, [])
const handleTaskContextMenu = useCallback(
(e: React.MouseEvent, taskId: string) => {
captureTaskSelection(taskId)
setMenuOpenTaskId(taskId)
tasksHover.setLocked(true)
preventTaskDismiss()
handleTaskContextMenuBase(e)
const handleChatContextMenu = useCallback(
(e: React.MouseEvent, chatId: string) => {
captureChatSelection(chatId)
setMenuOpenChatId(chatId)
chatsHover.setLocked(true)
preventChatDismiss()
handleChatContextMenuBase(e)
},
[captureTaskSelection, handleTaskContextMenuBase, preventTaskDismiss, tasksHover]
[captureChatSelection, handleChatContextMenuBase, preventChatDismiss, chatsHover]
)
const handleTaskMorePointerDown = useCallback(() => {
if (isTaskContextMenuOpen) {
preventTaskDismiss()
const handleChatMorePointerDown = useCallback(() => {
if (isChatContextMenuOpen) {
preventChatDismiss()
}
}, [isTaskContextMenuOpen, preventTaskDismiss])
}, [isChatContextMenuOpen, preventChatDismiss])
const handleTaskMoreClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>, taskId: string) => {
if (isTaskContextMenuOpen) {
closeTaskContextMenu()
const handleChatMoreClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>, chatId: string) => {
if (isChatContextMenuOpen) {
closeChatContextMenu()
return
}
tasksHover.setLocked(true)
captureTaskSelection(taskId)
setMenuOpenTaskId(taskId)
chatsHover.setLocked(true)
captureChatSelection(chatId)
setMenuOpenChatId(chatId)
const rect = e.currentTarget.getBoundingClientRect()
handleTaskContextMenuBase({
handleChatContextMenuBase({
preventDefault: () => {},
stopPropagation: () => {},
clientX: rect.right,
@@ -667,11 +667,11 @@ export const Sidebar = memo(function Sidebar() {
} as React.MouseEvent)
},
[
isTaskContextMenuOpen,
closeTaskContextMenu,
captureTaskSelection,
handleTaskContextMenuBase,
tasksHover,
isChatContextMenuOpen,
closeChatContextMenu,
captureChatSelection,
handleChatContextMenuBase,
chatsHover,
]
)
@@ -793,19 +793,19 @@ export const Sidebar = memo(function Sidebar() {
[navigateToSettings, getSettingsHref, setSidebarWidth]
)
const { data: fetchedTasks = [], isLoading: tasksLoading } = useTasks(workspaceId)
const { data: fetchedChats = [], isLoading: chatsLoading } = useMothershipChats(workspaceId)
useTaskEvents(workspaceId)
useMothershipChatEvents(workspaceId)
const tasks = useMemo(
const chats = useMemo(
() =>
fetchedTasks
? fetchedTasks.map((t) => ({
fetchedChats
? fetchedChats.map((t) => ({
...t,
href: `/workspace/${workspaceId}/task/${t.id}`,
href: `/workspace/${workspaceId}/chat/${t.id}`,
}))
: [],
[fetchedTasks, workspaceId]
[fetchedChats, workspaceId]
)
const { data: fetchedTables = [] } = useTablesList(workspaceId)
@@ -849,26 +849,26 @@ export const Sidebar = memo(function Sidebar() {
[fetchedKnowledgeBases, workspaceId, permissionConfig.hideKnowledgeBaseTab]
)
const taskIds = useMemo(() => tasks.map((t) => t.id), [tasks])
const chatIds = useMemo(() => chats.map((t) => t.id), [chats])
const { selectedTasks, handleTaskClick } = useTaskSelection({ taskIds })
const hasTaskMultiSelection = selectedTasks.size > 1
const { selectedChats, handleChatClick } = useChatSelection({ chatIds })
const hasChatMultiSelection = selectedChats.size > 1
const isMultiTaskContextMenu = contextMenuSelectionRef.current.taskIds.length > 1
const activeTaskContextMenuItem =
!isMultiTaskContextMenu && contextMenuSelectionRef.current.taskIds.length === 1
? tasks.find((task) => task.id === contextMenuSelectionRef.current.taskIds[0])
const isMultiChatContextMenu = contextMenuSelectionRef.current.chatIds.length > 1
const activeChatContextMenuItem =
!isMultiChatContextMenu && contextMenuSelectionRef.current.chatIds.length === 1
? chats.find((chat) => chat.id === contextMenuSelectionRef.current.chatIds[0])
: null
const [isTaskDeleteModalOpen, setIsTaskDeleteModalOpen] = useState(false)
const [isChatDeleteModalOpen, setIsChatDeleteModalOpen] = useState(false)
const handleDeleteTask = useCallback(() => {
const { taskIds: ids } = contextMenuSelectionRef.current
const handleDeleteChat = useCallback(() => {
const { chatIds: ids } = contextMenuSelectionRef.current
if (ids.length === 0) return
const names = ids.map((id) => tasks.find((t) => t.id === id)?.name).filter(Boolean) as string[]
contextMenuSelectionRef.current = { taskIds: ids, names }
setIsTaskDeleteModalOpen(true)
}, [tasks])
const names = ids.map((id) => chats.find((t) => t.id === id)?.name).filter(Boolean) as string[]
contextMenuSelectionRef.current = { chatIds: ids, names }
setIsChatDeleteModalOpen(true)
}, [chats])
const navigateToPage = useCallback(
(path: string) => {
@@ -880,35 +880,35 @@ export const Sidebar = memo(function Sidebar() {
[setSidebarWidth, router]
)
const handleConfirmDeleteTasks = () => {
const { taskIds: taskIdsToDelete } = contextMenuSelectionRef.current
if (taskIdsToDelete.length === 0) return
const handleConfirmDeleteChats = () => {
const { chatIds: chatIdsToDelete } = contextMenuSelectionRef.current
if (chatIdsToDelete.length === 0) return
const currentPath = pathname ?? ''
const isViewingDeletedTask = taskIdsToDelete.some(
(id) => currentPath === `/workspace/${workspaceId}/task/${id}`
const isViewingDeletedChat = chatIdsToDelete.some(
(id) => currentPath === `/workspace/${workspaceId}/chat/${id}`
)
const onDeleteSuccess = () => {
useFolderStore.getState().clearTaskSelection()
if (isViewingDeletedTask) {
useFolderStore.getState().clearChatSelection()
if (isViewingDeletedChat) {
navigateToPage(`/workspace/${workspaceId}/home`)
}
}
if (taskIdsToDelete.length === 1) {
deleteTaskMutation.mutate(taskIdsToDelete[0], { onSuccess: onDeleteSuccess })
if (chatIdsToDelete.length === 1) {
deleteChatMutation.mutate(chatIdsToDelete[0], { onSuccess: onDeleteSuccess })
} else {
deleteTasksMutation.mutate(taskIdsToDelete, { onSuccess: onDeleteSuccess })
deleteChatsMutation.mutate(chatIdsToDelete, { onSuccess: onDeleteSuccess })
}
setIsTaskDeleteModalOpen(false)
setIsChatDeleteModalOpen(false)
}
const [visibleTaskCount, setVisibleTaskCount] = useState(5)
const taskFlyoutRename = useFlyoutInlineRename({
const [visibleChatCount, setVisibleChatCount] = useState(5)
const chatFlyoutRename = useFlyoutInlineRename({
itemType: 'task',
onSave: async (taskId, name) => {
await renameTaskMutation.mutateAsync({ chatId: taskId, title: name })
onSave: async (chatId, name) => {
await renameChatMutation.mutateAsync({ chatId: chatId, title: name })
},
})
@@ -924,49 +924,49 @@ export const Sidebar = memo(function Sidebar() {
})
useEffect(() => {
tasksHover.setLocked(isTaskContextMenuOpen || !!taskFlyoutRename.editingId)
}, [isTaskContextMenuOpen, taskFlyoutRename.editingId, tasksHover.setLocked])
chatsHover.setLocked(isChatContextMenuOpen || !!chatFlyoutRename.editingId)
}, [isChatContextMenuOpen, chatFlyoutRename.editingId, chatsHover.setLocked])
useEffect(() => {
workflowsHover.setLocked(!!workflowFlyoutRename.editingId)
}, [workflowFlyoutRename.editingId, workflowsHover.setLocked])
const handleTaskOpenInNewTab = useCallback(() => {
const { taskIds: ids } = contextMenuSelectionRef.current
const handleChatOpenInNewTab = useCallback(() => {
const { chatIds: ids } = contextMenuSelectionRef.current
if (ids.length !== 1) return
window.open(`/workspace/${workspaceId}/task/${ids[0]}`, '_blank', 'noopener,noreferrer')
window.open(`/workspace/${workspaceId}/chat/${ids[0]}`, '_blank', 'noopener,noreferrer')
}, [workspaceId])
const handleMarkTaskAsRead = useCallback(() => {
const { taskIds: ids } = contextMenuSelectionRef.current
const handleMarkChatAsRead = useCallback(() => {
const { chatIds: ids } = contextMenuSelectionRef.current
if (ids.length !== 1) return
markTaskReadMutation.mutate(ids[0])
markChatReadMutation.mutate(ids[0])
}, [])
const handleMarkTaskAsUnread = useCallback(() => {
const { taskIds: ids } = contextMenuSelectionRef.current
const handleMarkChatAsUnread = useCallback(() => {
const { chatIds: ids } = contextMenuSelectionRef.current
if (ids.length !== 1) return
markTaskUnreadMutation.mutate(ids[0])
markChatUnreadMutation.mutate(ids[0])
}, [])
const handleStartTaskRename = useCallback(() => {
const { taskIds: ids } = contextMenuSelectionRef.current
const handleStartChatRename = useCallback(() => {
const { chatIds: ids } = contextMenuSelectionRef.current
if (ids.length !== 1) return
const taskId = ids[0]
const task = tasks.find((t) => t.id === taskId)
if (!task) return
tasksHover.setLocked(true)
taskFlyoutRename.startRename({ id: taskId, name: task.name })
}, [taskFlyoutRename, tasks, tasksHover])
const chatId = ids[0]
const chat = chats.find((t) => t.id === chatId)
if (!chat) return
chatsHover.setLocked(true)
chatFlyoutRename.startRename({ id: chatId, name: chat.name })
}, [chatFlyoutRename, chats, chatsHover])
const handleToggleTaskPin = useCallback(() => {
const { taskIds: ids } = contextMenuSelectionRef.current
const handleToggleChatPin = useCallback(() => {
const { chatIds: ids } = contextMenuSelectionRef.current
if (ids.length !== 1) return
const taskId = ids[0]
const task = tasks.find((t) => t.id === taskId)
if (!task) return
setTaskPinnedMutation.mutate({ chatId: taskId, pinned: !task.isPinned })
}, [tasks, setTaskPinnedMutation])
const chatId = ids[0]
const chat = chats.find((t) => t.id === chatId)
if (!chat) return
setChatPinnedMutation.mutate({ chatId: chatId, pinned: !chat.isPinned })
}, [chats, setChatPinnedMutation])
const handleCollapsedWorkflowOpenInNewTab = useCallback(
(workflow: { id: string }) => {
@@ -1123,7 +1123,7 @@ export const Sidebar = memo(function Sidebar() {
[workspaces, handleLeaveWorkspace]
)
const tasksCollapsedIcon = <Task className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
const chatsCollapsedIcon = <Task className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
const workflowsCollapsedIcon = (
<Workflow className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
@@ -1134,29 +1134,29 @@ export const Sidebar = memo(function Sidebar() {
onSelect: handleCreateWorkflow,
}
const handleNewTask = useCallback(async () => {
if (!workspaceId || isCreatingTaskRef.current) return
isCreatingTaskRef.current = true
const handleNewChat = useCallback(async () => {
if (!workspaceId || isCreatingChatRef.current) return
isCreatingChatRef.current = true
try {
const { id } = await createTaskMutation.mutateAsync()
const { id } = await createChatMutation.mutateAsync()
useMothershipDraftsStore.getState().clearDraft(`${workspaceId}:new`)
navigateToPage(`/workspace/${workspaceId}/task/${id}`)
navigateToPage(`/workspace/${workspaceId}/chat/${id}`)
} catch {
navigateToPage(`/workspace/${workspaceId}/home`)
} finally {
isCreatingTaskRef.current = false
isCreatingChatRef.current = false
}
}, [workspaceId, navigateToPage])
const tasksPrimaryAction = {
const chatsPrimaryAction = {
label: 'New chat',
onSelect: handleNewTask,
onSelect: handleNewChat,
}
const handleSeeMoreTasks = useCallback(() => setVisibleTaskCount((prev) => prev + 5), [])
const handleSeeLessTasks = useCallback(() => setVisibleTaskCount(5), [])
const handleSeeMoreChats = useCallback(() => setVisibleChatCount((prev) => prev + 5), [])
const handleSeeLessChats = useCallback(() => setVisibleChatCount(5), [])
const handleCloseTaskDeleteModal = useCallback(() => setIsTaskDeleteModalOpen(false), [])
const handleCloseChatDeleteModal = useCallback(() => setIsChatDeleteModalOpen(false), [])
const handleEdgeKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -1175,9 +1175,9 @@ export const Sidebar = memo(function Sidebar() {
captureEvent(posthog, 'docs_opened', { source: 'help_menu' })
}, [posthog])
const handleTaskRenameBlur = useCallback(
() => void taskFlyoutRename.saveRename(),
[taskFlyoutRename.saveRename]
const handleChatRenameBlur = useCallback(
() => void chatFlyoutRename.saveRename(),
[chatFlyoutRename.saveRename]
)
const handleWorkflowRenameBlur = useCallback(
@@ -1244,7 +1244,7 @@ export const Sidebar = memo(function Sidebar() {
{
id: 'add-task',
handler: () => {
handleNewTask()
handleNewChat()
},
},
])
@@ -1338,7 +1338,7 @@ export const Sidebar = memo(function Sidebar() {
)}
>
<div ref={scrollContentRef} className='flex flex-col'>
<div className='tasks-section flex flex-shrink-0 flex-col'>
<div className='chats-section flex flex-shrink-0 flex-col'>
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
<div className='text-[var(--text-muted)] text-small'>Chats</div>
{!isCollapsed && (
@@ -1348,8 +1348,8 @@ export const Sidebar = memo(function Sidebar() {
<Button
variant='quiet'
className='h-[18px] w-[18px] rounded-sm p-0'
onClick={handleNewTask}
disabled={createTaskMutation.isPending}
onClick={handleNewChat}
disabled={createChatMutation.isPending}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
@@ -1365,76 +1365,76 @@ export const Sidebar = memo(function Sidebar() {
</div>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={tasksCollapsedIcon}
hover={tasksHover}
icon={chatsCollapsedIcon}
hover={chatsHover}
ariaLabel='Chats'
className='mt-2'
primaryAction={tasksPrimaryAction}
primaryAction={chatsPrimaryAction}
>
{tasksLoading ? (
{chatsLoading ? (
<DropdownMenuItem disabled>
<Loader className='h-[14px] w-[14px]' animate />
Loading...
</DropdownMenuItem>
) : tasks.length === 0 ? (
) : chats.length === 0 ? (
<DropdownMenuItem disabled>No chats yet</DropdownMenuItem>
) : (
tasks.map((task) => (
<CollapsedTaskFlyoutItem
key={task.id}
task={task}
isCurrentRoute={pathname === task.href}
isMenuOpen={menuOpenTaskId === task.id}
isEditing={task.id === taskFlyoutRename.editingId}
editValue={taskFlyoutRename.value}
inputRef={taskFlyoutRename.inputRef}
isRenaming={taskFlyoutRename.isSaving}
onEditValueChange={taskFlyoutRename.setValue}
onEditKeyDown={taskFlyoutRename.handleKeyDown}
onEditBlur={handleTaskRenameBlur}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
onMoreClick={handleTaskMoreClick}
chats.map((chat) => (
<CollapsedChatFlyoutItem
key={chat.id}
chat={chat}
isCurrentRoute={pathname === chat.href}
isMenuOpen={menuOpenChatId === chat.id}
isEditing={chat.id === chatFlyoutRename.editingId}
editValue={chatFlyoutRename.value}
inputRef={chatFlyoutRename.inputRef}
isRenaming={chatFlyoutRename.isSaving}
onEditValueChange={chatFlyoutRename.setValue}
onEditKeyDown={chatFlyoutRename.handleKeyDown}
onEditBlur={handleChatRenameBlur}
onContextMenu={handleChatContextMenu}
onMorePointerDown={handleChatMorePointerDown}
onMoreClick={handleChatMoreClick}
/>
))
)}
</CollapsedSidebarMenu>
) : (
<div className={cn(SIDEBAR_ITEM_GAP_CLASS, 'mt-2 flex flex-col px-2')}>
{tasksLoading ? (
{chatsLoading ? (
<SidebarItemSkeleton />
) : (
<>
{tasks.length === 0 ? (
{chats.length === 0 ? (
<div className='flex h-[30px] items-center px-2 text-[var(--text-muted)] text-small'>
No chats yet
</div>
) : null}
{/* `selectTaskOnly` populates `selectedTasks` on every click, so
{/* `selectChatOnly` populates `selectedChats` on every click, so
a single entry just means "last clicked" — already conveyed by
`isCurrentRoute`. Highlight from selection only for explicit
multi-selection (size > 1), otherwise it lingers after navigating
away from a task. */}
{tasks.slice(0, visibleTaskCount).map((task) => {
const isCurrentRoute = pathname === task.href
const isRenaming = taskFlyoutRename.editingId === task.id
away from a chat. */}
{chats.slice(0, visibleChatCount).map((chat) => {
const isCurrentRoute = pathname === chat.href
const isRenaming = chatFlyoutRename.editingId === chat.id
const isSelected =
task.id !== 'new' &&
hasTaskMultiSelection &&
selectedTasks.has(task.id)
chat.id !== 'new' &&
hasChatMultiSelection &&
selectedChats.has(chat.id)
if (isRenaming) {
return (
<div
key={task.id}
key={chat.id}
className={chipVariants({ active: true, fullWidth: true })}
>
<input
ref={taskFlyoutRename.inputRef}
value={taskFlyoutRename.value}
onChange={(e) => taskFlyoutRename.setValue(e.target.value)}
onKeyDown={taskFlyoutRename.handleKeyDown}
onBlur={handleTaskRenameBlur}
ref={chatFlyoutRename.inputRef}
value={chatFlyoutRename.value}
onChange={(e) => chatFlyoutRename.setValue(e.target.value)}
onKeyDown={chatFlyoutRename.handleKeyDown}
onBlur={handleChatRenameBlur}
className='min-w-0 flex-1 border-none bg-transparent text-[14px] text-[var(--text-body)] outline-none'
/>
</div>
@@ -1442,37 +1442,37 @@ export const Sidebar = memo(function Sidebar() {
}
return (
<SidebarTaskItem
key={task.id}
task={task}
<SidebarChatItem
key={chat.id}
chat={chat}
isCurrentRoute={isCurrentRoute}
isSelected={isSelected}
isActive={!!task.isActive}
isUnread={!!task.isUnread}
isPinned={!!task.isPinned}
isMenuOpen={menuOpenTaskId === task.id}
isActive={!!chat.isActive}
isUnread={!!chat.isUnread}
isPinned={!!chat.isPinned}
isMenuOpen={menuOpenChatId === chat.id}
showCollapsedTooltips={showCollapsedTooltips}
onMultiSelectClick={handleTaskClick}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
onMoreClick={handleTaskMoreClick}
onMultiSelectClick={handleChatClick}
onContextMenu={handleChatContextMenu}
onMorePointerDown={handleChatMorePointerDown}
onMoreClick={handleChatMoreClick}
/>
)
})}
{tasks.length > 5 && (
{chats.length > 5 && (
<button
type='button'
onClick={
tasks.length > visibleTaskCount
? handleSeeMoreTasks
: handleSeeLessTasks
chats.length > visibleChatCount
? handleSeeMoreChats
: handleSeeLessChats
}
className={cn(
chipVariants({ fullWidth: true }),
'text-[var(--text-muted)] text-small'
)}
>
{tasks.length > visibleTaskCount ? 'See more' : 'See less'}
{chats.length > visibleChatCount ? 'See more' : 'See less'}
</button>
)}
</>
@@ -1714,36 +1714,36 @@ export const Sidebar = memo(function Sidebar() {
/>
<ContextMenu
isOpen={isTaskContextMenuOpen}
position={taskContextMenuPosition}
menuRef={taskMenuRef}
onClose={closeTaskContextMenu}
onOpenInNewTab={handleTaskOpenInNewTab}
onMarkAsRead={handleMarkTaskAsRead}
onMarkAsUnread={handleMarkTaskAsUnread}
onTogglePin={handleToggleTaskPin}
onRename={handleStartTaskRename}
onDelete={handleDeleteTask}
showOpenInNewTab={!isMultiTaskContextMenu}
showMarkAsRead={!isMultiTaskContextMenu && !!activeTaskContextMenuItem?.isUnread}
isOpen={isChatContextMenuOpen}
position={chatContextMenuPosition}
menuRef={chatMenuRef}
onClose={closeChatContextMenu}
onOpenInNewTab={handleChatOpenInNewTab}
onMarkAsRead={handleMarkChatAsRead}
onMarkAsUnread={handleMarkChatAsUnread}
onTogglePin={handleToggleChatPin}
onRename={handleStartChatRename}
onDelete={handleDeleteChat}
showOpenInNewTab={!isMultiChatContextMenu}
showMarkAsRead={!isMultiChatContextMenu && !!activeChatContextMenuItem?.isUnread}
showMarkAsUnread={
!isMultiTaskContextMenu &&
!!activeTaskContextMenuItem &&
!activeTaskContextMenuItem.isUnread
!isMultiChatContextMenu &&
!!activeChatContextMenuItem &&
!activeChatContextMenuItem.isUnread
}
showPin={!isMultiTaskContextMenu && !!activeTaskContextMenuItem}
isPinned={!!activeTaskContextMenuItem?.isPinned}
showRename={!isMultiTaskContextMenu}
showPin={!isMultiChatContextMenu && !!activeChatContextMenuItem}
isPinned={!!activeChatContextMenuItem?.isPinned}
showRename={!isMultiChatContextMenu}
showDuplicate={false}
disableRename={!canEdit}
disableDelete={!canEdit}
/>
<DeleteModal
isOpen={isTaskDeleteModalOpen}
onClose={handleCloseTaskDeleteModal}
onConfirm={handleConfirmDeleteTasks}
isDeleting={deleteTaskMutation.isPending || deleteTasksMutation.isPending}
isOpen={isChatDeleteModalOpen}
onClose={handleCloseChatDeleteModal}
onConfirm={handleConfirmDeleteChats}
isDeleting={deleteChatMutation.isPending || deleteChatsMutation.isPending}
itemType='task'
itemName={contextMenuSelectionRef.current.names}
/>
@@ -1772,7 +1772,7 @@ export const Sidebar = memo(function Sidebar() {
onOpenChange={setIsSearchModalOpen}
workflows={searchModalWorkflows}
workspaces={searchModalWorkspaces}
tasks={tasks}
chats={chats}
tables={searchModalTables}
files={searchModalFiles}
knowledgeBases={searchModalKnowledgeBases}
@@ -21,7 +21,11 @@ vi.mock('@tanstack/react-query', () => ({
useMutation: vi.fn((options) => options),
}))
import { fetchChatHistory, fetchTasks, useAddChatResource } from '@/hooks/queries/tasks'
import {
fetchMothershipChatHistory,
fetchMothershipChats,
useAddChatResource,
} from '@/hooks/queries/mothership-chats'
function jsonResponse(body: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(body), {
@@ -60,7 +64,7 @@ describe('tasks query boundary parsing', () => {
})
)
const tasks = await fetchTasks('ws-1')
const tasks = await fetchMothershipChats('ws-1')
expect(tasks).toHaveLength(1)
expect(tasks[0]).toEqual(
@@ -92,7 +96,9 @@ describe('tasks query boundary parsing', () => {
})
)
await expect(fetchTasks('ws-1')).rejects.toThrow('Response failed contract validation')
await expect(fetchMothershipChats('ws-1')).rejects.toThrow(
'Response failed contract validation'
)
})
it('parses valid mothership chat history responses', async () => {
@@ -114,7 +120,7 @@ describe('tasks query boundary parsing', () => {
})
)
const history = await fetchChatHistory('chat-1')
const history = await fetchMothershipChatHistory('chat-1')
expect(history).toEqual({
id: 'chat-1',
@@ -146,7 +152,7 @@ describe('tasks query boundary parsing', () => {
})
)
await expect(fetchChatHistory('chat-1')).rejects.toThrow(
await expect(fetchMothershipChatHistory('chat-1')).rejects.toThrow(
'Invalid chat response: chat.resources[0].type is invalid'
)
})
@@ -14,11 +14,11 @@ import {
forkMothershipChatContract,
getMothershipChatContract,
listMothershipChatsContract,
type MothershipTask,
type MothershipChat,
removeMothershipChatResourceContract,
reorderMothershipChatResourcesContract,
updateMothershipChatContract,
} from '@/lib/api/contracts/mothership-tasks'
} from '@/lib/api/contracts/mothership-chats'
import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message'
import { normalizeMessage } from '@/lib/copilot/chat/persisted-message'
import {
@@ -29,7 +29,7 @@ import { isStreamBatchEvent, type StreamBatchEvent } from '@/lib/copilot/request
import { type MothershipResource, MothershipResourceType } from '@/lib/copilot/resources/types'
import { useMothershipQueueStore } from '@/stores/mothership-queue/store'
export interface TaskMetadata {
export interface MothershipChatMetadata {
id: string
name: string
updatedAt: Date
@@ -38,7 +38,7 @@ export interface TaskMetadata {
isPinned: boolean
}
export interface TaskChatHistory {
export interface MothershipChatHistory {
id: string
title: string | null
messages: PersistedMessage[]
@@ -51,12 +51,13 @@ export interface TaskChatHistory {
} | null
}
export const taskKeys = {
all: ['tasks'] as const,
lists: () => [...taskKeys.all, 'list'] as const,
list: (workspaceId: string | undefined) => [...taskKeys.lists(), workspaceId ?? ''] as const,
details: () => [...taskKeys.all, 'detail'] as const,
detail: (chatId: string | undefined) => [...taskKeys.details(), chatId ?? ''] as const,
export const mothershipChatKeys = {
all: ['mothership-chats'] as const,
lists: () => [...mothershipChatKeys.all, 'list'] as const,
list: (workspaceId: string | undefined) =>
[...mothershipChatKeys.lists(), workspaceId ?? ''] as const,
details: () => [...mothershipChatKeys.all, 'detail'] as const,
detail: (chatId: string | undefined) => [...mothershipChatKeys.details(), chatId ?? ''] as const,
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -80,7 +81,7 @@ function isResourceType(value: unknown): value is MothershipResource['type'] {
)
}
function parseStreamSnapshot(value: unknown): TaskChatHistory['streamSnapshot'] {
function parseStreamSnapshot(value: unknown): MothershipChatHistory['streamSnapshot'] {
if (!isRecord(value)) {
return null
}
@@ -140,7 +141,7 @@ function parseResources(value: unknown, context: string): MothershipResource[] {
function parseStrictStreamSnapshot(
value: unknown,
context: string
): TaskChatHistory['streamSnapshot'] {
): MothershipChatHistory['streamSnapshot'] {
if (value === undefined || value === null) {
return null
}
@@ -150,7 +151,7 @@ function parseStrictStreamSnapshot(
return snapshot
}
function parseChatHistory(value: unknown): TaskChatHistory {
function parseChatHistory(value: unknown): MothershipChatHistory {
const responseContext = 'Invalid chat response'
const chatContext = `${responseContext}: chat`
@@ -185,7 +186,7 @@ function parseChatResourcesResponse(value: unknown): { resources: MothershipReso
}
}
function mapTask(chat: MothershipTask): TaskMetadata {
function mapChat(chat: MothershipChat): MothershipChatMetadata {
const updatedAt = new Date(chat.updatedAt)
return {
id: chat.id,
@@ -199,34 +200,34 @@ function mapTask(chat: MothershipTask): TaskMetadata {
}
}
export async function fetchTasks(
export async function fetchMothershipChats(
workspaceId: string,
signal?: AbortSignal
): Promise<TaskMetadata[]> {
): Promise<MothershipChatMetadata[]> {
const data = await requestJson(listMothershipChatsContract, {
query: { workspaceId },
signal,
})
return data.data.map(mapTask)
return data.data.map(mapChat)
}
/**
* Fetches mothership chat tasks for a workspace.
* Fetches mothership chat chats for a workspace.
* These are workspace-scoped conversations from the Home page.
*/
export function useTasks(workspaceId?: string) {
export function useMothershipChats(workspaceId?: string) {
return useQuery({
queryKey: taskKeys.list(workspaceId),
queryFn: workspaceId ? ({ signal }) => fetchTasks(workspaceId, signal) : skipToken,
queryKey: mothershipChatKeys.list(workspaceId),
queryFn: workspaceId ? ({ signal }) => fetchMothershipChats(workspaceId, signal) : skipToken,
placeholderData: keepPreviousData,
staleTime: 60 * 1000,
})
}
export async function fetchChatHistory(
export async function fetchMothershipChatHistory(
chatId: string,
signal?: AbortSignal
): Promise<TaskChatHistory> {
): Promise<MothershipChatHistory> {
try {
const data = await requestJson(getMothershipChatContract, {
params: { chatId },
@@ -254,60 +255,59 @@ export async function fetchChatHistory(
}
/**
* Fetches chat history for a single task (mothership chat).
* Used by the task page to load an existing conversation.
* Fetches chat history for a single chat (mothership chat).
* Used by the chat page to load an existing conversation.
*/
export function useChatHistory(chatId: string | undefined) {
export function useMothershipChatHistory(chatId: string | undefined) {
return useQuery({
queryKey: taskKeys.detail(chatId),
queryFn: ({ signal }) => fetchChatHistory(chatId!, signal),
enabled: Boolean(chatId),
queryKey: mothershipChatKeys.detail(chatId),
queryFn: chatId ? ({ signal }) => fetchMothershipChatHistory(chatId, signal) : skipToken,
staleTime: 30 * 1000,
})
}
async function deleteTask(chatId: string): Promise<void> {
async function deleteChat(chatId: string): Promise<void> {
await requestJson(deleteMothershipChatContract, {
params: { chatId },
})
}
/**
* Deletes a mothership chat task and invalidates the task list.
* Deletes a mothership chat chat and invalidates the chat list.
*/
export function useDeleteTask(workspaceId?: string) {
export function useDeleteMothershipChat(workspaceId?: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteTask,
mutationFn: deleteChat,
onSettled: (_data, _error, chatId) => {
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
queryClient.removeQueries({ queryKey: taskKeys.detail(chatId) })
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
queryClient.removeQueries({ queryKey: mothershipChatKeys.detail(chatId) })
useMothershipQueueStore.getState().clearChat(chatId)
},
})
}
/**
* Deletes multiple mothership chat tasks and invalidates the task list.
* Deletes multiple mothership chat chats and invalidates the chat list.
*/
export function useDeleteTasks(workspaceId?: string) {
export function useDeleteMothershipChats(workspaceId?: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (chatIds: string[]) => {
await Promise.all(chatIds.map(deleteTask))
await Promise.all(chatIds.map(deleteChat))
},
onSettled: (_data, _error, chatIds) => {
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
const queueStore = useMothershipQueueStore.getState()
for (const chatId of chatIds) {
queryClient.removeQueries({ queryKey: taskKeys.detail(chatId) })
queryClient.removeQueries({ queryKey: mothershipChatKeys.detail(chatId) })
queueStore.clearChat(chatId)
}
},
})
}
async function renameTask({ chatId, title }: { chatId: string; title: string }): Promise<void> {
async function renameChat({ chatId, title }: { chatId: string; title: string }): Promise<void> {
await requestJson(updateMothershipChatContract, {
params: { chatId },
body: { title },
@@ -315,31 +315,34 @@ async function renameTask({ chatId, title }: { chatId: string; title: string }):
}
/**
* Renames a mothership chat task with optimistic update.
* Renames a mothership chat chat with optimistic update.
*/
export function useRenameTask(workspaceId?: string) {
export function useRenameMothershipChat(workspaceId?: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: renameTask,
mutationFn: renameChat,
onMutate: async ({ chatId, title }) => {
await queryClient.cancelQueries({ queryKey: taskKeys.list(workspaceId) })
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.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))
const previousChats = queryClient.getQueryData<MothershipChatMetadata[]>(
mothershipChatKeys.list(workspaceId)
)
return { previousTasks }
queryClient.setQueryData<MothershipChatMetadata[]>(
mothershipChatKeys.list(workspaceId),
(old) => old?.map((chat) => (chat.id === chatId ? { ...chat, name: title } : chat))
)
return { previousChats }
},
onError: (_err, _variables, context) => {
if (context?.previousTasks) {
queryClient.setQueryData(taskKeys.list(workspaceId), context.previousTasks)
if (context?.previousChats) {
queryClient.setQueryData(mothershipChatKeys.list(workspaceId), context.previousChats)
}
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
queryClient.invalidateQueries({ queryKey: taskKeys.detail(variables.chatId) })
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(variables.chatId) })
},
})
}
@@ -360,14 +363,16 @@ export function useAddChatResource(chatId?: string) {
mutationFn: addChatResource,
onMutate: async ({ resource }) => {
if (!chatId) return
await queryClient.cancelQueries({ queryKey: taskKeys.detail(chatId) })
const previous = queryClient.getQueryData<TaskChatHistory>(taskKeys.detail(chatId))
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.detail(chatId) })
const previous = queryClient.getQueryData<MothershipChatHistory>(
mothershipChatKeys.detail(chatId)
)
if (previous) {
const exists = previous.resources.some(
(r) => r.type === resource.type && r.id === resource.id
)
if (!exists) {
queryClient.setQueryData<TaskChatHistory>(taskKeys.detail(chatId), {
queryClient.setQueryData<MothershipChatHistory>(mothershipChatKeys.detail(chatId), {
...previous,
resources: [...previous.resources, resource],
})
@@ -377,12 +382,12 @@ export function useAddChatResource(chatId?: string) {
},
onError: (_err, _variables, context) => {
if (context?.previous && chatId) {
queryClient.setQueryData(taskKeys.detail(chatId), context.previous)
queryClient.setQueryData(mothershipChatKeys.detail(chatId), context.previous)
}
},
onSettled: () => {
if (chatId) {
queryClient.invalidateQueries({ queryKey: taskKeys.detail(chatId) })
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(chatId) })
}
},
})
@@ -404,10 +409,12 @@ export function useReorderChatResources(chatId?: string) {
mutationFn: reorderChatResources,
onMutate: async ({ resources }) => {
if (!chatId) return
await queryClient.cancelQueries({ queryKey: taskKeys.detail(chatId) })
const previous = queryClient.getQueryData<TaskChatHistory>(taskKeys.detail(chatId))
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.detail(chatId) })
const previous = queryClient.getQueryData<MothershipChatHistory>(
mothershipChatKeys.detail(chatId)
)
if (previous) {
queryClient.setQueryData<TaskChatHistory>(taskKeys.detail(chatId), {
queryClient.setQueryData<MothershipChatHistory>(mothershipChatKeys.detail(chatId), {
...previous,
resources,
})
@@ -416,12 +423,12 @@ export function useReorderChatResources(chatId?: string) {
},
onError: (_err, _variables, context) => {
if (context?.previous && chatId) {
queryClient.setQueryData(taskKeys.detail(chatId), context.previous)
queryClient.setQueryData(mothershipChatKeys.detail(chatId), context.previous)
}
},
onSettled: () => {
if (chatId) {
queryClient.invalidateQueries({ queryKey: taskKeys.detail(chatId) })
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(chatId) })
}
},
})
@@ -444,11 +451,11 @@ export function useRemoveChatResource(chatId?: string) {
mutationFn: removeChatResource,
onMutate: async ({ resourceType, resourceId }) => {
if (!chatId) return
await queryClient.cancelQueries({ queryKey: taskKeys.detail(chatId) })
const removed: TaskChatHistory['resources'] = []
queryClient.setQueryData<TaskChatHistory>(taskKeys.detail(chatId), (prev) => {
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.detail(chatId) })
const removed: MothershipChatHistory['resources'] = []
queryClient.setQueryData<MothershipChatHistory>(mothershipChatKeys.detail(chatId), (prev) => {
if (!prev) return prev
const next: TaskChatHistory['resources'] = []
const next: MothershipChatHistory['resources'] = []
for (const r of prev.resources) {
if (r.type === resourceType && r.id === resourceId) removed.push(r)
else next.push(r)
@@ -459,26 +466,26 @@ export function useRemoveChatResource(chatId?: string) {
},
onError: (_err, _variables, context) => {
if (!chatId || !context?.removed.length) return
queryClient.setQueryData<TaskChatHistory>(taskKeys.detail(chatId), (prev) =>
queryClient.setQueryData<MothershipChatHistory>(mothershipChatKeys.detail(chatId), (prev) =>
prev ? { ...prev, resources: [...prev.resources, ...context.removed] } : prev
)
},
onSettled: () => {
if (chatId) {
queryClient.invalidateQueries({ queryKey: taskKeys.detail(chatId) })
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(chatId) })
}
},
})
}
async function markTaskRead(chatId: string): Promise<void> {
async function markChatRead(chatId: string): Promise<void> {
await requestJson(updateMothershipChatContract, {
params: { chatId },
body: { isUnread: false },
})
}
async function markTaskUnread(chatId: string): Promise<void> {
async function markChatUnread(chatId: string): Promise<void> {
await requestJson(updateMothershipChatContract, {
params: { chatId },
body: { isUnread: true },
@@ -491,81 +498,87 @@ function applyUnreadFlag(
chatId: string,
isUnread: boolean
): void {
const current = queryClient.getQueryData<TaskMetadata[]>(taskKeys.list(workspaceId))
const current = queryClient.getQueryData<MothershipChatMetadata[]>(
mothershipChatKeys.list(workspaceId)
)
if (!current) return
queryClient.setQueryData<TaskMetadata[]>(
taskKeys.list(workspaceId),
current.map((task) => (task.id === chatId ? { ...task, isUnread } : task))
queryClient.setQueryData<MothershipChatMetadata[]>(
mothershipChatKeys.list(workspaceId),
current.map((chat) => (chat.id === chatId ? { ...chat, isUnread } : chat))
)
}
/**
* Marks a task as read with optimistic update.
* Marks a chat as read with optimistic update.
*
* The server only updates `lastSeenAt`, never `updatedAt`, so we deliberately
* do not invalidate the list cache — that would trigger a refetch that can
* reorder the sidebar if any unrelated server-side update landed in between.
*
* If there is no cached list yet (initial fetch still in flight, e.g. on
* task-page refresh), we skip cancellation entirely so the in-flight fetch
* chat-page refresh), we skip cancellation entirely so the in-flight fetch
* can resolve normally — otherwise it would be orphaned and never refetched.
* `onSuccess` then reconciles whichever state the fetch produced.
*/
export function useMarkTaskRead(workspaceId?: string) {
export function useMarkMothershipChatRead(workspaceId?: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: markTaskRead,
mutationFn: markChatRead,
onMutate: async (chatId) => {
const previousTasks = queryClient.getQueryData<TaskMetadata[]>(taskKeys.list(workspaceId))
if (!previousTasks) return { previousTasks }
const previousChats = queryClient.getQueryData<MothershipChatMetadata[]>(
mothershipChatKeys.list(workspaceId)
)
if (!previousChats) return { previousChats }
await queryClient.cancelQueries({ queryKey: taskKeys.list(workspaceId) })
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
applyUnreadFlag(queryClient, workspaceId, chatId, false)
return { previousTasks }
return { previousChats }
},
onSuccess: (_data, chatId) => {
applyUnreadFlag(queryClient, workspaceId, chatId, false)
},
onError: (_err, _variables, context) => {
if (context?.previousTasks) {
queryClient.setQueryData(taskKeys.list(workspaceId), context.previousTasks)
if (context?.previousChats) {
queryClient.setQueryData(mothershipChatKeys.list(workspaceId), context.previousChats)
}
},
})
}
/**
* Marks a task as unread with optimistic update.
* Marks a chat as unread with optimistic update.
*
* Same rationale as `useMarkTaskRead` — no list invalidation, since the server
* Same rationale as `useMarkMothershipChatRead` — no list invalidation, since the server
* only flips `lastSeenAt` and the optimistic update fully reflects the change.
*/
export function useMarkTaskUnread(workspaceId?: string) {
export function useMarkMothershipChatUnread(workspaceId?: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: markTaskUnread,
mutationFn: markChatUnread,
onMutate: async (chatId) => {
const previousTasks = queryClient.getQueryData<TaskMetadata[]>(taskKeys.list(workspaceId))
if (!previousTasks) return { previousTasks }
const previousChats = queryClient.getQueryData<MothershipChatMetadata[]>(
mothershipChatKeys.list(workspaceId)
)
if (!previousChats) return { previousChats }
await queryClient.cancelQueries({ queryKey: taskKeys.list(workspaceId) })
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
applyUnreadFlag(queryClient, workspaceId, chatId, true)
return { previousTasks }
return { previousChats }
},
onSuccess: (_data, chatId) => {
applyUnreadFlag(queryClient, workspaceId, chatId, true)
},
onError: (_err, _variables, context) => {
if (context?.previousTasks) {
queryClient.setQueryData(taskKeys.list(workspaceId), context.previousTasks)
if (context?.previousChats) {
queryClient.setQueryData(mothershipChatKeys.list(workspaceId), context.previousChats)
}
},
})
}
async function setTaskPinned({
async function setChatPinned({
chatId,
pinned,
}: {
@@ -579,39 +592,41 @@ async function setTaskPinned({
}
/**
* Pins or unpins a task with optimistic update. Pinned tasks are sorted to
* Pins or unpins a chat with optimistic update. Pinned chats are sorted to
* the top of the list by the server; the optimistic reducer preserves that
* ordering by partitioning pinned and unpinned tasks while keeping each
* ordering by partitioning pinned and unpinned chats while keeping each
* partition in its existing order (server returns desc(updatedAt) within).
*/
export function useSetTaskPinned(workspaceId?: string) {
export function useSetMothershipChatPinned(workspaceId?: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: setTaskPinned,
mutationFn: setChatPinned,
onMutate: async ({ chatId, pinned }) => {
await queryClient.cancelQueries({ queryKey: taskKeys.list(workspaceId) })
const previousTasks = queryClient.getQueryData<TaskMetadata[]>(taskKeys.list(workspaceId))
if (!previousTasks) return { previousTasks: undefined }
const updated = previousTasks.map((task) =>
task.id === chatId ? { ...task, isPinned: pinned } : task
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
const previousChats = queryClient.getQueryData<MothershipChatMetadata[]>(
mothershipChatKeys.list(workspaceId)
)
const pinnedTasks = updated.filter((task) => task.isPinned)
const unpinnedTasks = updated.filter((task) => !task.isPinned)
queryClient.setQueryData<TaskMetadata[]>(taskKeys.list(workspaceId), [
...pinnedTasks,
...unpinnedTasks,
if (!previousChats) return { previousChats: undefined }
const updated = previousChats.map((chat) =>
chat.id === chatId ? { ...chat, isPinned: pinned } : chat
)
const pinnedChats = updated.filter((chat) => chat.isPinned)
const unpinnedChats = updated.filter((chat) => !chat.isPinned)
queryClient.setQueryData<MothershipChatMetadata[]>(mothershipChatKeys.list(workspaceId), [
...pinnedChats,
...unpinnedChats,
])
return { previousTasks }
return { previousChats }
},
onError: (_err, _variables, context) => {
if (context?.previousTasks) {
queryClient.setQueryData(taskKeys.list(workspaceId), context.previousTasks)
if (context?.previousChats) {
queryClient.setQueryData(mothershipChatKeys.list(workspaceId), context.previousChats)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
},
})
}
@@ -621,7 +636,7 @@ async function createChat(workspaceId: string): Promise<{ id: string }> {
return { id }
}
export function useCreateTask(workspaceId?: string) {
export function useCreateMothershipChat(workspaceId?: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => {
@@ -630,8 +645,10 @@ export function useCreateTask(workspaceId?: string) {
},
onSuccess: (data) => {
if (!workspaceId) return
const existing = queryClient.getQueryData<TaskMetadata[]>(taskKeys.list(workspaceId)) ?? []
const newTask: TaskMetadata = {
const existing =
queryClient.getQueryData<MothershipChatMetadata[]>(mothershipChatKeys.list(workspaceId)) ??
[]
const newChat: MothershipChatMetadata = {
id: data.id,
name: 'New chat',
updatedAt: new Date(),
@@ -639,17 +656,17 @@ export function useCreateTask(workspaceId?: string) {
isUnread: false,
isPinned: false,
}
const pinnedCount = existing.findIndex((task) => !task.isPinned)
const pinnedCount = existing.findIndex((chat) => !chat.isPinned)
const insertAt = pinnedCount === -1 ? existing.length : pinnedCount
queryClient.setQueryData<TaskMetadata[]>(taskKeys.list(workspaceId), [
queryClient.setQueryData<MothershipChatMetadata[]>(mothershipChatKeys.list(workspaceId), [
...existing.slice(0, insertAt),
newTask,
newChat,
...existing.slice(insertAt),
])
},
onSettled: () => {
if (!workspaceId) return
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
},
})
}
@@ -665,18 +682,20 @@ async function forkChat(params: {
return { id: data.id }
}
export function useForkTask(workspaceId?: string) {
export function useForkMothershipChat(workspaceId?: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: forkChat,
onSuccess: async (data, variables) => {
if (!workspaceId) return
await queryClient.cancelQueries({ queryKey: taskKeys.list(workspaceId) })
const existing = queryClient.getQueryData<TaskMetadata[]>(taskKeys.list(workspaceId))
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
const existing = queryClient.getQueryData<MothershipChatMetadata[]>(
mothershipChatKeys.list(workspaceId)
)
if (existing) {
const sourceTask = existing.find((t) => t.id === variables.chatId)
const baseName = (sourceTask?.name ?? 'New chat').replace(/^Fork \| /, '')
const optimisticTask: TaskMetadata = {
const sourceChat = existing.find((t) => t.id === variables.chatId)
const baseName = (sourceChat?.name ?? 'New chat').replace(/^Fork \| /, '')
const optimisticChat: MothershipChatMetadata = {
id: data.id,
name: `Fork | ${baseName}`,
updatedAt: new Date(),
@@ -684,18 +703,18 @@ export function useForkTask(workspaceId?: string) {
isUnread: false,
isPinned: false,
}
const pinnedCount = existing.findIndex((task) => !task.isPinned)
const pinnedCount = existing.findIndex((chat) => !chat.isPinned)
const insertAt = pinnedCount === -1 ? existing.length : pinnedCount
queryClient.setQueryData<TaskMetadata[]>(taskKeys.list(workspaceId), [
queryClient.setQueryData<MothershipChatMetadata[]>(mothershipChatKeys.list(workspaceId), [
...existing.slice(0, insertAt),
optimisticTask,
optimisticChat,
...existing.slice(insertAt),
])
}
},
onSettled: () => {
if (!workspaceId) return
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
},
})
}
@@ -4,10 +4,10 @@
import type { QueryClient } from '@tanstack/react-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { taskKeys } from '@/hooks/queries/tasks'
import { handleTaskStatusEvent } from '@/hooks/use-task-events'
import { mothershipChatKeys } from '@/hooks/queries/mothership-chats'
import { handleMothershipChatStatusEvent } from '@/hooks/use-mothership-chat-events'
describe('handleTaskStatusEvent', () => {
describe('handleMothershipChatStatusEvent', () => {
const queryClient = {
getQueryData: vi.fn(),
invalidateQueries: vi.fn().mockResolvedValue(undefined),
@@ -20,7 +20,7 @@ describe('handleTaskStatusEvent', () => {
})
it('invalidates the task list and detail for completed task events', () => {
handleTaskStatusEvent(
handleMothershipChatStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
@@ -32,10 +32,10 @@ describe('handleTaskStatusEvent', () => {
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(2)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
queryKey: mothershipChatKeys.list('ws-1'),
})
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.detail('chat-1'),
queryKey: mothershipChatKeys.detail('chat-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
@@ -49,7 +49,7 @@ describe('handleTaskStatusEvent', () => {
resources: [],
})
handleTaskStatusEvent(
handleMothershipChatStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
@@ -61,7 +61,7 @@ describe('handleTaskStatusEvent', () => {
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(1)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
queryKey: mothershipChatKeys.list('ws-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
@@ -75,7 +75,7 @@ describe('handleTaskStatusEvent', () => {
resources: [],
})
handleTaskStatusEvent(
handleMothershipChatStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
@@ -88,7 +88,7 @@ describe('handleTaskStatusEvent', () => {
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(1)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
queryKey: mothershipChatKeys.list('ws-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
@@ -102,7 +102,7 @@ describe('handleTaskStatusEvent', () => {
resources: [],
})
handleTaskStatusEvent(
handleMothershipChatStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
@@ -115,7 +115,7 @@ describe('handleTaskStatusEvent', () => {
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(1)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
queryKey: mothershipChatKeys.list('ws-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
@@ -129,7 +129,7 @@ describe('handleTaskStatusEvent', () => {
resources: [],
})
handleTaskStatusEvent(
handleMothershipChatStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
@@ -142,10 +142,10 @@ describe('handleTaskStatusEvent', () => {
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(2)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
queryKey: mothershipChatKeys.list('ws-1'),
})
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.detail('chat-1'),
queryKey: mothershipChatKeys.detail('chat-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
@@ -159,7 +159,7 @@ describe('handleTaskStatusEvent', () => {
resources: [],
})
handleTaskStatusEvent(
handleMothershipChatStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
@@ -172,10 +172,10 @@ describe('handleTaskStatusEvent', () => {
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(2)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
queryKey: mothershipChatKeys.list('ws-1'),
})
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.detail('chat-1'),
queryKey: mothershipChatKeys.detail('chat-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
@@ -189,7 +189,7 @@ describe('handleTaskStatusEvent', () => {
resources: [],
})
handleTaskStatusEvent(
handleMothershipChatStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
@@ -202,16 +202,16 @@ describe('handleTaskStatusEvent', () => {
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(2)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
queryKey: mothershipChatKeys.list('ws-1'),
})
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.detail('chat-1'),
queryKey: mothershipChatKeys.detail('chat-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
it('invalidates the task list and detail for metadata-changing task events', () => {
handleTaskStatusEvent(
handleMothershipChatStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
@@ -223,16 +223,16 @@ describe('handleTaskStatusEvent', () => {
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(2)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
queryKey: mothershipChatKeys.list('ws-1'),
})
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.detail('chat-1'),
queryKey: mothershipChatKeys.detail('chat-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
it('invalidates the task list and removes detail cache for deleted task events', () => {
handleTaskStatusEvent(
handleMothershipChatStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
@@ -244,16 +244,16 @@ describe('handleTaskStatusEvent', () => {
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(1)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
queryKey: mothershipChatKeys.list('ws-1'),
})
expect(queryClient.removeQueries).toHaveBeenCalledTimes(1)
expect(queryClient.removeQueries).toHaveBeenCalledWith({
queryKey: taskKeys.detail('chat-1'),
queryKey: mothershipChatKeys.detail('chat-1'),
})
})
it('invalidates the task list and detail for started task events', () => {
handleTaskStatusEvent(
handleMothershipChatStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
@@ -265,10 +265,10 @@ describe('handleTaskStatusEvent', () => {
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(2)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
queryKey: mothershipChatKeys.list('ws-1'),
})
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.detail('chat-1'),
queryKey: mothershipChatKeys.detail('chat-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
@@ -282,7 +282,7 @@ describe('handleTaskStatusEvent', () => {
resources: [],
})
handleTaskStatusEvent(
handleMothershipChatStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
@@ -294,7 +294,7 @@ describe('handleTaskStatusEvent', () => {
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(1)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
queryKey: mothershipChatKeys.list('ws-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
@@ -308,7 +308,7 @@ describe('handleTaskStatusEvent', () => {
resources: [],
})
handleTaskStatusEvent(
handleMothershipChatStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
@@ -321,7 +321,7 @@ describe('handleTaskStatusEvent', () => {
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(1)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
queryKey: mothershipChatKeys.list('ws-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
@@ -335,7 +335,7 @@ describe('handleTaskStatusEvent', () => {
resources: [],
})
handleTaskStatusEvent(
handleMothershipChatStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
@@ -348,7 +348,7 @@ describe('handleTaskStatusEvent', () => {
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(1)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
queryKey: mothershipChatKeys.list('ws-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
@@ -362,7 +362,7 @@ describe('handleTaskStatusEvent', () => {
resources: [],
})
handleTaskStatusEvent(
handleMothershipChatStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
@@ -375,16 +375,16 @@ describe('handleTaskStatusEvent', () => {
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(2)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
queryKey: mothershipChatKeys.list('ws-1'),
})
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.detail('chat-1'),
queryKey: mothershipChatKeys.detail('chat-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
it('keeps list invalidation only for unknown task event types', () => {
handleTaskStatusEvent(
handleMothershipChatStatusEvent(
queryClient,
'ws-1',
JSON.stringify({
@@ -396,13 +396,13 @@ describe('handleTaskStatusEvent', () => {
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(1)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: taskKeys.list('ws-1'),
queryKey: mothershipChatKeys.list('ws-1'),
})
expect(queryClient.removeQueries).not.toHaveBeenCalled()
})
it('does not invalidate when task event payload is invalid', () => {
handleTaskStatusEvent(queryClient, 'ws-1', '{')
handleMothershipChatStatusEvent(queryClient, 'ws-1', '{')
expect(queryClient.invalidateQueries).not.toHaveBeenCalled()
expect(queryClient.removeQueries).not.toHaveBeenCalled()
@@ -3,31 +3,31 @@ import { createLogger } from '@sim/logger'
import type { QueryClient } from '@tanstack/react-query'
import { useQueryClient } from '@tanstack/react-query'
import { getLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript'
import { type TaskChatHistory, taskKeys } from '@/hooks/queries/tasks'
import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats'
const logger = createLogger('TaskEvents')
const logger = createLogger('MothershipChatEvents')
const TASK_STATUS_TYPES = ['started', 'completed', 'created', 'deleted', 'renamed'] as const
type TaskStatusEventType = (typeof TASK_STATUS_TYPES)[number]
const TASK_STATUS_TYPE_SET = new Set<string>(TASK_STATUS_TYPES)
const CHAT_STATUS_TYPES = ['started', 'completed', 'created', 'deleted', 'renamed'] as const
type ChatStatusEventType = (typeof CHAT_STATUS_TYPES)[number]
const CHAT_STATUS_TYPE_SET = new Set<string>(CHAT_STATUS_TYPES)
interface TaskStatusEventPayload {
interface ChatStatusEventPayload {
chatId?: string
type?: TaskStatusEventType
type?: ChatStatusEventType
streamId?: string
}
const DETAIL_INVALIDATING_TASK_STATUS_TYPES = new Set<TaskStatusEventType>([
const DETAIL_INVALIDATING_CHAT_STATUS_TYPES = new Set<ChatStatusEventType>([
'started',
'completed',
'renamed',
])
function isTaskStatusEventType(value: unknown): value is TaskStatusEventType {
return typeof value === 'string' && TASK_STATUS_TYPE_SET.has(value)
function isChatStatusEventType(value: unknown): value is ChatStatusEventType {
return typeof value === 'string' && CHAT_STATUS_TYPE_SET.has(value)
}
function isLocalOptimisticActiveStream(current: TaskChatHistory | undefined) {
function isLocalOptimisticActiveStream(current: MothershipChatHistory | undefined) {
if (!current?.activeStreamId) return false
const liveAssistantId = getLiveAssistantMessageId(current.activeStreamId)
return current.messages.some((message) => message.id === liveAssistantId)
@@ -39,7 +39,7 @@ function isLocalOptimisticActiveStream(current: TaskChatHistory | undefined) {
* If either stream is absent from the transcript, callers should refetch
* instead of inferring order from incomplete cache state.
*/
function hasNewerKnownActiveStream(current: TaskChatHistory | undefined, streamId: string) {
function hasNewerKnownActiveStream(current: MothershipChatHistory | undefined, streamId: string) {
if (!current?.activeStreamId || current.activeStreamId === streamId) return false
const activeIndex = current.messages.findIndex((message) => message.id === current.activeStreamId)
@@ -50,8 +50,8 @@ function hasNewerKnownActiveStream(current: TaskChatHistory | undefined, streamI
}
function shouldSkipDetailInvalidationForStreamEvent(
current: TaskChatHistory | undefined,
payload: TaskStatusEventPayload
current: MothershipChatHistory | undefined,
payload: ChatStatusEventPayload
) {
if (payload.type !== 'started' && payload.type !== 'completed') return false
if (!current?.activeStreamId) return false
@@ -66,7 +66,7 @@ function shouldSkipDetailInvalidationForStreamEvent(
)
}
function parseTaskStatusEventPayload(data: unknown): TaskStatusEventPayload | null {
function parseChatStatusEventPayload(data: unknown): ChatStatusEventPayload | null {
let parsed = data
if (typeof parsed === 'string') {
@@ -85,43 +85,46 @@ function parseTaskStatusEventPayload(data: unknown): TaskStatusEventPayload | nu
return {
...(typeof record.chatId === 'string' ? { chatId: record.chatId } : {}),
...(isTaskStatusEventType(record.type) ? { type: record.type } : {}),
...(isChatStatusEventType(record.type) ? { type: record.type } : {}),
...(typeof record.streamId === 'string' ? { streamId: record.streamId } : {}),
}
}
export function handleTaskStatusEvent(
export function handleMothershipChatStatusEvent(
queryClient: Pick<QueryClient, 'getQueryData' | 'invalidateQueries' | 'removeQueries'>,
workspaceId: string,
data: unknown
): void {
const payload = parseTaskStatusEventPayload(data)
const payload = parseChatStatusEventPayload(data)
if (!payload) {
logger.warn('Received invalid task_status payload')
return
}
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
if (!payload.chatId) return
if (payload.type === 'deleted') {
queryClient.removeQueries({ queryKey: taskKeys.detail(payload.chatId) })
queryClient.removeQueries({ queryKey: mothershipChatKeys.detail(payload.chatId) })
return
}
if (payload.type === 'started' || payload.type === 'completed') {
const current = queryClient.getQueryData<TaskChatHistory>(taskKeys.detail(payload.chatId))
const current = queryClient.getQueryData<MothershipChatHistory>(
mothershipChatKeys.detail(payload.chatId)
)
if (shouldSkipDetailInvalidationForStreamEvent(current, payload)) {
return
}
}
if (payload.type && DETAIL_INVALIDATING_TASK_STATUS_TYPES.has(payload.type)) {
queryClient.invalidateQueries({ queryKey: taskKeys.detail(payload.chatId) })
if (payload.type && DETAIL_INVALIDATING_CHAT_STATUS_TYPES.has(payload.type)) {
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(payload.chatId) })
}
}
/**
* Subscribes to task status SSE events and invalidates task caches on changes.
* Subscribes to chat status SSE events and invalidates chat caches on changes.
* The SSE event name remains `task_status` for wire compatibility.
*/
export function useTaskEvents(workspaceId: string | undefined) {
export function useMothershipChatEvents(workspaceId: string | undefined) {
const queryClient = useQueryClient()
useEffect(() => {
@@ -132,7 +135,7 @@ export function useTaskEvents(workspaceId: string | undefined) {
)
eventSource.addEventListener('task_status', (event) => {
handleTaskStatusEvent(
handleMothershipChatStatusEvent(
queryClient,
workspaceId,
event instanceof MessageEvent ? event.data : undefined
@@ -201,7 +201,7 @@ export const removeMothershipChatResourceContract = defineRouteContract({
},
})
export const mothershipTaskSchema = z.object({
export const mothershipChatSchema = z.object({
id: z.string(),
title: z.string().nullable(),
updatedAt: dateStringSchema,
@@ -218,7 +218,7 @@ export const listMothershipChatsContract = defineRouteContract({
mode: 'json',
schema: z.object({
success: z.literal(true),
data: z.array(mothershipTaskSchema),
data: z.array(mothershipChatSchema),
}),
},
})
@@ -343,4 +343,4 @@ export const getMothershipChatContract = defineRouteContract({
},
})
export type MothershipTask = z.infer<typeof mothershipTaskSchema>
export type MothershipChat = z.infer<typeof mothershipChatSchema>
+42
View File
@@ -0,0 +1,42 @@
/**
* Chat Status Pub/Sub Adapter
*
* Broadcasts chat status events across processes using Redis Pub/Sub.
* Gracefully falls back to process-local EventEmitter when Redis is unavailable.
*
* The Redis channel and SSE label retain the legacy `task:status_changed`
* identifier so live status updates keep flowing across pods during a rolling
* deploy (old and new pods must publish/subscribe on the same channel).
*/
import { createPubSubChannel, type PubSubChannel } from '@/lib/events/pubsub'
interface ChatStatusEvent {
workspaceId: string
chatId: string
type: 'started' | 'completed' | 'created' | 'deleted' | 'renamed'
streamId?: string
}
type ChatPubSubGlobal = typeof globalThis & {
_chatStatusChannel?: PubSubChannel<ChatStatusEvent> | null
}
const g = globalThis as ChatPubSubGlobal
if (!('_chatStatusChannel' in g)) {
g._chatStatusChannel =
typeof window !== 'undefined'
? null
: createPubSubChannel<ChatStatusEvent>({ channel: 'task:status_changed', label: 'task' })
}
const channel = g._chatStatusChannel
export const chatPubSub = channel
? {
publishStatusChanged: (event: ChatStatusEvent) => channel.publish(event),
onStatusChanged: (handler: (event: ChatStatusEvent) => void) => channel.subscribe(handler),
dispose: () => channel.dispose(),
}
: null
+2 -2
View File
@@ -92,8 +92,8 @@ vi.mock('@/lib/copilot/chat/messages-store', () => ({
appendCopilotChatMessages,
}))
vi.mock('@/lib/copilot/tasks', () => ({
taskPubSub: {
vi.mock('@/lib/copilot/chat-status', () => ({
chatPubSub: {
publishStatusChanged: mockPublishStatusChanged,
},
}))
+5 -5
View File
@@ -23,6 +23,7 @@ import {
} from '@/lib/copilot/chat/process-contents'
import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state'
import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context'
import { chatPubSub } from '@/lib/copilot/chat-status'
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/constants'
import {
CopilotChatFinalizeOutcome,
@@ -41,7 +42,6 @@ import {
} from '@/lib/copilot/request/session'
import type { ExecutionContext, OrchestratorResult } from '@/lib/copilot/request/types'
import { persistChatResources } from '@/lib/copilot/resources/persistence'
import { taskPubSub } from '@/lib/copilot/tasks'
import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import { getWorkflowById, resolveWorkflowIdForUser } from '@/lib/workflows/utils'
@@ -352,7 +352,7 @@ async function persistUserMessage(params: {
)
if (notifyWorkspaceStatus && updated && workspaceId) {
taskPubSub?.publishStatusChanged({
chatPubSub?.publishStatusChanged({
workspaceId,
chatId,
type: 'started',
@@ -451,7 +451,7 @@ function buildOnComplete(params: {
finalization.outcome === CopilotChatFinalizeOutcome.AssistantAlreadyPersisted
if (notifyWorkspaceStatus && workspaceId && shouldPublishCompletion) {
taskPubSub?.publishStatusChanged({
chatPubSub?.publishStatusChanged({
workspaceId,
chatId,
type: 'completed',
@@ -470,7 +470,7 @@ function buildOnComplete(params: {
})
if (notifyWorkspaceStatus && workspaceId) {
taskPubSub?.publishStatusChanged({
chatPubSub?.publishStatusChanged({
workspaceId,
chatId,
type: 'completed',
@@ -502,7 +502,7 @@ function buildOnError(params: {
await finalizeAssistantTurn({ chatId, userMessageId })
if (notifyWorkspaceStatus && workspaceId) {
taskPubSub?.publishStatusChanged({
chatPubSub?.publishStatusChanged({
workspaceId,
chatId,
type: 'completed',
@@ -107,8 +107,8 @@ vi.mock('@sim/db', () => ({
},
}))
vi.mock('@/lib/copilot/tasks', () => ({
taskPubSub: null,
vi.mock('@/lib/copilot/chat-status', () => ({
chatPubSub: null,
}))
import { createSSEStream } from './start'
@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
import { chatPubSub } from '@/lib/copilot/chat-status'
import {
MothershipStreamV1EventType,
MothershipStreamV1SessionKind,
@@ -40,7 +41,6 @@ import {
import { SSE_RESPONSE_HEADERS } from '@/lib/copilot/request/session/sse'
import { reportTrace, TraceCollector } from '@/lib/copilot/request/trace'
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
import { taskPubSub } from '@/lib/copilot/tasks'
import { env } from '@/lib/core/config/env'
export { SSE_RESPONSE_HEADERS }
@@ -476,7 +476,7 @@ function fireTitleGeneration(params: {
payload: { kind: MothershipStreamV1SessionKind.title, title },
})
if (workspaceId) {
taskPubSub?.publishStatusChanged({
chatPubSub?.publishStatusChanged({
workspaceId,
chatId,
type: 'renamed',
-40
View File
@@ -1,40 +0,0 @@
/**
* Task Status Pub/Sub Adapter
*
* Broadcasts task status events across processes using Redis Pub/Sub.
* Gracefully falls back to process-local EventEmitter when Redis is unavailable.
*
* Channel: `task:status_changed`
*/
import { createPubSubChannel, type PubSubChannel } from '@/lib/events/pubsub'
interface TaskStatusEvent {
workspaceId: string
chatId: string
type: 'started' | 'completed' | 'created' | 'deleted' | 'renamed'
streamId?: string
}
type TaskPubSubGlobal = typeof globalThis & {
_taskStatusChannel?: PubSubChannel<TaskStatusEvent> | null
}
const g = globalThis as TaskPubSubGlobal
if (!('_taskStatusChannel' in g)) {
g._taskStatusChannel =
typeof window !== 'undefined'
? null
: createPubSubChannel<TaskStatusEvent>({ channel: 'task:status_changed', label: 'task' })
}
const channel = g._taskStatusChannel
export const taskPubSub = channel
? {
publishStatusChanged: (event: TaskStatusEvent) => channel.publish(event),
onStatusChanged: (handler: (event: TaskStatusEvent) => void) => channel.subscribe(handler),
dispose: () => channel.dispose(),
}
: null
+1 -1
View File
@@ -109,7 +109,7 @@ async function generateOAuthLink(
workflowId && workspaceId
? `${baseUrl}/workspace/${workspaceId}/w/${workflowId}`
: chatId && workspaceId
? `${baseUrl}/workspace/${workspaceId}/task/${chatId}`
? `${baseUrl}/workspace/${workspaceId}/chat/${chatId}`
: `${baseUrl}/workspace/${workspaceId}`
if (providerId === 'trello') {
+5 -5
View File
@@ -11,10 +11,10 @@ import {
buildPersistedUserMessage,
} from '@/lib/copilot/chat/persisted-message'
import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context'
import { chatPubSub } from '@/lib/copilot/chat-status'
import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless'
import { requestChatTitle } from '@/lib/copilot/request/lifecycle/start'
import type { OrchestratorResult } from '@/lib/copilot/request/types'
import { taskPubSub } from '@/lib/copilot/tasks'
import { isHosted } from '@/lib/core/config/feature-flags'
import * as agentmail from '@/lib/mothership/inbox/agentmail-client'
import { formatEmailAsMessage } from '@/lib/mothership/inbox/format'
@@ -117,7 +117,7 @@ export async function executeInboxTask(taskId: string): Promise<void> {
.then(async (title) => {
if (title && chatId) {
await db.update(copilotChats).set({ title }).where(eq(copilotChats.id, chatId))
taskPubSub?.publishStatusChanged({
chatPubSub?.publishStatusChanged({
workspaceId: ws.id,
chatId,
type: 'renamed',
@@ -128,7 +128,7 @@ export async function executeInboxTask(taskId: string): Promise<void> {
logger.warn('Failed to generate chat title', { chatId, err })
})
taskPubSub?.publishStatusChanged({
chatPubSub?.publishStatusChanged({
workspaceId: ws.id,
chatId,
type: 'created',
@@ -138,7 +138,7 @@ export async function executeInboxTask(taskId: string): Promise<void> {
const userMessageId = generateId()
if (chatId) {
taskPubSub?.publishStatusChanged({
chatPubSub?.publishStatusChanged({
workspaceId: ws.id,
chatId,
type: 'started',
@@ -260,7 +260,7 @@ export async function executeInboxTask(taskId: string): Promise<void> {
.where(eq(mothershipInboxTask.id, taskId))
if (chatId) {
taskPubSub?.publishStatusChanged({
chatPubSub?.publishStatusChanged({
workspaceId: ws.id,
chatId,
type: 'completed',
+1 -1
View File
@@ -32,7 +32,7 @@ export async function sendInboxResponse(
}
const chatUrl = inboxTask.chatId
? `${getBaseUrl()}/workspace/${ctx.workspaceId}/task/${inboxTask.chatId}`
? `${getBaseUrl()}/workspace/${ctx.workspaceId}/chat/${inboxTask.chatId}`
: `${getBaseUrl()}/workspace/${ctx.workspaceId}/home`
const text = result.success
+9
View File
@@ -315,6 +315,15 @@ const nextConfig: NextConfig = {
}
)
// Legacy chat URL support: the workspace chat route was renamed from
// `/workspace/:workspaceId/task/:chatId` to `/workspace/:workspaceId/chat/:chatId`.
// Preserve existing bookmarks and deeplinks.
redirects.push({
source: '/workspace/:workspaceId/task/:chatId',
destination: '/workspace/:workspaceId/chat/:chatId',
permanent: true,
})
return redirects
},
async rewrites() {
+47 -47
View File
@@ -9,8 +9,8 @@ interface FolderState {
selectedWorkflows: Set<string>
selectedFolders: Set<string>
lastSelectedFolderId: string | null
selectedTasks: Set<string>
lastSelectedTaskId: string | null
selectedChats: Set<string>
lastSelectedChatId: string | null
toggleExpanded: (folderId: string) => void
setExpanded: (folderId: string, expanded: boolean) => void
@@ -33,15 +33,15 @@ interface FolderState {
selectFolderRange: (folderIds: string[], fromId: string, toId: string) => void
isFolderSelected: (folderId: string) => boolean
// Task selection actions
selectTaskOnly: (taskId: string) => void
toggleTaskSelection: (taskId: string) => void
selectTaskRange: (taskIds: string[], fromId: string, toId: string) => void
clearTaskSelection: () => void
isTaskSelected: (taskId: string) => boolean
// Chat selection actions
selectChatOnly: (chatId: string) => void
toggleChatSelection: (chatId: string) => void
selectChatRange: (chatIds: string[], fromId: string, toId: string) => void
clearChatSelection: () => void
isChatSelected: (chatId: string) => boolean
// Unified selection helpers
getFullSelection: () => { workflowIds: string[]; folderIds: string[]; taskIds: string[] }
getFullSelection: () => { workflowIds: string[]; folderIds: string[]; chatIds: string[] }
hasAnySelection: () => boolean
isMixedSelection: () => boolean
clearAllSelection: () => void
@@ -54,8 +54,8 @@ export const useFolderStore = create<FolderState>()(
selectedWorkflows: new Set(),
selectedFolders: new Set(),
lastSelectedFolderId: null,
selectedTasks: new Set(),
lastSelectedTaskId: null,
selectedChats: new Set(),
lastSelectedChatId: null,
toggleExpanded: (folderId) =>
set((state) => {
@@ -104,9 +104,9 @@ export const useFolderStore = create<FolderState>()(
}
return {
selectedWorkflows: newSelected,
...(state.selectedTasks.size > 0 && {
selectedTasks: new Set<string>(),
lastSelectedTaskId: null,
...(state.selectedChats.size > 0 && {
selectedChats: new Set<string>(),
lastSelectedChatId: null,
}),
}
}),
@@ -118,8 +118,8 @@ export const useFolderStore = create<FolderState>()(
selectedWorkflows: new Set([workflowId]),
selectedFolders: new Set(),
lastSelectedFolderId: null,
selectedTasks: new Set(),
lastSelectedTaskId: null,
selectedChats: new Set(),
lastSelectedChatId: null,
}),
selectRange: (workflowIds, fromId, toId) => {
@@ -175,9 +175,9 @@ export const useFolderStore = create<FolderState>()(
return {
selectedFolders: newSelected,
lastSelectedFolderId: newLastSelected,
...(state.selectedTasks.size > 0 && {
selectedTasks: new Set<string>(),
lastSelectedTaskId: null,
...(state.selectedChats.size > 0 && {
selectedChats: new Set<string>(),
lastSelectedChatId: null,
}),
}
}),
@@ -188,8 +188,8 @@ export const useFolderStore = create<FolderState>()(
set({
selectedFolders: new Set([folderId]),
lastSelectedFolderId: folderId,
selectedTasks: new Set(),
lastSelectedTaskId: null,
selectedChats: new Set(),
lastSelectedChatId: null,
}),
selectFolderRange: (folderIds, fromId, toId) => {
@@ -206,11 +206,11 @@ export const useFolderStore = create<FolderState>()(
isFolderSelected: (folderId) => get().selectedFolders.has(folderId),
// Task selection actions
selectTaskOnly: (taskId) =>
// Chat selection actions
selectChatOnly: (chatId) =>
set((state) => ({
selectedTasks: new Set([taskId]),
lastSelectedTaskId: taskId,
selectedChats: new Set([chatId]),
lastSelectedChatId: chatId,
...(state.selectedWorkflows.size > 0 && { selectedWorkflows: new Set<string>() }),
...(state.selectedFolders.size > 0 && {
selectedFolders: new Set<string>(),
@@ -218,23 +218,23 @@ export const useFolderStore = create<FolderState>()(
}),
})),
toggleTaskSelection: (taskId) =>
toggleChatSelection: (chatId) =>
set((state) => {
const newSelected = new Set(state.selectedTasks)
const newSelected = new Set(state.selectedChats)
let newLastSelected: string | null
if (newSelected.has(taskId)) {
newSelected.delete(taskId)
if (newSelected.has(chatId)) {
newSelected.delete(chatId)
newLastSelected =
state.lastSelectedTaskId === taskId
state.lastSelectedChatId === chatId
? (Array.from(newSelected)[0] ?? null)
: state.lastSelectedTaskId
: state.lastSelectedChatId
} else {
newSelected.add(taskId)
newLastSelected = taskId
newSelected.add(chatId)
newLastSelected = chatId
}
return {
selectedTasks: newSelected,
lastSelectedTaskId: newLastSelected,
selectedChats: newSelected,
lastSelectedChatId: newLastSelected,
...(state.selectedWorkflows.size > 0 && { selectedWorkflows: new Set<string>() }),
...(state.selectedFolders.size > 0 && {
selectedFolders: new Set<string>(),
@@ -243,19 +243,19 @@ export const useFolderStore = create<FolderState>()(
}
}),
selectTaskRange: (taskIds, fromId, toId) => {
const fromIndex = taskIds.indexOf(fromId)
const toIndex = taskIds.indexOf(toId)
selectChatRange: (chatIds, fromId, toId) => {
const fromIndex = chatIds.indexOf(fromId)
const toIndex = chatIds.indexOf(toId)
if (fromIndex === -1 || toIndex === -1) return
const [start, end] = fromIndex < toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex]
const rangeIds = taskIds.slice(start, end + 1)
const rangeIds = chatIds.slice(start, end + 1)
const state = get()
set({
selectedTasks: new Set(rangeIds),
lastSelectedTaskId: fromId,
selectedChats: new Set(rangeIds),
lastSelectedChatId: fromId,
...(state.selectedWorkflows.size > 0 && { selectedWorkflows: new Set<string>() }),
...(state.selectedFolders.size > 0 && {
selectedFolders: new Set<string>(),
@@ -264,21 +264,21 @@ export const useFolderStore = create<FolderState>()(
})
},
clearTaskSelection: () => set({ selectedTasks: new Set(), lastSelectedTaskId: null }),
clearChatSelection: () => set({ selectedChats: new Set(), lastSelectedChatId: null }),
isTaskSelected: (taskId) => get().selectedTasks.has(taskId),
isChatSelected: (chatId) => get().selectedChats.has(chatId),
// Unified selection helpers
getFullSelection: () => ({
workflowIds: Array.from(get().selectedWorkflows),
folderIds: Array.from(get().selectedFolders),
taskIds: Array.from(get().selectedTasks),
chatIds: Array.from(get().selectedChats),
}),
hasAnySelection: () =>
get().selectedWorkflows.size > 0 ||
get().selectedFolders.size > 0 ||
get().selectedTasks.size > 0,
get().selectedChats.size > 0,
isMixedSelection: () => get().selectedWorkflows.size > 0 && get().selectedFolders.size > 0,
@@ -287,8 +287,8 @@ export const useFolderStore = create<FolderState>()(
selectedWorkflows: new Set(),
selectedFolders: new Set(),
lastSelectedFolderId: null,
selectedTasks: new Set(),
lastSelectedTaskId: null,
selectedChats: new Set(),
lastSelectedChatId: null,
}),
}),
{ name: 'folder-store' }