Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty sure this is actually needed - agent-group is unmounted after auto collapsed and needs a timer after the fact to animate itself closed.

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
Expand All @@ -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'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
)
}
Loading
Loading