Checkpoint

This commit is contained in:
Siddharth Ganesan
2026-02-18 18:55:10 -08:00
parent 4c3002f97d
commit 3338b25c30
3 changed files with 324 additions and 42 deletions

View File

@@ -1,12 +1,96 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { Send, Square } from 'lucide-react'
import { Check, CircleAlert, Loader2, Send, Square, Zap } from 'lucide-react'
import { useParams } from 'next/navigation'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Button } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { ContentBlock, ToolCallInfo, ToolCallStatus } from './hooks/use-workspace-chat'
import { useWorkspaceChat } from './hooks/use-workspace-chat'
const REMARK_PLUGINS = [remarkGfm]
/** Status icon for a tool call. */
function ToolStatusIcon({ status }: { status: ToolCallStatus }) {
switch (status) {
case 'executing':
return <Loader2 className='h-3 w-3 animate-spin text-[var(--text-tertiary)]' />
case 'success':
return <Check className='h-3 w-3 text-emerald-500' />
case 'error':
return <CircleAlert className='h-3 w-3 text-red-400' />
}
}
/** Formats a tool name for display: "edit_workflow" → "Edit Workflow". */
function formatToolName(name: string): string {
return name
.replace(/_v\d+$/, '')
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
/** Compact inline rendering of a single tool call. */
function ToolCallItem({ toolCall }: { toolCall: ToolCallInfo }) {
const label = toolCall.displayTitle || formatToolName(toolCall.name)
return (
<div className='flex items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-1.5'>
<Zap className='h-3 w-3 flex-shrink-0 text-[var(--text-tertiary)]' />
<span className='min-w-0 flex-1 truncate text-xs text-[var(--text-secondary)]'>{label}</span>
<ToolStatusIcon status={toolCall.status} />
</div>
)
}
/** Renders a subagent activity label. */
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>
)
}
/** Renders structured content blocks for an assistant message. */
function AssistantContent({ blocks, isStreaming }: { blocks: ContentBlock[]; isStreaming: boolean }) {
return (
<div className='space-y-2'>
{blocks.map((block, i) => {
switch (block.type) {
case 'text': {
if (!block.content?.trim()) return null
return (
<div key={`text-${i}`} className='prose-sm prose-invert max-w-none'>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS}>{block.content}</ReactMarkdown>
</div>
)
}
case 'tool_call': {
if (!block.toolCall) return null
return <ToolCallItem key={block.toolCall.id} toolCall={block.toolCall} />
}
case 'subagent': {
if (!block.content) return null
// Only show the subagent label if it's the last subagent block and we're streaming
const isLastSubagent =
isStreaming &&
blocks.slice(i + 1).every((b) => b.type !== 'subagent')
if (!isLastSubagent) return null
return <SubagentLabel key={`sub-${i}`} label={block.content} />
}
default:
return null
}
})}
</div>
)
}
export function Chat() {
const { workspaceId } = useParams<{ workspaceId: string }>()
const [inputValue, setInputValue] = useState('')
@@ -44,7 +128,7 @@ export function Chat() {
<div className='flex h-full flex-col'>
{/* Header */}
<div className='flex flex-shrink-0 items-center border-b border-[var(--border)] px-6 py-3'>
<h1 className='font-medium text-[16px] text-[var(--text-primary)]'>Chat</h1>
<h1 className='font-medium text-[16px] text-[var(--text-primary)]'>Mothership</h1>
</div>
{/* Messages area */}
@@ -61,34 +145,59 @@ export function Chat() {
<div className='mx-auto max-w-3xl space-y-4'>
{messages.map((msg) => {
const isStreamingEmpty =
isSending && msg.role === 'assistant' && !msg.content
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='rounded-lg bg-[var(--surface-3)] px-4 py-2 text-sm text-[var(--text-secondary)]'>
<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) return null
// Skip empty assistant messages
if (
msg.role === 'assistant' &&
!msg.content &&
(!msg.contentBlocks || msg.contentBlocks.length === 0)
)
return null
// User messages
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>
)
}
// Assistant messages with content blocks
const hasBlocks = msg.contentBlocks && msg.contentBlocks.length > 0
const isThisMessageStreaming = isSending && msg === messages[messages.length - 1]
return (
<div
key={msg.id}
className={cn(
'flex',
msg.role === 'user' ? 'justify-end' : 'justify-start'
)}
>
<div
className={cn(
'max-w-[85%] rounded-lg px-4 py-2 text-sm',
msg.role === 'user'
? 'bg-[var(--accent)] text-[var(--accent-foreground)]'
: 'bg-[var(--surface-3)] text-[var(--text-primary)]'
<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>
)}
>
<p className='whitespace-pre-wrap'>{msg.content}</p>
</div>
</div>
)

View File

@@ -5,11 +5,38 @@ import { createLogger } from '@sim/logger'
const logger = createLogger('useWorkspaceChat')
interface ChatMessage {
/** Status of a tool call as it progresses through execution. */
export type ToolCallStatus = 'executing' | 'success' | 'error'
/** Lightweight info about a single tool call rendered in the chat. */
export interface ToolCallInfo {
id: string
name: string
status: ToolCallStatus
/** Human-readable title from the backend ToolUI metadata. */
displayTitle?: string
}
/** A content block inside an assistant message. */
export type ContentBlockType = 'text' | 'tool_call' | 'subagent'
export interface ContentBlock {
type: ContentBlockType
/** Text content (for 'text' and 'subagent' blocks). */
content?: string
/** Tool call info (for 'tool_call' blocks). */
toolCall?: ToolCallInfo
}
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
timestamp: string
/** Structured content blocks for rich rendering. When present, prefer over `content`. */
contentBlocks?: ContentBlock[]
/** Name of the currently active subagent (shown as a label while streaming). */
activeSubagent?: string | null
}
interface UseWorkspaceChatProps {
@@ -25,6 +52,20 @@ interface UseWorkspaceChatReturn {
clearMessages: () => void
}
/** Maps subagent IDs to human-readable labels. */
const SUBAGENT_LABELS: Record<string, string> = {
build: 'Building',
deploy: 'Deploying',
auth: 'Connecting credentials',
research: 'Researching',
knowledge: 'Managing knowledge base',
custom_tool: 'Creating tool',
superagent: 'Executing action',
plan: 'Planning',
debug: 'Debugging',
edit: 'Editing workflow',
}
export function useWorkspaceChat({ workspaceId }: UseWorkspaceChatProps): UseWorkspaceChatReturn {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [isSending, setIsSending] = useState(false)
@@ -51,6 +92,8 @@ export function useWorkspaceChat({ workspaceId }: UseWorkspaceChatProps): UseWor
role: 'assistant',
content: '',
timestamp: new Date().toISOString(),
contentBlocks: [],
activeSubagent: null,
}
setMessages((prev) => [...prev, userMessage, assistantMessage])
@@ -58,6 +101,40 @@ export function useWorkspaceChat({ workspaceId }: UseWorkspaceChatProps): UseWor
const abortController = new AbortController()
abortControllerRef.current = abortController
// Mutable refs for the streaming context so we can build content blocks
// without relying on stale React state closures.
const blocksRef: ContentBlock[] = []
const toolCallMapRef = new Map<string, number>() // toolCallId → index in blocksRef
/** Ensure the last block is a text block and return it. */
const ensureTextBlock = (): ContentBlock => {
const last = blocksRef[blocksRef.length - 1]
if (last && last.type === 'text') return last
const newBlock: ContentBlock = { type: 'text', content: '' }
blocksRef.push(newBlock)
return newBlock
}
/** Push updated blocks + content into the assistant message. */
const flushBlocks = (extra?: Partial<ChatMessage>) => {
const fullText = blocksRef
.filter((b) => b.type === 'text')
.map((b) => b.content ?? '')
.join('')
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMessage.id
? {
...msg,
content: fullText,
contentBlocks: [...blocksRef],
...extra,
}
: msg
)
)
}
try {
const response = await fetch('/api/copilot/workspace-chat', {
method: 'POST',
@@ -98,27 +175,123 @@ export function useWorkspaceChat({ workspaceId }: UseWorkspaceChatProps): UseWor
try {
const event = JSON.parse(line.slice(6))
if (event.type === 'chat_id' && event.chatId) {
chatIdRef.current = event.chatId
} else if (event.type === 'content' && event.content) {
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMessage.id
? { ...msg, content: msg.content + event.content }
: msg
)
)
} else if (event.type === 'error') {
setError(event.error || 'An error occurred')
} else if (event.type === 'done') {
if (event.content && typeof event.content === 'string') {
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMessage.id && !msg.content
? { ...msg, content: event.content }
: msg
switch (event.type) {
case 'chat_id': {
if (event.chatId) {
chatIdRef.current = event.chatId
}
break
}
case 'content': {
if (event.content || event.data) {
const chunk =
typeof event.data === 'string' ? event.data : event.content || ''
if (chunk) {
const textBlock = ensureTextBlock()
textBlock.content = (textBlock.content ?? '') + chunk
flushBlocks()
}
}
break
}
case 'tool_generating':
case 'tool_call': {
const toolCallId = event.toolCallId
const toolName = event.toolName || event.data?.name || 'unknown'
if (!toolCallId) break
const ui = event.ui || event.data?.ui
const displayTitle = ui?.title || ui?.phaseLabel
if (!toolCallMapRef.has(toolCallId)) {
const toolBlock: ContentBlock = {
type: 'tool_call',
toolCall: {
id: toolCallId,
name: toolName,
status: 'executing',
displayTitle,
},
}
toolCallMapRef.set(toolCallId, blocksRef.length)
blocksRef.push(toolBlock)
} else {
const idx = toolCallMapRef.get(toolCallId)!
const existing = blocksRef[idx]
if (existing.toolCall) {
existing.toolCall.name = toolName
if (displayTitle) existing.toolCall.displayTitle = displayTitle
}
}
flushBlocks()
break
}
case 'tool_result': {
const toolCallId = event.toolCallId || event.data?.id
if (!toolCallId) break
const idx = toolCallMapRef.get(toolCallId)
if (idx !== undefined) {
const block = blocksRef[idx]
if (block.toolCall) {
block.toolCall.status = event.success ? 'success' : 'error'
}
flushBlocks()
}
break
}
case 'tool_error': {
const toolCallId = event.toolCallId || event.data?.id
if (!toolCallId) break
const idx = toolCallMapRef.get(toolCallId)
if (idx !== undefined) {
const block = blocksRef[idx]
if (block.toolCall) {
block.toolCall.status = 'error'
}
flushBlocks()
}
break
}
case 'subagent_start': {
const subagentName = event.subagent || event.data?.agent
if (subagentName) {
const label = SUBAGENT_LABELS[subagentName] || subagentName
const subBlock: ContentBlock = {
type: 'subagent',
content: label,
}
blocksRef.push(subBlock)
flushBlocks({ activeSubagent: label })
}
break
}
case 'subagent_end': {
flushBlocks({ activeSubagent: null })
break
}
case 'error': {
setError(event.error || 'An error occurred')
break
}
case 'done': {
if (event.content && typeof event.content === 'string') {
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMessage.id && !msg.content
? { ...msg, content: event.content }
: msg
)
)
)
}
break
}
}
} catch {

View File

@@ -250,7 +250,7 @@ export const Sidebar = memo(function Sidebar() {
[
{
id: 'chat',
label: 'Chat',
label: 'Mothership',
icon: MessageSquare,
href: `/workspace/${workspaceId}/chat`,
},