chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,721 @@
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import {
|
||||
keepPreviousData,
|
||||
skipToken,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { isApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
addMothershipChatResourceContract,
|
||||
createMothershipChatContract,
|
||||
deleteMothershipChatContract,
|
||||
forkMothershipChatContract,
|
||||
getMothershipChatContract,
|
||||
listMothershipChatsContract,
|
||||
type MothershipChat,
|
||||
removeMothershipChatResourceContract,
|
||||
reorderMothershipChatResourcesContract,
|
||||
updateMothershipChatContract,
|
||||
} from '@/lib/api/contracts/mothership-chats'
|
||||
import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message'
|
||||
import { normalizeMessage } from '@/lib/copilot/chat/persisted-message'
|
||||
import {
|
||||
type FilePreviewSession,
|
||||
isFilePreviewSession,
|
||||
} from '@/lib/copilot/request/session/file-preview-session-contract'
|
||||
import { isStreamBatchEvent, type StreamBatchEvent } from '@/lib/copilot/request/session/types'
|
||||
import { type MothershipResource, MothershipResourceType } from '@/lib/copilot/resources/types'
|
||||
import { useMothershipQueueStore } from '@/stores/mothership-queue/store'
|
||||
|
||||
export interface MothershipChatMetadata {
|
||||
id: string
|
||||
name: string
|
||||
updatedAt: Date
|
||||
isActive: boolean
|
||||
isUnread: boolean
|
||||
isPinned: boolean
|
||||
}
|
||||
|
||||
export interface MothershipChatHistory {
|
||||
id: string
|
||||
title: string | null
|
||||
messages: PersistedMessage[]
|
||||
activeStreamId: string | null
|
||||
resources: MothershipResource[]
|
||||
streamSnapshot?: {
|
||||
events: StreamBatchEvent[]
|
||||
previewSessions: FilePreviewSession[]
|
||||
status: string
|
||||
} | null
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
/** Shared by the `useMothershipChats` hook and the workspace sidebar prefetch. */
|
||||
export const MOTHERSHIP_CHAT_LIST_STALE_TIME = 60 * 1000
|
||||
export const MOTHERSHIP_CHAT_HISTORY_STALE_TIME = 30 * 1000
|
||||
|
||||
function assertValid(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
function isNullableString(value: unknown): value is string | null {
|
||||
return value === null || typeof value === 'string'
|
||||
}
|
||||
|
||||
function isResourceType(value: unknown): value is MothershipResource['type'] {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
Object.values(MothershipResourceType).some((type) => type === value)
|
||||
)
|
||||
}
|
||||
|
||||
function parseStreamSnapshot(value: unknown): MothershipChatHistory['streamSnapshot'] {
|
||||
if (!isRecordLike(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rawEvents = Array.isArray(value.events) ? value.events : []
|
||||
const events: StreamBatchEvent[] = []
|
||||
for (const entry of rawEvents) {
|
||||
if (!isStreamBatchEvent(entry)) {
|
||||
return null
|
||||
}
|
||||
events.push(entry)
|
||||
}
|
||||
|
||||
const rawPreviewSessions = Array.isArray(value.previewSessions) ? value.previewSessions : []
|
||||
const previewSessions: FilePreviewSession[] = []
|
||||
for (const session of rawPreviewSessions) {
|
||||
if (!isFilePreviewSession(session)) {
|
||||
return null
|
||||
}
|
||||
previewSessions.push(session)
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
previewSessions,
|
||||
status: typeof value.status === 'string' ? value.status : 'unknown',
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMessages(value: unknown): PersistedMessage[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return value.filter(isRecordLike).map((message) => normalizeMessage(message))
|
||||
}
|
||||
|
||||
function parseResource(value: unknown, context: string): MothershipResource {
|
||||
assertValid(isRecordLike(value), `${context} must be an object`)
|
||||
assertValid(isResourceType(value.type), `${context}.type is invalid`)
|
||||
assertValid(typeof value.id === 'string', `${context}.id must be a string`)
|
||||
assertValid(typeof value.title === 'string', `${context}.title must be a string`)
|
||||
|
||||
return {
|
||||
type: value.type,
|
||||
id: value.id,
|
||||
title: value.title,
|
||||
}
|
||||
}
|
||||
|
||||
function parseResources(value: unknown, context: string): MothershipResource[] {
|
||||
assertValid(Array.isArray(value), `${context} must be an array`)
|
||||
|
||||
return value.map((resource, index) => parseResource(resource, `${context}[${index}]`))
|
||||
}
|
||||
|
||||
function parseStrictStreamSnapshot(
|
||||
value: unknown,
|
||||
context: string
|
||||
): MothershipChatHistory['streamSnapshot'] {
|
||||
if (value === undefined || value === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const snapshot = parseStreamSnapshot(value)
|
||||
assertValid(snapshot !== null, `${context} is invalid`)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function parseChatHistory(value: unknown): MothershipChatHistory {
|
||||
const responseContext = 'Invalid chat response'
|
||||
const chatContext = `${responseContext}: chat`
|
||||
|
||||
assertValid(isRecordLike(value), `${responseContext}: body must be an object`)
|
||||
assertValid(isRecordLike(value.chat), `${chatContext} must be an object`)
|
||||
|
||||
const chat = value.chat
|
||||
|
||||
assertValid(typeof chat.id === 'string', `${chatContext}.id must be a string`)
|
||||
assertValid(isNullableString(chat.title), `${chatContext}.title must be a string or null`)
|
||||
assertValid(Array.isArray(chat.messages), `${chatContext}.messages must be an array`)
|
||||
assertValid(
|
||||
isNullableString(chat.activeStreamId),
|
||||
`${chatContext}.activeStreamId must be a string or null`
|
||||
)
|
||||
|
||||
return {
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
messages: normalizeMessages(chat.messages),
|
||||
activeStreamId: chat.activeStreamId,
|
||||
resources: parseResources(chat.resources, `${chatContext}.resources`),
|
||||
streamSnapshot: parseStrictStreamSnapshot(chat.streamSnapshot, `${chatContext}.streamSnapshot`),
|
||||
}
|
||||
}
|
||||
|
||||
function parseChatResourcesResponse(value: unknown): { resources: MothershipResource[] } {
|
||||
assertValid(isRecordLike(value), 'Invalid chat resources response: body must be an object')
|
||||
|
||||
return {
|
||||
resources: parseResources(value.resources, 'Invalid chat resources response: resources'),
|
||||
}
|
||||
}
|
||||
|
||||
export function mapChat(chat: MothershipChat): MothershipChatMetadata {
|
||||
const updatedAt = new Date(chat.updatedAt)
|
||||
return {
|
||||
id: chat.id,
|
||||
name: chat.title ?? 'New chat',
|
||||
updatedAt,
|
||||
isActive: chat.activeStreamId !== null,
|
||||
isUnread:
|
||||
chat.activeStreamId === null &&
|
||||
(chat.lastSeenAt === null || updatedAt > new Date(chat.lastSeenAt)),
|
||||
isPinned: chat.pinned,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMothershipChats(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<MothershipChatMetadata[]> {
|
||||
const data = await requestJson(listMothershipChatsContract, {
|
||||
query: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
return data.data.map(mapChat)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches mothership chat chats for a workspace.
|
||||
* These are workspace-scoped conversations from the Home page.
|
||||
*/
|
||||
export function useMothershipChats(workspaceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: mothershipChatKeys.list(workspaceId),
|
||||
queryFn: workspaceId ? ({ signal }) => fetchMothershipChats(workspaceId, signal) : skipToken,
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchMothershipChatHistory(
|
||||
chatId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<MothershipChatHistory> {
|
||||
try {
|
||||
const data = await requestJson(getMothershipChatContract, {
|
||||
params: { chatId },
|
||||
signal,
|
||||
})
|
||||
return parseChatHistory(data)
|
||||
} catch (error) {
|
||||
if (!isApiClientError(error)) throw error
|
||||
// Fall through to the legacy copilot-shape alias on any HTTP error (typically 404
|
||||
// when the chat lives in the older copilot table and isn't a mothership-typed row).
|
||||
}
|
||||
|
||||
// boundary-raw-fetch: legacy alias path /api/mothership/chat?chatId=... returns the
|
||||
// copilot lifecycle shape (activeStreamId, not conversationId) for chats stored under
|
||||
// the older copilot table; no contract exists for this alias path
|
||||
const copilotRes = await fetch(`/api/mothership/chat?chatId=${encodeURIComponent(chatId)}`, {
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!copilotRes.ok) {
|
||||
throw new Error('Failed to load chat')
|
||||
}
|
||||
|
||||
return parseChatHistory(await copilotRes.json())
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches chat history for a single chat (mothership chat).
|
||||
* Used by the chat page to load an existing conversation.
|
||||
*/
|
||||
export function useMothershipChatHistory(chatId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: mothershipChatKeys.detail(chatId),
|
||||
queryFn: chatId ? ({ signal }) => fetchMothershipChatHistory(chatId, signal) : skipToken,
|
||||
staleTime: MOTHERSHIP_CHAT_HISTORY_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteChat(chatId: string): Promise<void> {
|
||||
await requestJson(deleteMothershipChatContract, {
|
||||
params: { chatId },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a mothership chat chat and invalidates the chat list.
|
||||
*/
|
||||
export function useDeleteMothershipChat(workspaceId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: deleteChat,
|
||||
onSettled: (_data, _error, chatId) => {
|
||||
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
|
||||
queryClient.removeQueries({ queryKey: mothershipChatKeys.detail(chatId) })
|
||||
useMothershipQueueStore.getState().clearChat(chatId)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes multiple mothership chat chats and invalidates the chat list.
|
||||
*/
|
||||
export function useDeleteMothershipChats(workspaceId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (chatIds: string[]) => {
|
||||
await Promise.all(chatIds.map(deleteChat))
|
||||
},
|
||||
onSettled: (_data, _error, chatIds) => {
|
||||
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
|
||||
const queueStore = useMothershipQueueStore.getState()
|
||||
for (const chatId of chatIds) {
|
||||
queryClient.removeQueries({ queryKey: mothershipChatKeys.detail(chatId) })
|
||||
queueStore.clearChat(chatId)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function renameChat({ chatId, title }: { chatId: string; title: string }): Promise<void> {
|
||||
await requestJson(updateMothershipChatContract, {
|
||||
params: { chatId },
|
||||
body: { title },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a mothership chat chat with optimistic update.
|
||||
*/
|
||||
export function useRenameMothershipChat(workspaceId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: renameChat,
|
||||
onMutate: async ({ chatId, title }) => {
|
||||
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
|
||||
|
||||
const previousChats = queryClient.getQueryData<MothershipChatMetadata[]>(
|
||||
mothershipChatKeys.list(workspaceId)
|
||||
)
|
||||
|
||||
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?.previousChats) {
|
||||
queryClient.setQueryData(mothershipChatKeys.list(workspaceId), context.previousChats)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(variables.chatId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function addChatResource(params: {
|
||||
chatId: string
|
||||
resource: MothershipResource
|
||||
}): Promise<{ resources: MothershipResource[] }> {
|
||||
const data = await requestJson(addMothershipChatResourceContract, {
|
||||
body: { chatId: params.chatId, resource: params.resource },
|
||||
})
|
||||
return parseChatResourcesResponse(data)
|
||||
}
|
||||
|
||||
export function useAddChatResource(chatId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: addChatResource,
|
||||
onMutate: async ({ resource }) => {
|
||||
if (!chatId) return
|
||||
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<MothershipChatHistory>(mothershipChatKeys.detail(chatId), {
|
||||
...previous,
|
||||
resources: [...previous.resources, resource],
|
||||
})
|
||||
}
|
||||
}
|
||||
return { previous }
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previous && chatId) {
|
||||
queryClient.setQueryData(mothershipChatKeys.detail(chatId), context.previous)
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
if (chatId) {
|
||||
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(chatId) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function reorderChatResources(params: {
|
||||
chatId: string
|
||||
resources: MothershipResource[]
|
||||
}): Promise<{ resources: MothershipResource[] }> {
|
||||
const data = await requestJson(reorderMothershipChatResourcesContract, {
|
||||
body: { chatId: params.chatId, resources: params.resources },
|
||||
})
|
||||
return parseChatResourcesResponse(data)
|
||||
}
|
||||
|
||||
export function useReorderChatResources(chatId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: reorderChatResources,
|
||||
onMutate: async ({ resources }) => {
|
||||
if (!chatId) return
|
||||
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.detail(chatId) })
|
||||
const previous = queryClient.getQueryData<MothershipChatHistory>(
|
||||
mothershipChatKeys.detail(chatId)
|
||||
)
|
||||
if (previous) {
|
||||
queryClient.setQueryData<MothershipChatHistory>(mothershipChatKeys.detail(chatId), {
|
||||
...previous,
|
||||
resources,
|
||||
})
|
||||
}
|
||||
return { previous }
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previous && chatId) {
|
||||
queryClient.setQueryData(mothershipChatKeys.detail(chatId), context.previous)
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
if (chatId) {
|
||||
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(chatId) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function removeChatResource(params: {
|
||||
chatId: string
|
||||
resourceType: string
|
||||
resourceId: string
|
||||
}): Promise<{ resources: MothershipResource[] }> {
|
||||
const data = await requestJson(removeMothershipChatResourceContract, {
|
||||
body: params,
|
||||
})
|
||||
return parseChatResourcesResponse(data)
|
||||
}
|
||||
|
||||
export function useRemoveChatResource(chatId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: removeChatResource,
|
||||
onMutate: async ({ resourceType, resourceId }) => {
|
||||
if (!chatId) return
|
||||
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.detail(chatId) })
|
||||
const removed: MothershipChatHistory['resources'] = []
|
||||
queryClient.setQueryData<MothershipChatHistory>(mothershipChatKeys.detail(chatId), (prev) => {
|
||||
if (!prev) return prev
|
||||
const next: MothershipChatHistory['resources'] = []
|
||||
for (const r of prev.resources) {
|
||||
if (r.type === resourceType && r.id === resourceId) removed.push(r)
|
||||
else next.push(r)
|
||||
}
|
||||
return removed.length > 0 ? { ...prev, resources: next } : prev
|
||||
})
|
||||
return { removed }
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (!chatId || !context?.removed.length) return
|
||||
queryClient.setQueryData<MothershipChatHistory>(mothershipChatKeys.detail(chatId), (prev) =>
|
||||
prev ? { ...prev, resources: [...prev.resources, ...context.removed] } : prev
|
||||
)
|
||||
},
|
||||
onSettled: () => {
|
||||
if (chatId) {
|
||||
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(chatId) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function markChatRead(chatId: string): Promise<void> {
|
||||
await requestJson(updateMothershipChatContract, {
|
||||
params: { chatId },
|
||||
body: { isUnread: false },
|
||||
})
|
||||
}
|
||||
|
||||
async function markChatUnread(chatId: string): Promise<void> {
|
||||
await requestJson(updateMothershipChatContract, {
|
||||
params: { chatId },
|
||||
body: { isUnread: true },
|
||||
})
|
||||
}
|
||||
|
||||
function applyUnreadFlag(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
workspaceId: string | undefined,
|
||||
chatId: string,
|
||||
isUnread: boolean
|
||||
): void {
|
||||
const current = queryClient.getQueryData<MothershipChatMetadata[]>(
|
||||
mothershipChatKeys.list(workspaceId)
|
||||
)
|
||||
if (!current) return
|
||||
queryClient.setQueryData<MothershipChatMetadata[]>(
|
||||
mothershipChatKeys.list(workspaceId),
|
||||
current.map((chat) => (chat.id === chatId ? { ...chat, isUnread } : chat))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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 useMarkMothershipChatRead(workspaceId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: markChatRead,
|
||||
onMutate: async (chatId) => {
|
||||
const previousChats = queryClient.getQueryData<MothershipChatMetadata[]>(
|
||||
mothershipChatKeys.list(workspaceId)
|
||||
)
|
||||
if (!previousChats) return { previousChats }
|
||||
|
||||
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
|
||||
applyUnreadFlag(queryClient, workspaceId, chatId, false)
|
||||
|
||||
return { previousChats }
|
||||
},
|
||||
onSuccess: (_data, chatId) => {
|
||||
applyUnreadFlag(queryClient, workspaceId, chatId, false)
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previousChats) {
|
||||
queryClient.setQueryData(mothershipChatKeys.list(workspaceId), context.previousChats)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a chat as unread with optimistic update.
|
||||
*
|
||||
* Same rationale as `useMarkMothershipChatRead` — no list invalidation, since the server
|
||||
* only flips `lastSeenAt` and the optimistic update fully reflects the change.
|
||||
*/
|
||||
export function useMarkMothershipChatUnread(workspaceId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: markChatUnread,
|
||||
onMutate: async (chatId) => {
|
||||
const previousChats = queryClient.getQueryData<MothershipChatMetadata[]>(
|
||||
mothershipChatKeys.list(workspaceId)
|
||||
)
|
||||
if (!previousChats) return { previousChats }
|
||||
|
||||
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
|
||||
applyUnreadFlag(queryClient, workspaceId, chatId, true)
|
||||
|
||||
return { previousChats }
|
||||
},
|
||||
onSuccess: (_data, chatId) => {
|
||||
applyUnreadFlag(queryClient, workspaceId, chatId, true)
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previousChats) {
|
||||
queryClient.setQueryData(mothershipChatKeys.list(workspaceId), context.previousChats)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function setChatPinned({
|
||||
chatId,
|
||||
pinned,
|
||||
}: {
|
||||
chatId: string
|
||||
pinned: boolean
|
||||
}): Promise<void> {
|
||||
await requestJson(updateMothershipChatContract, {
|
||||
params: { chatId },
|
||||
body: { pinned },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 chats while keeping each
|
||||
* partition in its existing order (server returns desc(updatedAt) within).
|
||||
*/
|
||||
export function useSetMothershipChatPinned(workspaceId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: setChatPinned,
|
||||
onMutate: async ({ chatId, pinned }) => {
|
||||
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
|
||||
const previousChats = queryClient.getQueryData<MothershipChatMetadata[]>(
|
||||
mothershipChatKeys.list(workspaceId)
|
||||
)
|
||||
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 { previousChats }
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previousChats) {
|
||||
queryClient.setQueryData(mothershipChatKeys.list(workspaceId), context.previousChats)
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function createChat(workspaceId: string): Promise<{ id: string }> {
|
||||
const { id } = await requestJson(createMothershipChatContract, { body: { workspaceId } })
|
||||
return { id }
|
||||
}
|
||||
|
||||
export function useCreateMothershipChat(workspaceId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () => {
|
||||
if (!workspaceId) throw new Error('workspaceId is required')
|
||||
return createChat(workspaceId)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (!workspaceId) return
|
||||
const existing =
|
||||
queryClient.getQueryData<MothershipChatMetadata[]>(mothershipChatKeys.list(workspaceId)) ??
|
||||
[]
|
||||
const newChat: MothershipChatMetadata = {
|
||||
id: data.id,
|
||||
name: 'New chat',
|
||||
updatedAt: new Date(),
|
||||
isActive: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
}
|
||||
const pinnedCount = existing.findIndex((chat) => !chat.isPinned)
|
||||
const insertAt = pinnedCount === -1 ? existing.length : pinnedCount
|
||||
queryClient.setQueryData<MothershipChatMetadata[]>(mothershipChatKeys.list(workspaceId), [
|
||||
...existing.slice(0, insertAt),
|
||||
newChat,
|
||||
...existing.slice(insertAt),
|
||||
])
|
||||
},
|
||||
onSettled: () => {
|
||||
if (!workspaceId) return
|
||||
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function forkChat(params: {
|
||||
chatId: string
|
||||
upToMessageId: string
|
||||
}): Promise<{ id: string }> {
|
||||
const data = await requestJson(forkMothershipChatContract, {
|
||||
params: { chatId: params.chatId },
|
||||
body: { upToMessageId: params.upToMessageId },
|
||||
})
|
||||
return { id: data.id }
|
||||
}
|
||||
|
||||
export function useForkMothershipChat(workspaceId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: forkChat,
|
||||
onSuccess: async (data, variables) => {
|
||||
if (!workspaceId) return
|
||||
await queryClient.cancelQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
|
||||
const existing = queryClient.getQueryData<MothershipChatMetadata[]>(
|
||||
mothershipChatKeys.list(workspaceId)
|
||||
)
|
||||
if (existing) {
|
||||
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(),
|
||||
isActive: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
}
|
||||
const pinnedCount = existing.findIndex((chat) => !chat.isPinned)
|
||||
const insertAt = pinnedCount === -1 ? existing.length : pinnedCount
|
||||
queryClient.setQueryData<MothershipChatMetadata[]>(mothershipChatKeys.list(workspaceId), [
|
||||
...existing.slice(0, insertAt),
|
||||
optimisticChat,
|
||||
...existing.slice(insertAt),
|
||||
])
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
if (!workspaceId) return
|
||||
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user