Skip to content

Commit 48f2804

Browse files
committed
feat(mothership): resource viewer
1 parent 2ace725 commit 48f2804

File tree

5 files changed

+317
-71
lines changed

5 files changed

+317
-71
lines changed
Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,90 @@
11
'use client'
22

3-
export function MothershipView() {
3+
import { useMemo } from 'react'
4+
import { Skeleton } from '@/components/emcn'
5+
import { cn } from '@/lib/core/utils/cn'
6+
import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
7+
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
8+
import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/components'
9+
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
10+
11+
interface MothershipViewProps {
12+
workspaceId: string
13+
resources: MothershipResource[]
14+
activeResourceId: string | null
15+
onSelectResource: (id: string) => void
16+
}
17+
18+
export function MothershipView({
19+
workspaceId,
20+
resources,
21+
activeResourceId,
22+
onSelectResource,
23+
}: MothershipViewProps) {
24+
const active = resources.find((r) => r.id === activeResourceId) ?? resources[0] ?? null
25+
426
return (
5-
<div className='flex h-full w-[480px] flex-shrink-0 flex-col border-[var(--border)] border-l'>
6-
<div className='flex items-center border-[var(--border)] border-b px-[16px] py-[12px]'>
7-
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>Mothership</span>
27+
<div className='flex h-full w-[50%] min-w-[400px] flex-col border-[var(--border)] border-l'>
28+
<div className='flex shrink-0 gap-[2px] overflow-x-auto border-[var(--border)] border-b px-[12px]'>
29+
{resources.map((r) => (
30+
<button
31+
key={r.id}
32+
type='button'
33+
onClick={() => onSelectResource(r.id)}
34+
className={cn(
35+
'shrink-0 cursor-pointer border-b-[2px] px-[12px] py-[10px] text-[13px] transition-colors',
36+
active?.id === r.id
37+
? 'border-[var(--text-primary)] font-medium text-[var(--text-primary)]'
38+
: 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
39+
)}
40+
>
41+
{r.title}
42+
</button>
43+
))}
844
</div>
9-
<div className='flex flex-1 items-center justify-center'>
10-
<span className='text-[13px] text-[var(--text-muted)]'>No artifacts yet</span>
45+
46+
<div className='min-h-0 flex-1 overflow-hidden'>
47+
{active?.type === 'table' && (
48+
<Table key={active.id} workspaceId={workspaceId} tableId={active.id} embedded />
49+
)}
50+
{active?.type === 'file' && (
51+
<EmbeddedFile key={active.id} workspaceId={workspaceId} fileId={active.id} />
52+
)}
1153
</div>
1254
</div>
1355
)
1456
}
57+
58+
interface EmbeddedFileProps {
59+
workspaceId: string
60+
fileId: string
61+
}
62+
63+
function EmbeddedFile({ workspaceId, fileId }: EmbeddedFileProps) {
64+
const { data: files = [], isLoading } = useWorkspaceFiles(workspaceId)
65+
const file = useMemo(() => files.find((f) => f.id === fileId), [files, fileId])
66+
67+
if (isLoading) {
68+
return (
69+
<div className='flex h-full flex-col gap-[8px] p-[24px]'>
70+
<Skeleton className='h-[16px] w-[60%]' />
71+
<Skeleton className='h-[16px] w-[80%]' />
72+
<Skeleton className='h-[16px] w-[40%]' />
73+
</div>
74+
)
75+
}
76+
77+
if (!file) {
78+
return (
79+
<div className='flex h-full items-center justify-center'>
80+
<span className='text-[13px] text-[var(--text-muted)]'>File not found</span>
81+
</div>
82+
)
83+
}
84+
85+
return (
86+
<div className='flex h-full flex-col overflow-hidden'>
87+
<FileViewer key={file.id} file={file} workspaceId={workspaceId} canEdit={true} />
88+
</div>
89+
)
90+
}

apps/sim/app/workspace/[workspaceId]/home/home.tsx

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

33
import { useCallback, useState } from 'react'
44
import { useParams } from 'next/navigation'
5-
import { MessageContent, UserInput } from './components'
5+
import { MessageContent, MothershipView, UserInput } from './components'
66
import { useChat } from './hooks'
77

88
interface HomeProps {
@@ -12,10 +12,16 @@ interface HomeProps {
1212
export function Home({ chatId }: HomeProps = {}) {
1313
const { workspaceId } = useParams<{ workspaceId: string }>()
1414
const [inputValue, setInputValue] = useState('')
15-
const { messages, isSending, sendMessage, stopGeneration, chatBottomRef } = useChat(
16-
workspaceId,
17-
chatId
18-
)
15+
const {
16+
messages,
17+
isSending,
18+
sendMessage,
19+
stopGeneration,
20+
chatBottomRef,
21+
resources,
22+
activeResourceId,
23+
setActiveResourceId,
24+
} = useChat(workspaceId, chatId)
1925

2026
const handleSubmit = useCallback(() => {
2127
const trimmed = inputValue.trim()
@@ -102,6 +108,15 @@ export function Home({ chatId }: HomeProps = {}) {
102108
/>
103109
</div>
104110
</div>
111+
112+
{resources.length > 0 && (
113+
<MothershipView
114+
workspaceId={workspaceId}
115+
resources={resources}
116+
activeResourceId={activeResourceId}
117+
onSelectResource={setActiveResourceId}
118+
/>
119+
)}
105120
</div>
106121
)
107122
}

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

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
import { useCallback, useEffect, useRef, useState } from 'react'
2+
import { createLogger } from '@sim/logger'
23
import { useQueryClient } from '@tanstack/react-query'
34
import { usePathname, useRouter } from 'next/navigation'
45
import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
6+
import { tableKeys } from '@/hooks/queries/tables'
57
import {
68
type TaskChatHistory,
79
type TaskStoredContentBlock,
810
type TaskStoredMessage,
911
taskKeys,
1012
useChatHistory,
1113
} from '@/hooks/queries/tasks'
14+
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
1215
import type {
1316
ChatMessage,
1417
ContentBlock,
1518
ContentBlockType,
19+
MothershipResource,
1620
SSEPayload,
1721
SSEPayloadData,
1822
ToolCallStatus,
@@ -26,6 +30,9 @@ export interface UseChatReturn {
2630
sendMessage: (message: string) => Promise<void>
2731
stopGeneration: () => void
2832
chatBottomRef: React.RefObject<HTMLDivElement | null>
33+
resources: MothershipResource[]
34+
activeResourceId: string | null
35+
setActiveResourceId: (id: string | null) => void
2936
}
3037

3138
const STATE_TO_STATUS: Record<string, ToolCallStatus> = {
@@ -65,10 +72,66 @@ function mapStoredMessage(msg: TaskStoredMessage): ChatMessage {
6572
return mapped
6673
}
6774

75+
const logger = createLogger('useChat')
76+
6877
function getPayloadData(payload: SSEPayload): SSEPayloadData | undefined {
6978
return typeof payload.data === 'object' ? payload.data : undefined
7079
}
7180

81+
const RESOURCE_TOOL_NAMES = new Set(['user_table', 'workspace_file'])
82+
83+
function getResultData(parsed: SSEPayload): Record<string, unknown> | undefined {
84+
const topResult = parsed.result as Record<string, unknown> | undefined
85+
const nestedResult =
86+
typeof parsed.data === 'object' ? (parsed.data?.result as Record<string, unknown>) : undefined
87+
const result = topResult ?? nestedResult
88+
return result?.data as Record<string, unknown> | undefined
89+
}
90+
91+
function extractTableResource(
92+
parsed: SSEPayload,
93+
storedArgs: Record<string, unknown> | undefined,
94+
fallbackTableId: string | null
95+
): MothershipResource | null {
96+
const data = getResultData(parsed)
97+
const storedInnerArgs = storedArgs?.args as Record<string, unknown> | undefined
98+
99+
const table = data?.table as Record<string, unknown> | undefined
100+
if (table?.id) {
101+
return { type: 'table', id: table.id as string, title: (table.name as string) || 'Table' }
102+
}
103+
104+
const tableId =
105+
(data?.tableId as string) ?? storedInnerArgs?.tableId ?? storedArgs?.tableId ?? fallbackTableId
106+
const tableName = (data?.tableName as string) || (table?.name as string) || 'Table'
107+
if (tableId) return { type: 'table', id: tableId as string, title: tableName }
108+
109+
return null
110+
}
111+
112+
function extractFileResource(
113+
parsed: SSEPayload,
114+
storedArgs: Record<string, unknown> | undefined
115+
): MothershipResource | null {
116+
const data = getResultData(parsed)
117+
const storedInnerArgs = storedArgs?.args as Record<string, unknown> | undefined
118+
119+
const file = data?.file as Record<string, unknown> | undefined
120+
if (file?.id) {
121+
return { type: 'file', id: file.id as string, title: (file.name as string) || 'File' }
122+
}
123+
124+
const fileId = (data?.fileId as string) ?? (data?.id as string)
125+
const fileName =
126+
(data?.fileName as string) ||
127+
(data?.name as string) ||
128+
(storedInnerArgs?.fileName as string) ||
129+
'File'
130+
if (fileId && typeof fileId === 'string') return { type: 'file', id: fileId, title: fileName }
131+
132+
return null
133+
}
134+
72135
export function useChat(workspaceId: string, initialChatId?: string): UseChatReturn {
73136
const pathname = usePathname()
74137
const queryClient = useQueryClient()
@@ -77,13 +140,16 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
77140
const [messages, setMessages] = useState<ChatMessage[]>([])
78141
const [isSending, setIsSending] = useState(false)
79142
const [error, setError] = useState<string | null>(null)
143+
const [resources, setResources] = useState<MothershipResource[]>([])
144+
const [activeResourceId, setActiveResourceId] = useState<string | null>(null)
80145
const abortControllerRef = useRef<AbortController | null>(null)
81146
const chatIdRef = useRef<string | undefined>(initialChatId)
82147
const chatBottomRef = useRef<HTMLDivElement>(null)
83148
const appliedChatIdRef = useRef<string | undefined>(undefined)
84149
const pendingUserMsgRef = useRef<{ id: string; content: string } | null>(null)
85150
const streamIdRef = useRef<string | undefined>(undefined)
86151
const sendingRef = useRef(false)
152+
const toolArgsMapRef = useRef<Map<string, Record<string, unknown>>>(new Map())
87153

88154
useEffect(() => {
89155
routerRef.current = router
@@ -93,12 +159,30 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
93159

94160
const { data: chatHistory } = useChatHistory(initialChatId)
95161

162+
const addResource = useCallback((resource: MothershipResource) => {
163+
setResources((prev) => {
164+
const existing = prev.find((r) => r.type === resource.type && r.id === resource.id)
165+
if (existing) {
166+
const keepOldTitle = existing.title !== 'Table' && existing.title !== 'File'
167+
const title = keepOldTitle ? existing.title : resource.title
168+
if (title === existing.title) return prev
169+
return prev.map((r) =>
170+
r.id === existing.id && r.type === existing.type ? { ...r, title } : r
171+
)
172+
}
173+
return [...prev, resource]
174+
})
175+
setActiveResourceId(resource.id)
176+
}, [])
177+
96178
useEffect(() => {
97179
chatIdRef.current = initialChatId
98180
appliedChatIdRef.current = undefined
99181
setMessages([])
100182
setError(null)
101183
setIsSending(false)
184+
setResources([])
185+
setActiveResourceId(null)
102186
}, [initialChatId])
103187

104188
useEffect(() => {
@@ -110,6 +194,8 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
110194
setMessages([])
111195
setError(null)
112196
setIsSending(false)
197+
setResources([])
198+
setActiveResourceId(null)
113199
}, [isHomePage])
114200

115201
useEffect(() => {
@@ -124,6 +210,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
124210
let buffer = ''
125211
const blocks: ContentBlock[] = []
126212
const toolMap = new Map<string, number>()
213+
let lastTableId: string | null = null
127214

128215
const ensureTextBlock = (): ContentBlock => {
129216
const last = blocks[blocks.length - 1]
@@ -164,6 +251,8 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
164251
continue
165252
}
166253

254+
logger.debug('SSE event received', parsed)
255+
167256
switch (parsed.type) {
168257
case 'chat_id': {
169258
if (parsed.chatId) {
@@ -201,6 +290,14 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
201290
const data = getPayloadData(parsed)
202291
const name = parsed.toolName || data?.name || 'unknown'
203292
if (!id) break
293+
294+
if (RESOURCE_TOOL_NAMES.has(name)) {
295+
const args = data?.arguments ?? data?.input
296+
if (args) {
297+
toolArgsMapRef.current.set(id, args)
298+
}
299+
}
300+
204301
const ui = parsed.ui || data?.ui
205302
if (ui?.hidden) break
206303
const displayTitle = ui?.title || ui?.phaseLabel
@@ -229,6 +326,33 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
229326
blocks[idx].toolCall!.status = parsed.success ? 'success' : 'error'
230327
flush()
231328
}
329+
330+
const toolName = parsed.toolName || getPayloadData(parsed)?.name
331+
if (toolName && parsed.success && RESOURCE_TOOL_NAMES.has(toolName)) {
332+
const storedArgs = toolArgsMapRef.current.get(id)
333+
let resource: MothershipResource | null = null
334+
335+
if (toolName === 'user_table') {
336+
resource = extractTableResource(parsed, storedArgs, lastTableId)
337+
if (resource) {
338+
lastTableId = resource.id
339+
queryClient.invalidateQueries({ queryKey: tableKeys.detail(resource.id) })
340+
queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(resource.id) })
341+
}
342+
} else if (toolName === 'workspace_file') {
343+
resource = extractFileResource(parsed, storedArgs)
344+
if (resource) {
345+
queryClient.invalidateQueries({
346+
queryKey: workspaceFilesKeys.list(workspaceId),
347+
})
348+
queryClient.invalidateQueries({
349+
queryKey: workspaceFilesKeys.content(workspaceId, resource.id),
350+
})
351+
}
352+
}
353+
354+
if (resource) addResource(resource)
355+
}
232356
break
233357
}
234358
case 'tool_error': {
@@ -265,7 +389,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
265389
}
266390
}
267391
},
268-
[workspaceId, queryClient]
392+
[workspaceId, queryClient, addResource]
269393
)
270394

271395
const finalize = useCallback(() => {
@@ -401,5 +525,8 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
401525
sendMessage,
402526
stopGeneration,
403527
chatBottomRef,
528+
resources,
529+
activeResourceId,
530+
setActiveResourceId,
404531
}
405532
}

0 commit comments

Comments
 (0)