mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
-1
@@ -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
-6
@@ -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>
|
||||
|
||||
+2
-2
@@ -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])
|
||||
|
||||
|
||||
+2
-2
@@ -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() })
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+1
-1
@@ -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]
|
||||
|
||||
+16
-16
@@ -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
-1
@@ -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'
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
BlocksGroup,
|
||||
ChatsGroup,
|
||||
ConnectedAccountsGroup,
|
||||
DocsGroup,
|
||||
FilesGroup,
|
||||
@@ -7,7 +8,6 @@ export {
|
||||
KnowledgeBasesGroup,
|
||||
PagesGroup,
|
||||
TablesGroup,
|
||||
TasksGroup,
|
||||
ToolOpsGroup,
|
||||
ToolsGroup,
|
||||
TriggersGroup,
|
||||
|
||||
+1
-1
@@ -182,7 +182,7 @@ export const WorkflowsGroup = memo(function WorkflowsGroup({
|
||||
)
|
||||
})
|
||||
|
||||
export const TasksGroup = memo(function TasksGroup({
|
||||
export const ChatsGroup = memo(function ChatsGroup({
|
||||
items,
|
||||
onSelect,
|
||||
}: {
|
||||
|
||||
+9
-9
@@ -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} />
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
+11
-5
@@ -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) })
|
||||
},
|
||||
})
|
||||
}
|
||||
+42
-42
@@ -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
|
||||
+3
-3
@@ -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>
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
@@ -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') {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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' }
|
||||
|
||||
Reference in New Issue
Block a user