feat(mothership): resource viewer

This commit is contained in:
Emir Karabeg
2026-03-09 12:06:54 -07:00
parent 2ace7252f9
commit 48f280427e
5 changed files with 317 additions and 71 deletions

View File

@@ -1,14 +1,90 @@
'use client'
export function MothershipView() {
import { useMemo } from 'react'
import { Skeleton } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/components'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
interface MothershipViewProps {
workspaceId: string
resources: MothershipResource[]
activeResourceId: string | null
onSelectResource: (id: string) => void
}
export function MothershipView({
workspaceId,
resources,
activeResourceId,
onSelectResource,
}: MothershipViewProps) {
const active = resources.find((r) => r.id === activeResourceId) ?? resources[0] ?? null
return (
<div className='flex h-full w-[480px] flex-shrink-0 flex-col border-[var(--border)] border-l'>
<div className='flex items-center border-[var(--border)] border-b px-[16px] py-[12px]'>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>Mothership</span>
<div className='flex h-full w-[50%] min-w-[400px] flex-col border-[var(--border)] border-l'>
<div className='flex shrink-0 gap-[2px] overflow-x-auto border-[var(--border)] border-b px-[12px]'>
{resources.map((r) => (
<button
key={r.id}
type='button'
onClick={() => onSelectResource(r.id)}
className={cn(
'shrink-0 cursor-pointer border-b-[2px] px-[12px] py-[10px] text-[13px] transition-colors',
active?.id === r.id
? 'border-[var(--text-primary)] font-medium text-[var(--text-primary)]'
: 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
)}
>
{r.title}
</button>
))}
</div>
<div className='flex flex-1 items-center justify-center'>
<span className='text-[13px] text-[var(--text-muted)]'>No artifacts yet</span>
<div className='min-h-0 flex-1 overflow-hidden'>
{active?.type === 'table' && (
<Table key={active.id} workspaceId={workspaceId} tableId={active.id} embedded />
)}
{active?.type === 'file' && (
<EmbeddedFile key={active.id} workspaceId={workspaceId} fileId={active.id} />
)}
</div>
</div>
)
}
interface EmbeddedFileProps {
workspaceId: string
fileId: string
}
function EmbeddedFile({ workspaceId, fileId }: EmbeddedFileProps) {
const { data: files = [], isLoading } = useWorkspaceFiles(workspaceId)
const file = useMemo(() => files.find((f) => f.id === fileId), [files, fileId])
if (isLoading) {
return (
<div className='flex h-full flex-col gap-[8px] p-[24px]'>
<Skeleton className='h-[16px] w-[60%]' />
<Skeleton className='h-[16px] w-[80%]' />
<Skeleton className='h-[16px] w-[40%]' />
</div>
)
}
if (!file) {
return (
<div className='flex h-full items-center justify-center'>
<span className='text-[13px] text-[var(--text-muted)]'>File not found</span>
</div>
)
}
return (
<div className='flex h-full flex-col overflow-hidden'>
<FileViewer key={file.id} file={file} workspaceId={workspaceId} canEdit={true} />
</div>
)
}

View File

@@ -2,7 +2,7 @@
import { useCallback, useState } from 'react'
import { useParams } from 'next/navigation'
import { MessageContent, UserInput } from './components'
import { MessageContent, MothershipView, UserInput } from './components'
import { useChat } from './hooks'
interface HomeProps {
@@ -12,10 +12,16 @@ interface HomeProps {
export function Home({ chatId }: HomeProps = {}) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const [inputValue, setInputValue] = useState('')
const { messages, isSending, sendMessage, stopGeneration, chatBottomRef } = useChat(
workspaceId,
chatId
)
const {
messages,
isSending,
sendMessage,
stopGeneration,
chatBottomRef,
resources,
activeResourceId,
setActiveResourceId,
} = useChat(workspaceId, chatId)
const handleSubmit = useCallback(() => {
const trimmed = inputValue.trim()
@@ -102,6 +108,15 @@ export function Home({ chatId }: HomeProps = {}) {
/>
</div>
</div>
{resources.length > 0 && (
<MothershipView
workspaceId={workspaceId}
resources={resources}
activeResourceId={activeResourceId}
onSelectResource={setActiveResourceId}
/>
)}
</div>
)
}

View File

@@ -1,7 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { usePathname, useRouter } from 'next/navigation'
import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
import { tableKeys } from '@/hooks/queries/tables'
import {
type TaskChatHistory,
type TaskStoredContentBlock,
@@ -9,10 +11,12 @@ import {
taskKeys,
useChatHistory,
} from '@/hooks/queries/tasks'
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
import type {
ChatMessage,
ContentBlock,
ContentBlockType,
MothershipResource,
SSEPayload,
SSEPayloadData,
ToolCallStatus,
@@ -26,6 +30,9 @@ export interface UseChatReturn {
sendMessage: (message: string) => Promise<void>
stopGeneration: () => void
chatBottomRef: React.RefObject<HTMLDivElement | null>
resources: MothershipResource[]
activeResourceId: string | null
setActiveResourceId: (id: string | null) => void
}
const STATE_TO_STATUS: Record<string, ToolCallStatus> = {
@@ -65,10 +72,66 @@ function mapStoredMessage(msg: TaskStoredMessage): ChatMessage {
return mapped
}
const logger = createLogger('useChat')
function getPayloadData(payload: SSEPayload): SSEPayloadData | undefined {
return typeof payload.data === 'object' ? payload.data : undefined
}
const RESOURCE_TOOL_NAMES = new Set(['user_table', 'workspace_file'])
function getResultData(parsed: SSEPayload): Record<string, unknown> | undefined {
const topResult = parsed.result as Record<string, unknown> | undefined
const nestedResult =
typeof parsed.data === 'object' ? (parsed.data?.result as Record<string, unknown>) : undefined
const result = topResult ?? nestedResult
return result?.data as Record<string, unknown> | undefined
}
function extractTableResource(
parsed: SSEPayload,
storedArgs: Record<string, unknown> | undefined,
fallbackTableId: string | null
): MothershipResource | null {
const data = getResultData(parsed)
const storedInnerArgs = storedArgs?.args as Record<string, unknown> | undefined
const table = data?.table as Record<string, unknown> | undefined
if (table?.id) {
return { type: 'table', id: table.id as string, title: (table.name as string) || 'Table' }
}
const tableId =
(data?.tableId as string) ?? storedInnerArgs?.tableId ?? storedArgs?.tableId ?? fallbackTableId
const tableName = (data?.tableName as string) || (table?.name as string) || 'Table'
if (tableId) return { type: 'table', id: tableId as string, title: tableName }
return null
}
function extractFileResource(
parsed: SSEPayload,
storedArgs: Record<string, unknown> | undefined
): MothershipResource | null {
const data = getResultData(parsed)
const storedInnerArgs = storedArgs?.args as Record<string, unknown> | undefined
const file = data?.file as Record<string, unknown> | undefined
if (file?.id) {
return { type: 'file', id: file.id as string, title: (file.name as string) || 'File' }
}
const fileId = (data?.fileId as string) ?? (data?.id as string)
const fileName =
(data?.fileName as string) ||
(data?.name as string) ||
(storedInnerArgs?.fileName as string) ||
'File'
if (fileId && typeof fileId === 'string') return { type: 'file', id: fileId, title: fileName }
return null
}
export function useChat(workspaceId: string, initialChatId?: string): UseChatReturn {
const pathname = usePathname()
const queryClient = useQueryClient()
@@ -77,6 +140,8 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
const [messages, setMessages] = useState<ChatMessage[]>([])
const [isSending, setIsSending] = useState(false)
const [error, setError] = useState<string | null>(null)
const [resources, setResources] = useState<MothershipResource[]>([])
const [activeResourceId, setActiveResourceId] = useState<string | null>(null)
const abortControllerRef = useRef<AbortController | null>(null)
const chatIdRef = useRef<string | undefined>(initialChatId)
const chatBottomRef = useRef<HTMLDivElement>(null)
@@ -84,6 +149,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
const pendingUserMsgRef = useRef<{ id: string; content: string } | null>(null)
const streamIdRef = useRef<string | undefined>(undefined)
const sendingRef = useRef(false)
const toolArgsMapRef = useRef<Map<string, Record<string, unknown>>>(new Map())
useEffect(() => {
routerRef.current = router
@@ -93,12 +159,30 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
const { data: chatHistory } = useChatHistory(initialChatId)
const addResource = useCallback((resource: MothershipResource) => {
setResources((prev) => {
const existing = prev.find((r) => r.type === resource.type && r.id === resource.id)
if (existing) {
const keepOldTitle = existing.title !== 'Table' && existing.title !== 'File'
const title = keepOldTitle ? existing.title : resource.title
if (title === existing.title) return prev
return prev.map((r) =>
r.id === existing.id && r.type === existing.type ? { ...r, title } : r
)
}
return [...prev, resource]
})
setActiveResourceId(resource.id)
}, [])
useEffect(() => {
chatIdRef.current = initialChatId
appliedChatIdRef.current = undefined
setMessages([])
setError(null)
setIsSending(false)
setResources([])
setActiveResourceId(null)
}, [initialChatId])
useEffect(() => {
@@ -110,6 +194,8 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
setMessages([])
setError(null)
setIsSending(false)
setResources([])
setActiveResourceId(null)
}, [isHomePage])
useEffect(() => {
@@ -124,6 +210,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
let buffer = ''
const blocks: ContentBlock[] = []
const toolMap = new Map<string, number>()
let lastTableId: string | null = null
const ensureTextBlock = (): ContentBlock => {
const last = blocks[blocks.length - 1]
@@ -164,6 +251,8 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
continue
}
logger.debug('SSE event received', parsed)
switch (parsed.type) {
case 'chat_id': {
if (parsed.chatId) {
@@ -201,6 +290,14 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
const data = getPayloadData(parsed)
const name = parsed.toolName || data?.name || 'unknown'
if (!id) break
if (RESOURCE_TOOL_NAMES.has(name)) {
const args = data?.arguments ?? data?.input
if (args) {
toolArgsMapRef.current.set(id, args)
}
}
const ui = parsed.ui || data?.ui
if (ui?.hidden) break
const displayTitle = ui?.title || ui?.phaseLabel
@@ -229,6 +326,33 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
blocks[idx].toolCall!.status = parsed.success ? 'success' : 'error'
flush()
}
const toolName = parsed.toolName || getPayloadData(parsed)?.name
if (toolName && parsed.success && RESOURCE_TOOL_NAMES.has(toolName)) {
const storedArgs = toolArgsMapRef.current.get(id)
let resource: MothershipResource | null = null
if (toolName === 'user_table') {
resource = extractTableResource(parsed, storedArgs, lastTableId)
if (resource) {
lastTableId = resource.id
queryClient.invalidateQueries({ queryKey: tableKeys.detail(resource.id) })
queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(resource.id) })
}
} else if (toolName === 'workspace_file') {
resource = extractFileResource(parsed, storedArgs)
if (resource) {
queryClient.invalidateQueries({
queryKey: workspaceFilesKeys.list(workspaceId),
})
queryClient.invalidateQueries({
queryKey: workspaceFilesKeys.content(workspaceId, resource.id),
})
}
}
if (resource) addResource(resource)
}
break
}
case 'tool_error': {
@@ -265,7 +389,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
}
}
},
[workspaceId, queryClient]
[workspaceId, queryClient, addResource]
)
const finalize = useCallback(() => {
@@ -401,5 +525,8 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
sendMessage,
stopGeneration,
chatBottomRef,
resources,
activeResourceId,
setActiveResourceId,
}
}

View File

@@ -47,6 +47,9 @@ export interface SSEPayloadData {
ui?: SSEPayloadUI
id?: string
agent?: string
arguments?: Record<string, unknown>
input?: Record<string, unknown>
result?: Record<string, unknown>
}
export interface SSEPayload {
@@ -60,4 +63,13 @@ export interface SSEPayload {
success?: boolean
error?: string
subagent?: string
result?: Record<string, unknown>
}
export type MothershipResourceType = 'table' | 'file'
export interface MothershipResource {
type: MothershipResourceType
id: string
title: string
}

View File

@@ -125,12 +125,22 @@ function computeNormalizedSelection(
}
}
export function Table() {
interface TableProps {
workspaceId?: string
tableId?: string
embedded?: boolean
}
export function Table({
workspaceId: propWorkspaceId,
tableId: propTableId,
embedded,
}: TableProps = {}) {
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const tableId = params.tableId as string
const workspaceId = propWorkspaceId || (params.workspaceId as string)
const tableId = propTableId || (params.tableId as string)
const [queryOptions, setQueryOptions] = useState<QueryOptions>({
filter: null,
@@ -729,37 +739,41 @@ export function Table() {
return (
<div className='flex h-full flex-col'>
<ResourceHeader
icon={TableIcon}
breadcrumbs={[
{ label: 'Tables', onClick: handleNavigateBack },
...(tableData
? [
{
label: tableData.name,
dropdownItems: [
{ label: 'Rename', icon: Pencil, onClick: () => {} },
{!embedded && (
<>
<ResourceHeader
icon={TableIcon}
breadcrumbs={[
{ label: 'Tables', onClick: handleNavigateBack },
...(tableData
? [
{
label: 'Delete',
icon: Trash,
onClick: () => setShowDeleteTableConfirm(true),
label: tableData.name,
dropdownItems: [
{ label: 'Rename', icon: Pencil, onClick: () => {} },
{
label: 'Delete',
icon: Trash,
onClick: () => setShowDeleteTableConfirm(true),
},
],
},
],
},
]
: []),
]}
create={{
label: 'New column',
onClick: handleAddColumn,
disabled: addColumnMutation.isPending,
}}
/>
]
: []),
]}
create={{
label: 'New column',
onClick: handleAddColumn,
disabled: addColumnMutation.isPending,
}}
/>
<ResourceOptionsBar
sort={sortConfig}
filter={<TableFilter columns={columns} onApply={handleFilterApply} />}
/>
<ResourceOptionsBar
sort={sortConfig}
filter={<TableFilter columns={columns} onApply={handleFilterApply} />}
/>
</>
)}
<div className='min-h-0 flex-1 overflow-auto overscroll-none' data-table-scroll>
<table
@@ -946,34 +960,36 @@ export function Table() {
onDelete={handleContextMenuDelete}
/>
<Modal open={showDeleteTableConfirm} onOpenChange={setShowDeleteTableConfirm}>
<ModalContent size='sm'>
<ModalHeader>Delete Table</ModalHeader>
<ModalBody>
<p className='text-[13px] text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{tableData?.name}</span>?{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={() => setShowDeleteTableConfirm(false)}
disabled={deleteTableMutation.isPending}
>
Cancel
</Button>
<Button
variant='destructive'
onClick={handleDeleteTable}
disabled={deleteTableMutation.isPending}
>
{deleteTableMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{!embedded && (
<Modal open={showDeleteTableConfirm} onOpenChange={setShowDeleteTableConfirm}>
<ModalContent size='sm'>
<ModalHeader>Delete Table</ModalHeader>
<ModalBody>
<p className='text-[13px] text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{tableData?.name}</span>?{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={() => setShowDeleteTableConfirm(false)}
disabled={deleteTableMutation.isPending}
>
Cancel
</Button>
<Button
variant='destructive'
onClick={handleDeleteTable}
disabled={deleteTableMutation.isPending}
>
{deleteTableMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
<Modal
open={renamingColumn !== null}