improvement(ui): Merge ui components for mothership chat (#3748)

* improvement(ui): Merge ui definitions for mothership chat

* Fix lint

* Restore copilot layout

* Fix subagent text not animating collapses

---------

Co-authored-by: Theodore Li <theo@sim.ai>
This commit is contained in:
Theodore Li
2026-03-24 20:19:04 -04:00
committed by GitHub
co-authored by Theodore Li
parent f6975fc0a3
commit cdea2404e3
8 changed files with 292 additions and 209 deletions
@@ -3,6 +3,7 @@ export {
assistantMessageHasRenderableContent,
MessageContent,
} from './message-content'
export { MothershipChat } from './mothership-chat/mothership-chat'
export { MothershipView } from './mothership-view'
export { QueuedMessages } from './queued-messages'
export { TemplatePrompts } from './template-prompts'
@@ -44,6 +44,21 @@ export function AgentGroup({
const [expanded, setExpanded] = useState(defaultExpanded || !allDone)
const [mounted, setMounted] = useState(defaultExpanded || !allDone)
const didAutoCollapseRef = useRef(allDone)
const wasAutoExpandedRef = useRef(defaultExpanded)
useEffect(() => {
if (defaultExpanded) {
wasAutoExpandedRef.current = true
setMounted(true)
setExpanded(true)
return
}
if (wasAutoExpandedRef.current && allDone) {
wasAutoExpandedRef.current = false
setExpanded(false)
}
}, [defaultExpanded, allDone])
useEffect(() => {
if (!autoCollapse || didAutoCollapseRef.current) return
@@ -65,7 +80,10 @@ export function AgentGroup({
{hasItems ? (
<button
type='button'
onClick={() => setExpanded((prev) => !prev)}
onClick={() => {
wasAutoExpandedRef.current = false
setExpanded((prev) => !prev)
}}
className='flex cursor-pointer items-center gap-[8px]'
>
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
@@ -389,7 +389,7 @@ export function MessageContent({
return (
<div key={segment.id} className={isStreaming ? 'animate-stream-fade-in' : undefined}>
<AgentGroup
key={`${segment.id}-${segment.id === lastOpenSubagentGroupId ? 'expanded' : 'default'}`}
key={segment.id}
agentName={segment.agentName}
agentLabel={segment.agentLabel}
items={segment.items}
@@ -0,0 +1,190 @@
'use client'
import { useLayoutEffect, useRef } from 'react'
import { cn } from '@/lib/core/utils/cn'
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments'
import {
assistantMessageHasRenderableContent,
MessageContent,
} from '@/app/workspace/[workspaceId]/home/components/message-content'
import { PendingTagIndicator } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages'
import { UserInput } from '@/app/workspace/[workspaceId]/home/components/user-input'
import { UserMessageContent } from '@/app/workspace/[workspaceId]/home/components/user-message-content'
import { useAutoScroll } from '@/app/workspace/[workspaceId]/home/hooks'
import type {
ChatMessage,
FileAttachmentForApi,
QueuedMessage,
} from '@/app/workspace/[workspaceId]/home/types'
import type { ChatContext } from '@/stores/panel'
interface MothershipChatProps {
messages: ChatMessage[]
isSending: boolean
onSubmit: (
text: string,
fileAttachments?: FileAttachmentForApi[],
contexts?: ChatContext[]
) => void
onStopGeneration: () => void
messageQueue: QueuedMessage[]
onRemoveQueuedMessage: (id: string) => void
onSendQueuedMessage: (id: string) => Promise<void>
onEditQueuedMessage: (id: string) => void
userId?: string
onContextAdd?: (context: ChatContext) => void
editValue?: string
onEditValueConsumed?: () => void
layout?: 'mothership-view' | 'copilot-view'
initialScrollBlocked?: boolean
animateInput?: boolean
onInputAnimationEnd?: () => void
className?: string
}
const LAYOUT_STYLES = {
'mothership-view': {
scrollContainer:
'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable]',
content: 'mx-auto max-w-[42rem] space-y-6',
userRow: 'flex flex-col items-end gap-[6px] pt-3',
attachmentWidth: 'max-w-[70%]',
userBubble: 'max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2',
assistantRow: 'group/msg relative pb-5',
footer: 'flex-shrink-0 px-[24px] pb-[16px]',
footerInner: 'mx-auto max-w-[42rem]',
},
'copilot-view': {
scrollContainer: 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4',
content: 'space-y-4',
userRow: 'flex flex-col items-end gap-[6px] pt-2',
attachmentWidth: 'max-w-[85%]',
userBubble: 'max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2',
assistantRow: 'group/msg relative pb-3',
footer: 'flex-shrink-0 px-3 pb-3',
footerInner: '',
},
} as const
export function MothershipChat({
messages,
isSending,
onSubmit,
onStopGeneration,
messageQueue,
onRemoveQueuedMessage,
onSendQueuedMessage,
onEditQueuedMessage,
userId,
onContextAdd,
editValue,
onEditValueConsumed,
layout = 'mothership-view',
initialScrollBlocked = false,
animateInput = false,
onInputAnimationEnd,
className,
}: MothershipChatProps) {
const styles = LAYOUT_STYLES[layout]
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isSending)
const hasMessages = messages.length > 0
const initialScrollDoneRef = useRef(false)
useLayoutEffect(() => {
if (!hasMessages) {
initialScrollDoneRef.current = false
return
}
if (initialScrollDoneRef.current || initialScrollBlocked) return
initialScrollDoneRef.current = true
scrollToBottom()
}, [hasMessages, initialScrollBlocked, scrollToBottom])
return (
<div className={cn('flex h-full min-h-0 flex-col', className)}>
<div ref={scrollContainerRef} className={styles.scrollContainer}>
<div className={styles.content}>
{messages.map((msg, index) => {
if (msg.role === 'user') {
const hasAttachments = Boolean(msg.attachments?.length)
return (
<div key={msg.id} className={styles.userRow}>
{hasAttachments && (
<ChatMessageAttachments
attachments={msg.attachments ?? []}
align='end'
className={styles.attachmentWidth}
/>
)}
<div className={styles.userBubble}>
<UserMessageContent content={msg.content} contexts={msg.contexts} />
</div>
</div>
)
}
const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
const hasRenderableAssistant = assistantMessageHasRenderableContent(
msg.contentBlocks ?? [],
msg.content ?? ''
)
const isLastAssistant = index === messages.length - 1
const isThisStreaming = isSending && isLastAssistant
if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
return <PendingTagIndicator key={msg.id} />
}
if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) {
return null
}
const isLastMessage = index === messages.length - 1
return (
<div key={msg.id} className={styles.assistantRow}>
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
<div className='absolute right-0 bottom-0 z-10'>
<MessageActions content={msg.content} requestId={msg.requestId} />
</div>
)}
<MessageContent
blocks={msg.contentBlocks || []}
fallbackContent={msg.content}
isStreaming={isThisStreaming}
onOptionSelect={isLastMessage ? onSubmit : undefined}
/>
</div>
)
})}
</div>
</div>
<div
className={cn(styles.footer, animateInput && 'animate-slide-in-bottom')}
onAnimationEnd={animateInput ? onInputAnimationEnd : undefined}
>
<div className={styles.footerInner}>
<QueuedMessages
messageQueue={messageQueue}
onRemove={onRemoveQueuedMessage}
onSendNow={onSendQueuedMessage}
onEdit={onEditQueuedMessage}
/>
<UserInput
onSubmit={onSubmit}
isSending={isSending}
onStopGeneration={onStopGeneration}
isInitialView={false}
userId={userId}
onContextAdd={onContextAdd}
editValue={editValue}
onEditValueConsumed={onEditValueConsumed}
/>
</div>
</div>
</div>
)
}
@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { PanelLeft } from '@/components/emcn/icons'
@@ -11,21 +11,10 @@ import {
LandingWorkflowSeedStorage,
} from '@/lib/core/utils/browser-storage'
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
import type { ChatContext } from '@/stores/panel'
import {
assistantMessageHasRenderableContent,
ChatMessageAttachments,
MessageContent,
MothershipView,
QueuedMessages,
TemplatePrompts,
UserInput,
UserMessageContent,
} from './components'
import { PendingTagIndicator } from './components/message-content/components/special-tags'
import { useAutoScroll, useChat, useMothershipResize } from './hooks'
import { MothershipChat, MothershipView, TemplatePrompts, UserInput } from './components'
import { getMothershipUseChatOptions, useChat, useMothershipResize } from './hooks'
import type { FileAttachmentForApi, MothershipResource, MothershipResourceType } from './types'
const logger = createLogger('Home')
@@ -173,7 +162,11 @@ export function Home({ chatId }: HomeProps = {}) {
sendNow,
editQueuedMessage,
streamingFile,
} = useChat(workspaceId, chatId, { onResourceEvent: handleResourceEvent })
} = useChat(
workspaceId,
chatId,
getMothershipUseChatOptions({ onResourceEvent: handleResourceEvent })
)
const [editingInputValue, setEditingInputValue] = useState('')
const [prevChatId, setPrevChatId] = useState(chatId)
@@ -285,22 +278,7 @@ export function Home({ chatId }: HomeProps = {}) {
[addResource, handleResourceEvent]
)
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isSending)
const hasMessages = messages.length > 0
const initialScrollDoneRef = useRef(false)
useLayoutEffect(() => {
if (!hasMessages) {
initialScrollDoneRef.current = false
return
}
if (initialScrollDoneRef.current) return
if (resources.length > 0 && isResourceCollapsed) return
initialScrollDoneRef.current = true
scrollToBottom()
}, [hasMessages, resources.length, isResourceCollapsed, scrollToBottom])
useEffect(() => {
if (hasMessages) return
@@ -354,90 +332,23 @@ export function Home({ chatId }: HomeProps = {}) {
return (
<div className='relative flex h-full bg-[var(--bg)]'>
<div className='flex h-full min-w-[320px] flex-1 flex-col'>
<div
ref={scrollContainerRef}
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable]'
>
<div className='mx-auto max-w-[42rem] space-y-6'>
{messages.map((msg, index) => {
if (msg.role === 'user') {
const hasAttachments = msg.attachments && msg.attachments.length > 0
return (
<div key={msg.id} className='flex flex-col items-end gap-[6px] pt-3'>
{hasAttachments && (
<ChatMessageAttachments
attachments={msg.attachments!}
align='end'
className='max-w-[70%]'
/>
)}
<div className='max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2'>
<UserMessageContent content={msg.content} contexts={msg.contexts} />
</div>
</div>
)
}
const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
const hasRenderableAssistant = assistantMessageHasRenderableContent(
msg.contentBlocks ?? [],
msg.content ?? ''
)
const isLastAssistant = msg.role === 'assistant' && index === messages.length - 1
const isThisStreaming = isSending && isLastAssistant
if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
return <PendingTagIndicator key={msg.id} />
}
if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) {
return null
}
const isLastMessage = index === messages.length - 1
return (
<div key={msg.id} className='group/msg relative pb-5'>
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
<div className='absolute right-0 bottom-0 z-10'>
<MessageActions content={msg.content} requestId={msg.requestId} />
</div>
)}
<MessageContent
blocks={msg.contentBlocks || []}
fallbackContent={msg.content}
isStreaming={isThisStreaming}
onOptionSelect={isLastMessage ? sendMessage : undefined}
/>
</div>
)
})}
</div>
</div>
<div
className={`flex-shrink-0 px-[24px] pb-[16px]${isInputEntering ? ' animate-slide-in-bottom' : ''}`}
onAnimationEnd={isInputEntering ? () => setIsInputEntering(false) : undefined}
>
<div className='mx-auto max-w-[42rem]'>
<QueuedMessages
messageQueue={messageQueue}
onRemove={removeFromQueue}
onSendNow={sendNow}
onEdit={handleEditQueuedMessage}
/>
<UserInput
onSubmit={handleSubmit}
isSending={isSending}
onStopGeneration={stopGeneration}
isInitialView={false}
userId={session?.user?.id}
onContextAdd={handleContextAdd}
editValue={editingInputValue}
onEditValueConsumed={clearEditingValue}
/>
</div>
</div>
<MothershipChat
messages={messages}
isSending={isSending}
onSubmit={handleSubmit}
onStopGeneration={stopGeneration}
messageQueue={messageQueue}
onRemoveQueuedMessage={removeFromQueue}
onSendQueuedMessage={sendNow}
onEditQueuedMessage={handleEditQueuedMessage}
userId={session?.user?.id}
onContextAdd={handleContextAdd}
editValue={editingInputValue}
onEditValueConsumed={clearEditingValue}
animateInput={isInputEntering}
onInputAnimationEnd={isInputEntering ? () => setIsInputEntering(false) : undefined}
initialScrollBlocked={resources.length > 0 && isResourceCollapsed}
/>
</div>
{/* Resize handle — zero-width flex child whose absolute child straddles the border */}
@@ -1,6 +1,10 @@
export { useAnimatedPlaceholder } from './use-animated-placeholder'
export { useAutoScroll } from './use-auto-scroll'
export type { UseChatReturn } from './use-chat'
export { useChat } from './use-chat'
export {
getMothershipUseChatOptions,
getWorkflowCopilotUseChatOptions,
useChat,
} from './use-chat'
export { useMothershipResize } from './use-mothership-resize'
export { useStreamingReveal } from './use-streaming-reveal'
@@ -8,7 +8,7 @@ import {
markRunToolManuallyStopped,
reportManualRunToolStop,
} from '@/lib/copilot/client-sse/run-tool-execution'
import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
import { COPILOT_CHAT_API_PATH, MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
import {
extractResourcesFromToolResult,
isResourceToolName,
@@ -265,6 +265,29 @@ export interface UseChatOptions {
onStreamEnd?: (chatId: string, messages: ChatMessage[]) => void
}
export function getMothershipUseChatOptions(
options: Pick<UseChatOptions, 'onResourceEvent' | 'onStreamEnd'> = {}
): UseChatOptions {
return {
apiPath: MOTHERSHIP_CHAT_API_PATH,
stopPath: '/api/mothership/chat/stop',
...options,
}
}
export function getWorkflowCopilotUseChatOptions(
options: Pick<
UseChatOptions,
'workflowId' | 'onToolResult' | 'onTitleUpdate' | 'onStreamEnd'
> = {}
): UseChatOptions {
return {
apiPath: COPILOT_CHAT_API_PATH,
stopPath: '/api/mothership/chat/stop',
...options,
}
}
export function useChat(
workspaceId: string,
initialChatId?: string,
@@ -34,16 +34,9 @@ import { Lock, Unlock, Upload } from '@/components/emcn/icons'
import { VariableIcon } from '@/components/icons'
import { useSession } from '@/lib/auth/auth-client'
import { generateWorkflowJson } from '@/lib/workflows/operations/import-export'
import { ConversationListItem, MessageActions } from '@/app/workspace/[workspaceId]/components'
import {
assistantMessageHasRenderableContent,
MessageContent,
QueuedMessages,
UserInput,
UserMessageContent,
} from '@/app/workspace/[workspaceId]/home/components'
import { PendingTagIndicator } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import { useAutoScroll, useChat } from '@/app/workspace/[workspaceId]/home/hooks'
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components'
import { getWorkflowCopilotUseChatOptions, useChat } from '@/app/workspace/[workspaceId]/home/hooks'
import type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -332,13 +325,15 @@ export const Panel = memo(function Panel() {
removeFromQueue: copilotRemoveFromQueue,
sendNow: copilotSendNow,
editQueuedMessage: copilotEditQueuedMessage,
} = useChat(workspaceId, copilotChatId, {
apiPath: '/api/copilot/chat',
stopPath: '/api/mothership/chat/stop',
workflowId: activeWorkflowId || undefined,
onTitleUpdate: loadCopilotChats,
onToolResult: handleCopilotToolResult,
})
} = useChat(
workspaceId,
copilotChatId,
getWorkflowCopilotUseChatOptions({
workflowId: activeWorkflowId || undefined,
onTitleUpdate: loadCopilotChats,
onToolResult: handleCopilotToolResult,
})
)
const handleCopilotNewChat = useCallback(() => {
if (!activeWorkflowId || !workspaceId) return
@@ -403,9 +398,6 @@ export const Panel = memo(function Panel() {
[copilotSendMessage]
)
const { ref: copilotScrollRef, scrollToBottom: copilotScrollToBottom } =
useAutoScroll(copilotIsSending)
/**
* Mark hydration as complete on mount
* This allows React to take over visibility control from CSS
@@ -813,77 +805,21 @@ export const Panel = memo(function Panel() {
</div>
</div>
<div
ref={copilotScrollRef}
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4'
>
<div className='space-y-4'>
{copilotMessages.map((msg, index) => {
if (msg.role === 'user') {
return (
<div key={msg.id} className='flex flex-col items-end gap-[6px] pt-2'>
<div className='max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2'>
<UserMessageContent content={msg.content} contexts={msg.contexts} />
</div>
</div>
)
}
const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
const hasRenderableAssistant = assistantMessageHasRenderableContent(
msg.contentBlocks ?? [],
msg.content ?? ''
)
const isLastAssistant =
msg.role === 'assistant' && index === copilotMessages.length - 1
const isThisStreaming = copilotIsSending && isLastAssistant
if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
return <PendingTagIndicator key={msg.id} />
}
if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) {
return null
}
const isLastMessage = index === copilotMessages.length - 1
return (
<div key={msg.id} className='group/msg relative pb-3'>
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
<div className='absolute right-0 bottom-0 z-10'>
<MessageActions content={msg.content} requestId={msg.requestId} />
</div>
)}
<MessageContent
blocks={msg.contentBlocks || []}
fallbackContent={msg.content}
isStreaming={isThisStreaming}
onOptionSelect={isLastMessage ? copilotSendMessage : undefined}
/>
</div>
)
})}
</div>
</div>
<div className='flex-shrink-0 px-3 pb-3'>
<QueuedMessages
messageQueue={copilotMessageQueue}
onRemove={copilotRemoveFromQueue}
onSendNow={copilotSendNow}
onEdit={handleCopilotEditQueuedMessage}
/>
<UserInput
onSubmit={handleCopilotSubmit}
isSending={copilotIsSending}
onStopGeneration={copilotStopGeneration}
isInitialView={false}
userId={session?.user?.id}
editValue={copilotEditingInputValue}
onEditValueConsumed={clearCopilotEditingValue}
/>
</div>
<MothershipChat
className='min-h-0 flex-1'
messages={copilotMessages}
isSending={copilotIsSending}
onSubmit={handleCopilotSubmit}
onStopGeneration={copilotStopGeneration}
messageQueue={copilotMessageQueue}
onRemoveQueuedMessage={copilotRemoveFromQueue}
onSendQueuedMessage={copilotSendNow}
onEditQueuedMessage={handleCopilotEditQueuedMessage}
userId={session?.user?.id}
editValue={copilotEditingInputValue}
onEditValueConsumed={clearCopilotEditingValue}
layout='copilot-view'
/>
</div>
)}
<div