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 @@ -17,6 +17,7 @@ import {
toast,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
import { useForkMothershipChat } from '@/hooks/queries/mothership-chats'
import { useFolderStore } from '@/stores/folders/store'
Expand Down Expand Up @@ -49,21 +50,20 @@ const BUTTON_CLASS =

interface MessageActionsProps {
content: string
chatId?: string
userQuery?: string
requestId?: string
messageId?: string
}

export const MessageActions = memo(function MessageActions({
content,
chatId,
userQuery,
requestId,
messageId,
}: MessageActionsProps) {
const router = useRouter()
const params = useParams<{ workspaceId: string }>()
const { chatId } = useChatSurface()
const [copied, setCopied] = useState(false)
const [copiedRequestId, setCopiedRequestId] = useState(false)
const [pendingFeedback, setPendingFeedback] = useState<'up' | 'down' | null>(null)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
'use client'

import {
createContext,
type ReactNode,
useCallback,
useContext,
useLayoutEffect,
useMemo,
useRef,
} from 'react'
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
import type { ChatContext } from '@/stores/panel'

/**
* Identity and interaction callbacks shared across a Mothership chat surface
* (home conversation view, home initial view, copilot panel). Carried via
* context so leaf components (UserInput, MessageContent, MessageActions) can
* consume them without relaying through every intermediate component.
*/
interface ChatSurfaceContextValue {
/** Resolved id of the chat backing this surface, if one exists yet. */
chatId?: string
/** Id of the user interacting with this surface. */
userId?: string
/** Notifies the surface owner that a context chip was added to the input. */
onContextAdd: (context: ChatContext) => void
/** Notifies the surface owner that a context chip was removed from the input. */
onContextRemove: (context: ChatContext) => void
/** Opens a workspace resource referenced from rendered message content. */
onWorkspaceResourceSelect: (resource: MothershipResource) => void
}

const noop = () => {}

const ChatSurfaceContext = createContext<ChatSurfaceContextValue>({
onContextAdd: noop,
onContextRemove: noop,
onWorkspaceResourceSelect: noop,
})

interface ChatSurfaceProviderProps {
chatId?: string
userId?: string
onContextAdd?: (context: ChatContext) => void
onContextRemove?: (context: ChatContext) => void
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
children: ReactNode
}

/**
* Provides the chat-surface identity and interaction callbacks to descendants.
* Callbacks are latched in refs and exposed as stable wrappers so the memoized
* context value only changes when `chatId` or `userId` change — consumers do
* not re-render when a parent re-creates a handler.
*/
export function ChatSurfaceProvider({
chatId,
userId,
onContextAdd,
onContextRemove,
onWorkspaceResourceSelect,
children,
}: ChatSurfaceProviderProps) {
const onContextAddRef = useRef(onContextAdd)
const onContextRemoveRef = useRef(onContextRemove)
const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect)

useLayoutEffect(() => {
onContextAddRef.current = onContextAdd
onContextRemoveRef.current = onContextRemove
onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect
})

const stableOnContextAdd = useCallback((context: ChatContext) => {
onContextAddRef.current?.(context)
}, [])
const stableOnContextRemove = useCallback((context: ChatContext) => {
onContextRemoveRef.current?.(context)
}, [])
const stableOnWorkspaceResourceSelect = useCallback((resource: MothershipResource) => {
onWorkspaceResourceSelectRef.current?.(resource)
}, [])

const value = useMemo<ChatSurfaceContextValue>(
() => ({
chatId,
userId,
onContextAdd: stableOnContextAdd,
onContextRemove: stableOnContextRemove,
onWorkspaceResourceSelect: stableOnWorkspaceResourceSelect,
}),
[chatId, userId, stableOnContextAdd, stableOnContextRemove, stableOnWorkspaceResourceSelect]
)

return <ChatSurfaceContext.Provider value={value}>{children}</ChatSurfaceContext.Provider>
}

/**
* Reads the surrounding chat surface. Outside a provider this returns no-op
* callbacks and undefined identity, matching the previous optional-prop
* behavior.
*/
export function useChatSurface(): ChatSurfaceContextValue {
return useContext(ChatSurfaceContext)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ChatSurfaceProvider, useChatSurface } from './chat-surface-context'
5 changes: 5 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/home/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
export { ChatMessageAttachments } from './chat-message-attachments'
export { ChatSurfaceProvider, useChatSurface } from './chat-surface-context'
export { ContextMentionIcon } from './context-mention-icon'
export { CreditsChip } from './credits-chip'
export {
assistantMessageHasRenderableContent,
MessageContent,
} from './message-content'
export { MothershipChat } from './mothership-chat'
export {
MothershipResourcesProvider,
useMothershipResources,
} from './mothership-resources-context'
export { MothershipView } from './mothership-view'
export { QueuedMessages } from './queued-messages'
export { SuggestedActions } from './suggested-actions'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { Read as ReadTool, WorkspaceFile } from '@/lib/copilot/generated/tool-ca
import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils'
import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state'
import type { ContentBlock, MothershipResource, OptionItem, ToolCallData } from '../../types'
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
import type { ContentBlock, OptionItem, ToolCallData } from '../../types'
import { SUBAGENT_LABELS, TOOL_UI_METADATA } from '../../types'
import type { AgentGroupItem } from './components'
import {
Expand Down Expand Up @@ -676,16 +677,15 @@ interface MessageContentProps {
fallbackContent: string
isStreaming: boolean
onOptionSelect?: (id: string) => void
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
}

function MessageContentInner({
blocks,
fallbackContent,
isStreaming = false,
onOptionSelect,
onWorkspaceResourceSelect,
}: MessageContentProps) {
const { onWorkspaceResourceSelect } = useChatSurface()
const parsed = useMemo(() => (blocks.length > 0 ? parseBlocks(blocks) : []), [blocks])

const segments: MessageSegment[] =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from '
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 { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
import {
assistantMessageHasRenderableContent,
MessageContent,
Expand Down Expand Up @@ -124,20 +125,16 @@ interface AssistantMessageRowProps {
message: ChatMessage
isStreaming: boolean
precedingUserContent?: string
chatId?: string
rowClassName: string
onOptionSelect?: (id: string) => void
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
}

const AssistantMessageRow = memo(function AssistantMessageRow({
message,
isStreaming,
precedingUserContent,
chatId,
rowClassName,
onOptionSelect,
onWorkspaceResourceSelect,
}: AssistantMessageRowProps) {
const blocks = message.contentBlocks ?? EMPTY_BLOCKS
const hasAnyBlocks = blocks.length > 0
Expand All @@ -161,13 +158,11 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
fallbackContent={message.content}
isStreaming={isStreaming}
onOptionSelect={onOptionSelect}
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
/>
{showActions && (
<div className='mt-2.5'>
<MessageActions
content={message.content}
chatId={chatId}
userQuery={precedingUserContent}
requestId={message.requestId}
messageId={message.id}
Expand Down Expand Up @@ -241,17 +236,12 @@ export function MothershipChat({
const userInputRef = useRef<UserInputHandle>(null)

const onSubmitRef = useRef(onSubmit)
const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect)
useEffect(() => {
onSubmitRef.current = onSubmit
onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect
}, [onSubmit, onWorkspaceResourceSelect])
}, [onSubmit])
const stableOnOptionSelect = useCallback((id: string) => {
onSubmitRef.current(id)
}, [])
const stableOnWorkspaceResourceSelect = useCallback((resource: MothershipResource) => {
onWorkspaceResourceSelectRef.current?.(resource)
}, [])

function handleSendQueuedHead() {
const topMessage = messageQueue[0]
Expand Down Expand Up @@ -286,75 +276,78 @@ export function MothershipChat({
}, [isStaging, stagedMessageCount, initialScrollBlocked, scrollToBottom])

return (
<div className={cn('flex h-full min-h-0 flex-col', className)}>
<div ref={scrollContainerRef} className={styles.scrollContainer}>
{isLoading && !hasMessages ? (
<MothershipChatSkeleton layout={layout} />
) : (
<div className={styles.content}>
{stagedMessages.map((msg, localIndex) => {
const index = stagedOffset + localIndex
if (msg.role === 'user') {
<ChatSurfaceProvider
chatId={chatId}
userId={userId}
onContextAdd={onContextAdd}
onContextRemove={onContextRemove}
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
>
<div className={cn('flex h-full min-h-0 flex-col', className)}>
<div ref={scrollContainerRef} className={styles.scrollContainer}>
{isLoading && !hasMessages ? (
<MothershipChatSkeleton layout={layout} />
) : (
<div className={styles.content}>
{stagedMessages.map((msg, localIndex) => {
const index = stagedOffset + localIndex
if (msg.role === 'user') {
return (
<UserMessageRow
key={msg.id}
content={msg.content}
contexts={msg.contexts}
attachments={msg.attachments}
rowClassName={styles.userRow}
bubbleClassName={styles.userBubble}
attachmentWidthClassName={styles.attachmentWidth}
/>
)
}

const isLast = index === messages.length - 1
return (
<UserMessageRow
key={msg.id}
content={msg.content}
contexts={msg.contexts}
attachments={msg.attachments}
rowClassName={styles.userRow}
bubbleClassName={styles.userBubble}
attachmentWidthClassName={styles.attachmentWidth}
<AssistantMessageRow
key={assistantTurnKeyByIndex[index] ?? msg.id}
message={msg}
isStreaming={isStreamActive && isLast}
precedingUserContent={precedingUserContentByIndex[index]}
rowClassName={styles.assistantRow}
onOptionSelect={isLast ? stableOnOptionSelect : undefined}
/>
)
}
})}
</div>
)}
</div>

const isLast = index === messages.length - 1
return (
<AssistantMessageRow
key={assistantTurnKeyByIndex[index] ?? msg.id}
message={msg}
isStreaming={isStreamActive && isLast}
precedingUserContent={precedingUserContentByIndex[index]}
chatId={chatId}
rowClassName={styles.assistantRow}
onOptionSelect={isLast ? stableOnOptionSelect : undefined}
onWorkspaceResourceSelect={stableOnWorkspaceResourceSelect}
/>
)
})}
<div
className={cn(styles.footer, animateInput && 'animate-slide-in-bottom')}
onAnimationEnd={animateInput ? onInputAnimationEnd : undefined}
>
<div className={styles.footerInner}>
<QueuedMessages
messageQueue={messageQueue}
editingQueuedId={editingQueuedId}
dispatchingHeadId={dispatchingHeadId}
onRemove={onRemoveQueuedMessage}
onSendNow={onSendQueuedMessage}
onEdit={handleEditQueued}
onCancelEdit={onCancelQueueEdit}
/>
<UserInput
ref={userInputRef}
onSubmit={onSubmit}
isSending={isStreamActive}
onStopGeneration={onStopGeneration}
isInitialView={false}
onSendQueuedHead={handleSendQueuedHead}
onEditQueuedTail={handleEditQueuedTail}
draftScopeKey={draftScopeKey}
/>
</div>
)}
</div>

<div
className={cn(styles.footer, animateInput && 'animate-slide-in-bottom')}
onAnimationEnd={animateInput ? onInputAnimationEnd : undefined}
>
<div className={styles.footerInner}>
<QueuedMessages
messageQueue={messageQueue}
editingQueuedId={editingQueuedId}
dispatchingHeadId={dispatchingHeadId}
onRemove={onRemoveQueuedMessage}
onSendNow={onSendQueuedMessage}
onEdit={handleEditQueued}
onCancelEdit={onCancelQueueEdit}
/>
<UserInput
ref={userInputRef}
onSubmit={onSubmit}
isSending={isStreamActive}
onStopGeneration={onStopGeneration}
isInitialView={false}
userId={userId}
onContextAdd={onContextAdd}
onContextRemove={onContextRemove}
onSendQueuedHead={handleSendQueuedHead}
onEditQueuedTail={handleEditQueuedTail}
draftScopeKey={draftScopeKey}
/>
</div>
</div>
</div>
</ChatSurfaceProvider>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export {
MothershipResourcesProvider,
useMothershipResources,
} from './mothership-resources-context'
Loading
Loading