This commit is contained in:
Siddharth Ganesan
2026-02-25 12:37:23 -08:00
parent 41f0957ccc
commit f12d8f631f

View File

@@ -6,11 +6,57 @@ import { useParams } from 'next/navigation'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Button } from '@/components/emcn'
import type { ContentBlock, ToolCallInfo, ToolCallStatus } from './hooks/use-workspace-chat'
import { useWorkspaceChat } from './hooks/use-workspace-chat'
import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
const REMARK_PLUGINS = [remarkGfm]
// ── Types ──
interface SSEEvent {
timestamp: string
raw: string
}
type ToolCallStatus = 'executing' | 'success' | 'error'
interface ToolCallInfo {
id: string
name: string
status: ToolCallStatus
displayTitle?: string
}
type ContentBlockType = 'text' | 'tool_call' | 'subagent'
interface ContentBlock {
type: ContentBlockType
content?: string
toolCall?: ToolCallInfo
}
interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
contentBlocks?: ContentBlock[]
}
const SUBAGENT_LABELS: Record<string, string> = {
build: 'Building',
deploy: 'Deploying',
auth: 'Connecting credentials',
research: 'Researching',
knowledge: 'Managing knowledge base',
table: 'Managing tables',
custom_tool: 'Creating tool',
superagent: 'Executing action',
plan: 'Planning',
debug: 'Debugging',
edit: 'Editing workflow',
}
// ── Rendered chat components ──
function ToolStatusIcon({ status }: { status: ToolCallStatus }) {
switch (status) {
case 'executing':
@@ -22,16 +68,14 @@ function ToolStatusIcon({ status }: { status: ToolCallStatus }) {
}
}
function formatToolName(name: string): string {
return name
.replace(/_v\d+$/, '')
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
function ToolCallItem({ toolCall }: { toolCall: ToolCallInfo }) {
const label = toolCall.displayTitle || formatToolName(toolCall.name)
const label =
toolCall.displayTitle ||
toolCall.name
.replace(/_v\d+$/, '')
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
return (
<div className='flex items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-1.5'>
@@ -42,16 +86,13 @@ function ToolCallItem({ toolCall }: { toolCall: ToolCallInfo }) {
)
}
function SubagentLabel({ label }: { label: string }) {
return (
<div className='flex items-center gap-2 py-0.5'>
<Loader2 className='h-3 w-3 animate-spin text-[var(--text-tertiary)]' />
<span className='text-xs text-[var(--text-tertiary)]'>{label}</span>
</div>
)
}
function AssistantContent({ blocks, isStreaming }: { blocks: ContentBlock[]; isStreaming: boolean }) {
function AssistantBlocks({
blocks,
isStreaming,
}: {
blocks: ContentBlock[]
isStreaming: boolean
}) {
return (
<div className='space-y-2'>
{blocks.map((block, i) => {
@@ -70,10 +111,14 @@ function AssistantContent({ blocks, isStreaming }: { blocks: ContentBlock[]; isS
}
case 'subagent': {
if (!block.content) return null
const isLastSubagent =
isStreaming && blocks.slice(i + 1).every((b) => b.type !== 'subagent')
if (!isLastSubagent) return null
return <SubagentLabel key={`sub-${i}`} label={block.content} />
const isLast = isStreaming && blocks.slice(i + 1).every((b) => b.type !== 'subagent')
if (!isLast) return null
return (
<div key={`sub-${i}`} className='flex items-center gap-2 py-0.5'>
<Loader2 className='h-3 w-3 animate-spin text-[var(--text-tertiary)]' />
<span className='text-xs text-[var(--text-tertiary)]'>{block.content}</span>
</div>
)
}
default:
return null
@@ -83,28 +128,217 @@ function AssistantContent({ blocks, isStreaming }: { blocks: ContentBlock[]; isS
)
}
// ── Main component ──
export function Chat() {
const { workspaceId } = useParams<{ workspaceId: string }>()
const [inputValue, setInputValue] = useState('')
const inputRef = useRef<HTMLTextAreaElement>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const [events, setEvents] = useState<SSEEvent[]>([])
const [messages, setMessages] = useState<ChatMessage[]>([])
const [isSending, setIsSending] = useState(false)
const [error, setError] = useState<string | null>(null)
const abortControllerRef = useRef<AbortController | null>(null)
const chatIdRef = useRef<string | undefined>(undefined)
const rawBottomRef = useRef<HTMLDivElement>(null)
const chatBottomRef = useRef<HTMLDivElement>(null)
const { messages, isSending, error, sendMessage, abortMessage } = useWorkspaceChat({
workspaceId,
})
const sendMessage = useCallback(
async (message: string) => {
if (!message.trim() || !workspaceId) return
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [])
setError(null)
setIsSending(true)
const handleSubmit = useCallback(async () => {
const userMessageId = crypto.randomUUID()
const assistantId = crypto.randomUUID()
setEvents((prev) => [
...prev,
{ timestamp: new Date().toISOString(), raw: JSON.stringify({ type: 'user', message }) },
])
setMessages((prev) => [
...prev,
{ id: userMessageId, role: 'user', content: message },
{ id: assistantId, role: 'assistant', content: '', contentBlocks: [] },
])
const abortController = new AbortController()
abortControllerRef.current = abortController
const blocks: ContentBlock[] = []
const toolMap = new Map<string, number>()
const ensureTextBlock = (): ContentBlock => {
const last = blocks[blocks.length - 1]
if (last?.type === 'text') return last
const b: ContentBlock = { type: 'text', content: '' }
blocks.push(b)
return b
}
const flush = () => {
const text = blocks
.filter((b) => b.type === 'text')
.map((b) => b.content ?? '')
.join('')
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: text, contentBlocks: [...blocks] } : m
)
)
}
try {
const response = await fetch(MOTHERSHIP_CHAT_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message,
workspaceId,
userMessageId,
createNewChat: !chatIdRef.current,
...(chatIdRef.current ? { chatId: chatIdRef.current } : {}),
}),
signal: abortController.signal,
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || `Request failed: ${response.status}`)
}
if (!response.body) throw new Error('No response body')
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const payload = line.slice(6)
setEvents((prev) => [
...prev,
{ timestamp: new Date().toISOString(), raw: payload },
])
let parsed: any
try {
parsed = JSON.parse(payload)
} catch {
continue
}
switch (parsed.type) {
case 'chat_id': {
if (parsed.chatId) chatIdRef.current = parsed.chatId
break
}
case 'content': {
const chunk =
typeof parsed.data === 'string'
? parsed.data
: parsed.content || ''
if (chunk) {
const tb = ensureTextBlock()
tb.content = (tb.content ?? '') + chunk
flush()
}
break
}
case 'tool_generating':
case 'tool_call': {
const id = parsed.toolCallId
const name = parsed.toolName || parsed.data?.name || 'unknown'
if (!id) break
const ui = parsed.ui || parsed.data?.ui
if (ui?.hidden) break
const displayTitle = ui?.title || ui?.phaseLabel
if (!toolMap.has(id)) {
toolMap.set(id, blocks.length)
blocks.push({
type: 'tool_call',
toolCall: { id, name, status: 'executing', displayTitle },
})
} else {
const idx = toolMap.get(id)!
const tc = blocks[idx].toolCall
if (tc) {
tc.name = name
if (displayTitle) tc.displayTitle = displayTitle
}
}
flush()
break
}
case 'tool_result': {
const id = parsed.toolCallId || parsed.data?.id
if (!id) break
const idx = toolMap.get(id)
if (idx !== undefined && blocks[idx].toolCall) {
blocks[idx].toolCall!.status = parsed.success ? 'success' : 'error'
flush()
}
break
}
case 'tool_error': {
const id = parsed.toolCallId || parsed.data?.id
if (!id) break
const idx = toolMap.get(id)
if (idx !== undefined && blocks[idx].toolCall) {
blocks[idx].toolCall!.status = 'error'
flush()
}
break
}
case 'subagent_start': {
const name = parsed.subagent || parsed.data?.agent
if (name) {
blocks.push({ type: 'subagent', content: SUBAGENT_LABELS[name] || name })
flush()
}
break
}
case 'subagent_end': {
flush()
break
}
case 'error': {
setError(parsed.error || 'An error occurred')
break
}
}
}
}
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
const msg = err instanceof Error ? err.message : 'Failed to send message'
setError(msg)
} finally {
setIsSending(false)
abortControllerRef.current = null
rawBottomRef.current?.scrollIntoView({ behavior: 'smooth' })
chatBottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}
},
[workspaceId]
)
const handleSubmit = useCallback(() => {
const trimmed = inputValue.trim()
if (!trimmed || !workspaceId) return
if (!trimmed) return
setInputValue('')
await sendMessage(trimmed)
scrollToBottom()
}, [inputValue, workspaceId, sendMessage, scrollToBottom])
sendMessage(trimmed)
}, [inputValue, sendMessage])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -116,82 +350,111 @@ export function Chat() {
[handleSubmit]
)
const clear = () => {
setEvents([])
setMessages([])
chatIdRef.current = undefined
}
return (
<div className='flex h-full flex-col'>
<div className='flex flex-shrink-0 items-center border-b border-[var(--border)] px-6 py-3'>
<div className='flex flex-shrink-0 items-center justify-between border-b border-[var(--border)] px-6 py-3'>
<h1 className='font-medium text-[16px] text-[var(--text-primary)]'>Mothership</h1>
{(events.length > 0 || messages.length > 0) && (
<button
onClick={clear}
className='text-xs text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
>
Clear
</button>
)}
</div>
<div className='flex-1 overflow-y-auto px-6 py-4'>
{messages.length === 0 && !isSending ? (
<div className='flex h-full items-center justify-center'>
<div className='flex flex-col items-center gap-3 text-center'>
<p className='text-[var(--text-secondary)] text-sm'>
Ask anything about your workspace build workflows, manage resources, get help.
</p>
</div>
{/* Split pane: left = rendered chat, right = raw SSE */}
<div className='flex min-h-0 flex-1'>
{/* Rendered chat */}
<div className='flex w-1/2 flex-col border-r border-[var(--border)]'>
<div className='border-b border-[var(--border)] px-4 py-2'>
<span className='text-xs font-medium text-[var(--text-tertiary)]'>Chat</span>
</div>
) : (
<div className='mx-auto max-w-3xl space-y-4'>
{messages.map((msg) => {
const isStreamingEmpty =
isSending &&
msg.role === 'assistant' &&
!msg.content &&
(!msg.contentBlocks || msg.contentBlocks.length === 0)
if (isStreamingEmpty) {
return (
<div key={msg.id} className='flex justify-start'>
<div className='flex items-center gap-2 rounded-lg bg-[var(--surface-3)] px-4 py-2 text-sm text-[var(--text-secondary)]'>
<Loader2 className='h-3 w-3 animate-spin' />
Thinking...
</div>
</div>
)
}
if (
msg.role === 'assistant' &&
!msg.content &&
(!msg.contentBlocks || msg.contentBlocks.length === 0)
)
return null
if (msg.role === 'user') {
return (
<div key={msg.id} className='flex justify-end'>
<div className='max-w-[85%] rounded-lg bg-[var(--accent)] px-4 py-2 text-sm text-[var(--accent-foreground)]'>
<p className='whitespace-pre-wrap'>{msg.content}</p>
</div>
</div>
)
}
const hasBlocks = msg.contentBlocks && msg.contentBlocks.length > 0
const isThisMessageStreaming = isSending && msg === messages[messages.length - 1]
return (
<div key={msg.id} className='flex justify-start'>
<div className='max-w-[85%] rounded-lg bg-[var(--surface-3)] px-4 py-2 text-sm text-[var(--text-primary)]'>
{hasBlocks ? (
<AssistantContent
blocks={msg.contentBlocks!}
isStreaming={isThisMessageStreaming}
/>
) : (
<div className='prose-sm prose-invert max-w-none'>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS}>
{msg.content}
</ReactMarkdown>
<div className='flex-1 overflow-y-auto px-4 py-4'>
{messages.length === 0 ? (
<div className='flex h-full items-center justify-center'>
<p className='text-sm text-[var(--text-tertiary)]'>Send a message to start</p>
</div>
) : (
<div className='space-y-4'>
{messages.map((msg) => {
if (msg.role === 'user') {
return (
<div key={msg.id} className='flex justify-end'>
<div className='max-w-[85%] rounded-lg bg-[var(--accent)] px-4 py-2 text-sm text-[var(--accent-foreground)]'>
<p className='whitespace-pre-wrap'>{msg.content}</p>
</div>
</div>
)}
</div>
</div>
)
})}
<div ref={messagesEndRef} />
)
}
const hasBlocks = msg.contentBlocks && msg.contentBlocks.length > 0
const isThisStreaming = isSending && msg === messages[messages.length - 1]
if (!hasBlocks && !msg.content && isThisStreaming) {
return (
<div key={msg.id} className='flex justify-start'>
<div className='flex items-center gap-2 rounded-lg bg-[var(--surface-3)] px-4 py-2 text-sm text-[var(--text-secondary)]'>
<Loader2 className='h-3 w-3 animate-spin' />
Thinking...
</div>
</div>
)
}
if (!hasBlocks && !msg.content) return null
return (
<div key={msg.id} className='flex justify-start'>
<div className='max-w-[85%] rounded-lg bg-[var(--surface-3)] px-4 py-2 text-sm text-[var(--text-primary)]'>
{hasBlocks ? (
<AssistantBlocks blocks={msg.contentBlocks!} isStreaming={isThisStreaming} />
) : (
<div className='prose-sm prose-invert max-w-none'>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS}>
{msg.content}
</ReactMarkdown>
</div>
)}
</div>
</div>
)
})}
<div ref={chatBottomRef} />
</div>
)}
</div>
)}
</div>
{/* Raw SSE events */}
<div className='flex w-1/2 flex-col'>
<div className='border-b border-[var(--border)] px-4 py-2'>
<span className='text-xs font-medium text-[var(--text-tertiary)]'>Raw SSE</span>
</div>
<div className='flex-1 overflow-y-auto bg-[var(--surface-1)] font-mono text-xs'>
{events.map((evt, i) => (
<div
key={i}
className='border-b border-[var(--border)] px-4 py-2 hover:bg-[var(--surface-2)]'
>
<span className='mr-2 text-[var(--text-tertiary)]'>
{new Date(evt.timestamp).toLocaleTimeString()}
</span>
<span className='whitespace-pre-wrap break-all text-[var(--text-primary)]'>
{evt.raw}
</span>
</div>
))}
<div ref={rawBottomRef} />
</div>
</div>
</div>
{error && (
@@ -203,7 +466,6 @@ export function Chat() {
<div className='flex-shrink-0 border-t border-[var(--border)] px-6 py-4'>
<div className='mx-auto flex max-w-3xl items-end gap-2'>
<textarea
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
@@ -221,7 +483,10 @@ export function Chat() {
<Button
variant='ghost'
size='sm'
onClick={abortMessage}
onClick={() => {
abortControllerRef.current?.abort()
setIsSending(false)
}}
className='h-[38px] w-[38px] flex-shrink-0 p-0'
>
<Square className='h-4 w-4' />