Skip to content

Commit bbc79ed

Browse files
committed
feat(fork-chat): add fork chat to mothership
1 parent e85a243 commit bbc79ed

20 files changed

Lines changed: 18034 additions & 20 deletions

File tree

apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts

Lines changed: 531 additions & 0 deletions
Large diffs are not rendered by default.

apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts

Lines changed: 125 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
import { db } from '@sim/db'
2-
import { copilotChats } from '@sim/db/schema'
2+
import { copilotChats, workspaceFiles } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { generateId } from '@sim/utils/id'
5-
import { eq } from 'drizzle-orm'
5+
import { eq, inArray } from 'drizzle-orm'
66
import { type NextRequest, NextResponse } from 'next/server'
77
import { forkMothershipChatContract } from '@/lib/api/contracts/mothership-chats'
88
import { parseRequest } from '@/lib/api/server'
9+
import { checkStorageQuota } from '@/lib/billing/storage'
10+
import {
11+
executeChatFileBlobCopies,
12+
filterForkableChatFiles,
13+
listForkableChatFiles,
14+
planChatFileCopies,
15+
} from '@/lib/copilot/chat/fork-chat-files'
916
import { loadCopilotChatMessages } from '@/lib/copilot/chat/lifecycle'
1017
import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store'
18+
import {
19+
rewriteMessageFileRefs,
20+
rewriteResourceFileRefs,
21+
} from '@/lib/copilot/chat/rewrite-file-references'
1122
import { chatPubSub } from '@/lib/copilot/chat-status'
1223
import { fetchGo } from '@/lib/copilot/request/go/fetch'
1324
import {
@@ -18,6 +29,7 @@ import {
1829
createNotFoundResponse,
1930
createUnauthorizedResponse,
2031
} from '@/lib/copilot/request/http'
32+
import { removeChatResources } from '@/lib/copilot/resources/persistence'
2133
import type { MothershipResource } from '@/lib/copilot/resources/types'
2234
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
2335
import { env } from '@/lib/core/config/env'
@@ -33,7 +45,16 @@ const logger = createLogger('ForkChatAPI')
3345
/**
3446
* POST /api/mothership/chats/[chatId]/fork
3547
* Creates a new chat branched from the given chat, keeping messages up to and
36-
* including the specified message. Resources and copilot-side state are copied.
48+
* including the specified message, along with the chat's uploads born
49+
* at-or-before the fork point (a file travels iff the user message that
50+
* carried it is kept). Resources and copilot-side state are copied.
51+
*
52+
* Every copied file gets a fresh row id and storage key, bytes are physically
53+
* copied and counted against the storage quota, and every in-transcript file
54+
* reference is re-pointed at the copies so the new chat survives deletion of
55+
* the source chat. File resources whose chat-owned file was NOT copied
56+
* (uploads born after the cut) are dropped from the new chat's resources
57+
* rather than left as ghosts pointing at the source chat's files.
3758
*/
3859
export const POST = withRouteHandler(
3960
async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => {
@@ -82,17 +103,49 @@ export const POST = withRouteHandler(
82103
}
83104
const forkedMessages = messages.slice(0, forkIdx + 1)
84105

85-
// Resources are stored as a jsonb array on the chat row — copy them directly.
106+
// Single workspace_files read per fork: every chat-owned upload. The
107+
// copied set is timeline-cut to the kept message slice in memory (files
108+
// born after the fork point stay behind).
109+
const chatOwnedFiles = await listForkableChatFiles(db, chatId)
110+
const sourceFiles = filterForkableChatFiles(
111+
chatOwnedFiles,
112+
new Set(forkedMessages.map((m) => m.id))
113+
)
114+
// Sum only rows the plan will actually copy — planChatFileCopies skips
115+
// rows with no workspaceId, so counting their bytes could reject a fork
116+
// whose real copies fit within quota.
117+
const totalFileBytes = sourceFiles.reduce(
118+
(sum, row) => (row.workspaceId ? sum + row.size : sum),
119+
0
120+
)
121+
if (totalFileBytes > 0) {
122+
const quotaCheck = await checkStorageQuota(userId, totalFileBytes)
123+
if (!quotaCheck.allowed) {
124+
return createBadRequestResponse(quotaCheck.error || 'Storage limit exceeded')
125+
}
126+
}
127+
128+
// Resources are stored as a jsonb array on the chat row. They carry no
129+
// timestamps, so they can't be timeline-cut like messages — instead,
130+
// file resources whose chat-owned file is NOT copied (uploads born
131+
// after the cut) are dropped in the rewrite below; everything else is
132+
// copied.
86133
const parentResources = Array.isArray(parent.resources)
87134
? (parent.resources as MothershipResource[])
88135
: []
89136

137+
// The source chat's chat-owned file ids (no cut) — the "is this
138+
// resource a ghost?" test set for the rewrite.
139+
const chatOwnedFileIds = new Set(chatOwnedFiles.map((row) => row.id))
140+
90141
const newId = generateId()
142+
// Strip a leading "Fork | " so titles don't stack prefixes when forking
143+
// a forked chat.
91144
const baseTitle = (parent.title ?? 'New chat').replace(/^Fork \| /, '')
92145
const title = `Fork | ${baseTitle}`
93146
const now = new Date()
94147

95-
const newChat = await db.transaction(async (tx) => {
148+
const result = await db.transaction(async (tx) => {
96149
const [row] = await tx
97150
.insert(copilotChats)
98151
.values({
@@ -114,13 +167,72 @@ export const POST = withRouteHandler(
114167

115168
if (!row) return null
116169

117-
await appendCopilotChatMessages(newId, forkedMessages, { chatModel: parent.model }, tx)
118-
return row
170+
// File rows FK the new chat row, so the plan runs after the insert.
171+
const { idMap, keyMap, blobTasks } = await planChatFileCopies({
172+
tx,
173+
rows: sourceFiles,
174+
newChatId: newId,
175+
userId,
176+
now,
177+
})
178+
179+
const maps = { fileIds: idMap, fileKeys: keyMap }
180+
const newChatResources = rewriteResourceFileRefs(parentResources, maps, chatOwnedFileIds)
181+
// Skip the redundant update only when the rewrite changed nothing:
182+
// no ids re-pointed AND no ghost resources dropped. (idMap and keyMap
183+
// are populated in lockstep, so idMap alone decides the first half.)
184+
if (idMap.size > 0 || newChatResources.length !== parentResources.length) {
185+
await tx
186+
.update(copilotChats)
187+
.set({ resources: newChatResources })
188+
.where(eq(copilotChats.id, newId))
189+
}
190+
191+
await appendCopilotChatMessages(
192+
newId,
193+
rewriteMessageFileRefs(forkedMessages, maps),
194+
{ chatModel: parent.model },
195+
tx
196+
)
197+
return { row, blobTasks }
119198
})
120199

121-
if (!newChat) {
200+
if (!result) {
122201
return createInternalServerErrorResponse('Failed to create forked chat')
123202
}
203+
const newChat = result.row
204+
205+
const { copied, failed, failedCopyIds } = await executeChatFileBlobCopies(result.blobTasks, {
206+
userId,
207+
workspaceId: parent.workspaceId ?? undefined,
208+
})
209+
if (failed > 0) {
210+
// A failed blob copy leaves a committed row with no bytes behind it.
211+
// Cleanly absent beats present-but-broken: hard-delete the dead rows
212+
// (they vanish from the VFS listings and name resolution) and drop
213+
// their resource chips from the new chat. Inline transcript embeds
214+
// can't be healed — those 404 — which is what `failedFileCopies` in
215+
// the response warns the user about.
216+
try {
217+
await db.delete(workspaceFiles).where(inArray(workspaceFiles.id, failedCopyIds))
218+
await removeChatResources(
219+
newId,
220+
failedCopyIds.map((id) => ({ type: 'file' as const, id, title: '' }))
221+
)
222+
} catch (cleanupError) {
223+
logger.error('Failed to clean up dead file rows after blob-copy failure', {
224+
newChatId: newId,
225+
failedCopyIds,
226+
error: cleanupError,
227+
})
228+
}
229+
logger.warn('Some chat file blobs failed to copy during fork', {
230+
chatId,
231+
newChatId: newId,
232+
copied,
233+
failed,
234+
})
235+
}
124236

125237
// Clone copilot-service conversation state (messages, active_messages, memory files).
126238
// Best-effort: if the copilot service doesn't have a row for the source chat yet, skip.
@@ -168,7 +280,11 @@ export const POST = withRouteHandler(
168280
{ groups: { workspace: parent.workspaceId ?? '' } }
169281
)
170282

171-
return NextResponse.json({ success: true, id: newId })
283+
return NextResponse.json({
284+
success: true,
285+
id: newId,
286+
...(failed > 0 ? { failedFileCopies: failed } : {}),
287+
})
172288
} catch (error) {
173289
if (isWorkspaceAccessDeniedError(error)) {
174290
return createForbiddenResponse('Workspace access denied')

apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@ import {
1010
ChipModalHeader,
1111
cn,
1212
Duplicate,
13+
Split,
1314
ThumbsDown,
1415
ThumbsUp,
1516
Tooltip,
1617
toast,
1718
} from '@sim/emcn'
18-
import { GitBranch } from 'lucide-react'
1919
import { useParams, useRouter } from 'next/navigation'
20+
import { isLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript'
2021
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
2122
import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
2223
import { useForkMothershipChat } from '@/hooks/queries/mothership-chats'
@@ -153,6 +154,11 @@ export const MessageActions = memo(function MessageActions({
153154
if (!chatId || !messageId || forkChat.isPending) return
154155
try {
155156
const result = await forkChat.mutateAsync({ chatId, upToMessageId: messageId })
157+
if (result.failedFileCopies) {
158+
toast.warning(
159+
`${result.failedFileCopies} file${result.failedFileCopies === 1 ? '' : 's'} could not be copied to the fork`
160+
)
161+
}
156162
useFolderStore.getState().clearChatSelection()
157163
router.push(`/workspace/${params.workspaceId}/chat/${result.id}`)
158164
} catch {
@@ -162,7 +168,10 @@ export const MessageActions = memo(function MessageActions({
162168

163169
const hasContent = Boolean(content)
164170
const canSubmitFeedback = Boolean(chatId && userQuery)
165-
const canFork = false
171+
// A live (just-streamed) assistant message carries a synthetic id that the
172+
// persisted transcript doesn't know — forking it would 400. The button
173+
// appears once the transcript refetch swaps in the persisted message id.
174+
const canFork = Boolean(chatId && messageId && !isLiveAssistantMessageId(messageId))
166175
if (!hasContent && !canSubmitFeedback && !canFork) return null
167176

168177
return (
@@ -220,15 +229,15 @@ export const MessageActions = memo(function MessageActions({
220229
<Tooltip.Trigger asChild>
221230
<button
222231
type='button'
223-
aria-label='Fork from here'
232+
aria-label='Branch in new chat'
224233
onClick={handleFork}
225234
disabled={forkChat.isPending}
226235
className={cn(BUTTON_CLASS, forkChat.isPending && 'cursor-not-allowed opacity-50')}
227236
>
228-
<GitBranch className={ICON_CLASS} />
237+
<Split className={ICON_CLASS} />
229238
</button>
230239
</Tooltip.Trigger>
231-
<Tooltip.Content side='top'>Fork from here</Tooltip.Content>
240+
<Tooltip.Content side='top'>Branch in new chat</Tooltip.Content>
232241
</Tooltip.Root>
233242
)}
234243
</div>

apps/sim/hooks/queries/mothership-chats.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -675,12 +675,12 @@ export function useCreateMothershipChat(workspaceId?: string) {
675675
async function forkChat(params: {
676676
chatId: string
677677
upToMessageId: string
678-
}): Promise<{ id: string }> {
678+
}): Promise<{ id: string; failedFileCopies?: number }> {
679679
const data = await requestJson(forkMothershipChatContract, {
680680
params: { chatId: params.chatId },
681681
body: { upToMessageId: params.upToMessageId },
682682
})
683-
return { id: data.id }
683+
return { id: data.id, failedFileCopies: data.failedFileCopies }
684684
}
685685

686686
export function useForkMothershipChat(workspaceId?: string) {

apps/sim/lib/api/contracts/mothership-chats.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,13 @@ export const forkMothershipChatContract = defineRouteContract({
276276
schema: z.object({
277277
success: z.literal(true),
278278
id: z.string(),
279+
/**
280+
* Present (and > 0) when some file blobs could not be byte-copied: the
281+
* new chat exists and its transcript references those copies, but their
282+
* bytes are missing (blob copies are best-effort, post-transaction).
283+
* Callers should surface a warning.
284+
*/
285+
failedFileCopies: z.number().optional(),
279286
}),
280287
},
281288
})

apps/sim/lib/copilot/chat/effective-transcript.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest'
66
import {
77
buildEffectiveChatTranscript,
88
getLiveAssistantMessageId,
9+
isLiveAssistantMessageId,
910
} from '@/lib/copilot/chat/effective-transcript'
1011
import { normalizeMessage } from '@/lib/copilot/chat/persisted-message'
1112
import {
@@ -261,3 +262,11 @@ describe('buildEffectiveChatTranscript', () => {
261262
)
262263
})
263264
})
265+
266+
describe('isLiveAssistantMessageId', () => {
267+
it('recognizes the synthetic live-assistant id and nothing else', () => {
268+
expect(isLiveAssistantMessageId(getLiveAssistantMessageId('stream-1'))).toBe(true)
269+
expect(isLiveAssistantMessageId('f620fceb-4e9d-4e7f-ab7f-890a2a823564')).toBe(false)
270+
expect(isLiveAssistantMessageId('')).toBe(false)
271+
})
272+
})

apps/sim/lib/copilot/chat/effective-transcript.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@ export function getLiveAssistantMessageId(streamId: string): string {
3434
return `live-assistant:${streamId}`
3535
}
3636

37+
/**
38+
* True for the synthetic id of a streaming/just-streamed assistant message.
39+
* These ids exist only in the client's effective transcript — never in the
40+
* persisted one — so message-scoped server actions (e.g. fork) must not be
41+
* offered until the transcript refetch swaps in the persisted message id.
42+
*/
43+
export function isLiveAssistantMessageId(messageId: string): boolean {
44+
return messageId.startsWith('live-assistant:')
45+
}
46+
3747
function asPayloadRecord(value: unknown): Record<string, unknown> | undefined {
3848
return isRecordLike(value) ? value : undefined
3949
}

0 commit comments

Comments
 (0)