Skip to content

Commit 9b10e44

Browse files
committed
fix: copilot, improvement: tables, mothership
1 parent 5c6797a commit 9b10e44

File tree

20 files changed

+404
-564
lines changed

20 files changed

+404
-564
lines changed

apps/sim/app/api/mothership/chat/route.ts

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { db } from '@sim/db'
22
import { copilotChats } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { eq } from 'drizzle-orm'
4+
import { eq, sql } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
77
import { getSession } from '@/lib/auth'
@@ -123,14 +123,20 @@ export async function POST(req: NextRequest) {
123123
timestamp: new Date().toISOString(),
124124
}
125125

126-
await db
126+
const [updated] = await db
127127
.update(copilotChats)
128128
.set({
129-
messages: [...conversationHistory, userMsg],
129+
messages: sql`${copilotChats.messages} || ${JSON.stringify([userMsg])}::jsonb`,
130130
conversationId: userMessageId,
131131
updatedAt: new Date(),
132132
})
133133
.where(eq(copilotChats.id, actualChatId))
134+
.returning({ messages: copilotChats.messages })
135+
136+
if (updated) {
137+
const freshMessages: any[] = Array.isArray(updated.messages) ? updated.messages : []
138+
conversationHistory = freshMessages.filter((m: any) => m.id !== userMessageId)
139+
}
134140
}
135141

136142
const [workspaceContext, userPermission] = await Promise.all([
@@ -177,13 +183,6 @@ export async function POST(req: NextRequest) {
177183
onComplete: async (result: OrchestratorResult) => {
178184
if (!actualChatId) return
179185

180-
const userMessage = {
181-
id: userMessageId,
182-
role: 'user' as const,
183-
content: message,
184-
timestamp: new Date().toISOString(),
185-
}
186-
187186
const assistantMessage: Record<string, unknown> = {
188187
id: crypto.randomUUID(),
189188
role: 'assistant' as const,
@@ -194,16 +193,29 @@ export async function POST(req: NextRequest) {
194193
assistantMessage.toolCalls = result.toolCalls
195194
}
196195

197-
const updatedMessages = [...conversationHistory, userMessage, assistantMessage]
198-
199196
try {
200-
await db
201-
.update(copilotChats)
202-
.set({
203-
messages: updatedMessages,
204-
conversationId: null,
205-
})
197+
const [row] = await db
198+
.select({ messages: copilotChats.messages })
199+
.from(copilotChats)
206200
.where(eq(copilotChats.id, actualChatId))
201+
.limit(1)
202+
203+
const msgs: any[] = Array.isArray(row?.messages) ? row.messages : []
204+
const userIdx = msgs.findIndex((m: any) => m.id === userMessageId)
205+
const alreadyHasResponse =
206+
userIdx >= 0 &&
207+
userIdx + 1 < msgs.length &&
208+
(msgs[userIdx + 1] as any)?.role === 'assistant'
209+
210+
if (!alreadyHasResponse) {
211+
await db
212+
.update(copilotChats)
213+
.set({
214+
messages: sql`${copilotChats.messages} || ${JSON.stringify([assistantMessage])}::jsonb`,
215+
conversationId: sql`CASE WHEN ${copilotChats.conversationId} = ${userMessageId} THEN NULL ELSE ${copilotChats.conversationId} END`,
216+
})
217+
.where(eq(copilotChats.id, actualChatId))
218+
}
207219
} catch (error) {
208220
logger.error(`[${tracker.requestId}] Failed to persist chat messages`, {
209221
chatId: actualChatId,
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { db } from '@sim/db'
2+
import { copilotChats } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { and, eq, sql } from 'drizzle-orm'
5+
import { type NextRequest, NextResponse } from 'next/server'
6+
import { z } from 'zod'
7+
import { getSession } from '@/lib/auth'
8+
9+
const logger = createLogger('MothershipChatStopAPI')
10+
11+
const StopSchema = z.object({
12+
chatId: z.string(),
13+
streamId: z.string(),
14+
content: z.string(),
15+
})
16+
17+
/**
18+
* POST /api/mothership/chat/stop
19+
* Persists partial assistant content when the user stops a stream mid-response.
20+
* Clears conversationId so the server-side onComplete won't duplicate the message.
21+
*/
22+
export async function POST(req: NextRequest) {
23+
try {
24+
const session = await getSession()
25+
if (!session?.user?.id) {
26+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
27+
}
28+
29+
const { chatId, streamId, content } = StopSchema.parse(await req.json())
30+
31+
const setClause: Record<string, unknown> = {
32+
conversationId: null,
33+
updatedAt: new Date(),
34+
}
35+
36+
if (content.trim()) {
37+
const assistantMessage = {
38+
id: crypto.randomUUID(),
39+
role: 'assistant' as const,
40+
content,
41+
timestamp: new Date().toISOString(),
42+
}
43+
setClause.messages = sql`${copilotChats.messages} || ${JSON.stringify([assistantMessage])}::jsonb`
44+
}
45+
46+
await db
47+
.update(copilotChats)
48+
.set(setClause)
49+
.where(
50+
and(
51+
eq(copilotChats.id, chatId),
52+
eq(copilotChats.userId, session.user.id),
53+
eq(copilotChats.conversationId, streamId)
54+
)
55+
)
56+
57+
return NextResponse.json({ success: true })
58+
} catch (error) {
59+
if (error instanceof z.ZodError) {
60+
return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
61+
}
62+
logger.error('Error stopping chat stream:', error)
63+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
64+
}
65+
}

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
import { lazy, Suspense, useMemo } from 'react'
44
import { Skeleton } from '@/components/emcn'
5-
import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
5+
import {
6+
FileViewer,
7+
isPreviewable,
8+
} from '@/app/workspace/[workspaceId]/files/components/file-viewer'
69
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
710
import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/components'
811
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
@@ -68,7 +71,13 @@ function EmbeddedFile({ workspaceId, fileId }: EmbeddedFileProps) {
6871

6972
return (
7073
<div className='flex h-full flex-col overflow-hidden'>
71-
<FileViewer key={file.id} file={file} workspaceId={workspaceId} canEdit={true} />
74+
<FileViewer
75+
key={file.id}
76+
file={file}
77+
workspaceId={workspaceId}
78+
canEdit={true}
79+
showPreview={isPreviewable(file)}
80+
/>
7281
</div>
7382
)
7483
}
Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,60 @@
11
'use client'
22

3+
import type { ElementType } from 'react'
4+
import { Button } from '@/components/emcn'
5+
import { Table as TableIcon } from '@/components/emcn/icons'
6+
import { WorkflowIcon } from '@/components/icons'
7+
import { getDocumentIcon } from '@/components/icons/document-icons'
38
import { cn } from '@/lib/core/utils/cn'
4-
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
9+
import type {
10+
MothershipResource,
11+
MothershipResourceType,
12+
} from '@/app/workspace/[workspaceId]/home/types'
513

614
interface ResourceTabsProps {
715
resources: MothershipResource[]
816
activeId: string | null
917
onSelect: (id: string) => void
1018
}
1119

20+
const RESOURCE_ICONS: Record<Exclude<MothershipResourceType, 'file'>, ElementType> = {
21+
table: TableIcon,
22+
workflow: WorkflowIcon,
23+
}
24+
25+
function getResourceIcon(resource: MothershipResource): ElementType {
26+
if (resource.type === 'file') {
27+
return getDocumentIcon('', resource.title)
28+
}
29+
return RESOURCE_ICONS[resource.type]
30+
}
31+
1232
/**
1333
* Horizontal tab bar for switching between mothership resources.
14-
* Mirrors the role of ResourceHeader in the Resource abstraction.
34+
* Renders each resource as a subtle Button matching ResourceHeader actions.
1535
*/
1636
export function ResourceTabs({ resources, activeId, onSelect }: ResourceTabsProps) {
1737
return (
18-
<div className='flex shrink-0 gap-[2px] overflow-x-auto border-[var(--border)] border-b px-[12px]'>
19-
{resources.map((resource) => (
20-
<button
21-
key={resource.id}
22-
type='button'
23-
onClick={() => onSelect(resource.id)}
24-
className={cn(
25-
'shrink-0 cursor-pointer border-b-[2px] px-[12px] py-[10px] text-[13px] transition-colors',
26-
activeId === resource.id
27-
? 'border-[var(--text-primary)] font-medium text-[var(--text-primary)]'
28-
: 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
29-
)}
30-
>
31-
{resource.title}
32-
</button>
33-
))}
38+
<div className='flex shrink-0 items-center gap-[6px] overflow-x-auto border-[var(--border)] border-b px-[16px] py-[8.5px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
39+
{resources.map((resource) => {
40+
const Icon = getResourceIcon(resource)
41+
const isActive = activeId === resource.id
42+
43+
return (
44+
<Button
45+
key={resource.id}
46+
variant='subtle'
47+
onClick={() => onSelect(resource.id)}
48+
className={cn(
49+
'shrink-0 border border-transparent bg-transparent px-[8px] py-[4px] text-[12px]',
50+
isActive && 'border-[var(--border)] bg-[var(--surface-4)]'
51+
)}
52+
>
53+
<Icon className={cn('mr-[6px] h-[14px] w-[14px] text-[var(--text-icon)]')} />
54+
{resource.title}
55+
</Button>
56+
)
57+
})}
3458
</div>
3559
)
3660
}

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export interface UseChatReturn {
4646
size: number
4747
}>
4848
) => Promise<void>
49-
stopGeneration: () => void
49+
stopGeneration: () => Promise<void>
5050
chatBottomRef: React.RefObject<HTMLDivElement | null>
5151
resources: MothershipResource[]
5252
activeResourceId: string | null
@@ -140,6 +140,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
140140
const sendingRef = useRef(false)
141141
const toolArgsMapRef = useRef<Map<string, Record<string, unknown>>>(new Map())
142142
const streamGenRef = useRef(0)
143+
const streamingContentRef = useRef('')
143144

144145
const isHomePage = pathname.endsWith('/home')
145146

@@ -212,6 +213,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
212213
let lastWorkflowId: string | null = null
213214
let runningText = ''
214215

216+
streamingContentRef.current = ''
215217
toolArgsMapRef.current.clear()
216218

217219
const ensureTextBlock = (): ContentBlock => {
@@ -283,6 +285,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
283285
const tb = ensureTextBlock()
284286
tb.content = (tb.content ?? '') + chunk
285287
runningText += chunk
288+
streamingContentRef.current = runningText
286289
flush()
287290
}
288291
break
@@ -427,6 +430,24 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
427430
[workspaceId, queryClient, addResource]
428431
)
429432

433+
const persistPartialResponse = useCallback(async () => {
434+
const chatId = chatIdRef.current
435+
const streamId = streamIdRef.current
436+
if (!chatId || !streamId) return
437+
438+
const content = streamingContentRef.current
439+
try {
440+
const res = await fetch('/api/mothership/chat/stop', {
441+
method: 'POST',
442+
headers: { 'Content-Type': 'application/json' },
443+
body: JSON.stringify({ chatId, streamId, content }),
444+
})
445+
if (res.ok) streamingContentRef.current = ''
446+
} catch (err) {
447+
logger.warn('Failed to persist partial response', err)
448+
}
449+
}, [])
450+
430451
const invalidateChatQueries = useCallback(() => {
431452
const activeChatId = chatIdRef.current
432453
if (activeChatId) {
@@ -495,6 +516,9 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
495516
) => {
496517
if (!message.trim() || !workspaceId) return
497518

519+
if (sendingRef.current) {
520+
await persistPartialResponse()
521+
}
498522
abortControllerRef.current?.abort()
499523

500524
const gen = ++streamGenRef.current
@@ -566,17 +590,20 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
566590
}
567591
}
568592
},
569-
[workspaceId, queryClient, processSSEStream, finalize]
593+
[workspaceId, queryClient, processSSEStream, finalize, persistPartialResponse]
570594
)
571595

572-
const stopGeneration = useCallback(() => {
596+
const stopGeneration = useCallback(async () => {
597+
if (sendingRef.current) {
598+
await persistPartialResponse()
599+
}
573600
streamGenRef.current++
574601
abortControllerRef.current?.abort()
575602
abortControllerRef.current = null
576603
sendingRef.current = false
577604
setIsSending(false)
578605
invalidateChatQueries()
579-
}, [invalidateChatQueries])
606+
}, [invalidateChatQueries, persistPartialResponse])
580607

581608
useEffect(() => {
582609
return () => {

0 commit comments

Comments
 (0)