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
@@ -1,6 +1,6 @@
'use client'

import { type ComponentPropsWithoutRef, useEffect, useMemo, useRef } from 'react'
import { type ComponentPropsWithoutRef, memo, useEffect, useMemo, useRef } from 'react'
import { Streamdown } from 'streamdown'
import 'streamdown/styles.css'
import 'prismjs/components/prism-typescript'
Expand Down Expand Up @@ -237,7 +237,7 @@ interface ChatContentProps {
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
}

export function ChatContent({
function ChatContentInner({
content,
isStreaming = false,
onOptionSelect,
Expand Down Expand Up @@ -335,3 +335,5 @@ export function ChatContent({
</div>
)
}

export const ChatContent = memo(ChatContentInner)
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client'

import { memo, useMemo } from 'react'
import {
Read as ReadTool,
ToolSearchToolRegex,
Expand Down Expand Up @@ -407,14 +408,14 @@ interface MessageContentProps {
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
}

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

const segments: MessageSegment[] =
parsed.length > 0
Expand Down Expand Up @@ -537,3 +538,5 @@ export function MessageContent({
</div>
)
}

export const MessageContent = memo(MessageContentInner)
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useLayoutEffect, useRef } from 'react'
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, 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'
Expand All @@ -17,6 +17,9 @@ import {
import { UserMessageContent } from '@/app/workspace/[workspaceId]/home/components/user-message-content'
import type {
ChatMessage,
ChatMessageAttachment,
ChatMessageContext,
ContentBlock,
FileAttachmentForApi,
MothershipResource,
QueuedMessage,
Expand Down Expand Up @@ -78,6 +81,100 @@ const LAYOUT_STYLES = {
},
} as const

const EMPTY_BLOCKS: ContentBlock[] = []

interface UserMessageRowProps {
content: string
contexts?: ChatMessageContext[]
attachments?: ChatMessageAttachment[]
rowClassName: string
bubbleClassName: string
attachmentWidthClassName: string
}

const UserMessageRow = memo(function UserMessageRow({
content,
contexts,
attachments,
rowClassName,
bubbleClassName,
attachmentWidthClassName,
}: UserMessageRowProps) {
const hasAttachments = Boolean(attachments?.length)
return (
<div className={rowClassName}>
{hasAttachments && (
<ChatMessageAttachments
attachments={attachments ?? []}
align='end'
className={attachmentWidthClassName}
/>
)}
<div className={bubbleClassName}>
<UserMessageContent content={content} contexts={contexts} />
</div>
</div>
)
})

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
const trimmedContent = message.content?.trim() ?? ''

if (!hasAnyBlocks && !trimmedContent && isStreaming) {
return <PendingTagIndicator />
}

const hasRenderableAssistant = assistantMessageHasRenderableContent(blocks, message.content ?? '')
if (!hasRenderableAssistant && !trimmedContent && !isStreaming) {
return null
}

const showActions = !isStreaming && (message.content || hasAnyBlocks)

return (
<div className={rowClassName}>
<MessageContent
blocks={blocks}
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}
/>
</div>
)}
</div>
)
})

export function MothershipChat({
messages,
isSending,
Expand Down Expand Up @@ -111,17 +208,31 @@ export function MothershipChat({
const { staged: stagedMessages, isStaging } = useProgressiveList(messages, stagingKey)
const stagedMessageCount = stagedMessages.length
const stagedOffset = messages.length - stagedMessages.length
const precedingUserContentByIndex: Array<string | undefined> = []
let lastUserContent: string | undefined
for (const [index, message] of messages.entries()) {
precedingUserContentByIndex[index] = lastUserContent
if (message.role === 'user') {
lastUserContent = message.content
const precedingUserContentByIndex = useMemo(() => {
const out: Array<string | undefined> = []
let lastUserContent: string | undefined
for (const [index, message] of messages.entries()) {
out[index] = lastUserContent
if (message.role === 'user') lastUserContent = message.content
}
}
return out
}, [messages])
const initialScrollDoneRef = useRef(false)
const userInputRef = useRef<UserInputHandle>(null)

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

function handleSendQueuedHead() {
const topMessage = messageQueue[0]
if (!topMessage) return
Expand Down Expand Up @@ -164,63 +275,31 @@ export function MothershipChat({
{stagedMessages.map((msg, localIndex) => {
const index = stagedOffset + localIndex
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>
<UserMessageRow
key={msg.id}
content={msg.content}
contexts={msg.contexts}
attachments={msg.attachments}
rowClassName={styles.userRow}
bubbleClassName={styles.userBubble}
attachmentWidthClassName={styles.attachmentWidth}
/>
)
}

const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
const hasRenderableAssistant = assistantMessageHasRenderableContent(
msg.contentBlocks ?? [],
msg.content ?? ''
)
const isLastAssistant = index === messages.length - 1
const isThisStreaming = isStreamActive && 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
const precedingUserContent = precedingUserContentByIndex[index]

const isLast = index === messages.length - 1
return (
<div key={msg.id} className={styles.assistantRow}>
<MessageContent
blocks={msg.contentBlocks || []}
fallbackContent={msg.content}
isStreaming={isThisStreaming}
onOptionSelect={isLastMessage ? onSubmit : undefined}
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
/>
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
<div className='mt-2.5'>
<MessageActions
content={msg.content}
chatId={chatId}
userQuery={precedingUserContent}
requestId={msg.requestId}
messageId={msg.id}
/>
</div>
)}
</div>
<AssistantMessageRow
key={msg.id}
message={msg}
isStreaming={isStreamActive && isLast}
precedingUserContent={precedingUserContentByIndex[index]}
chatId={chatId}
rowClassName={styles.assistantRow}
onOptionSelect={isLast ? stableOnOptionSelect : undefined}
onWorkspaceResourceSelect={stableOnWorkspaceResourceSelect}
/>
)
})}
</div>
Expand Down
12 changes: 12 additions & 0 deletions apps/sim/lib/copilot/chat/display-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,18 @@ function toDisplayContexts(
}))
}

const displayMessageCache = new WeakMap<PersistedMessage, ChatMessage>()

/**
* Maps a `PersistedMessage` (server wire shape) to a `ChatMessage` (UI shape).
* Reference-stable: returns the same object for a given `PersistedMessage`
* instance so `React.memo` boundaries downstream of React Query's structural
* sharing can short-circuit on identity.
*/
export function toDisplayMessage(msg: PersistedMessage): ChatMessage {
const cached = displayMessageCache.get(msg)
if (cached) return cached
Comment thread
waleedlatif1 marked this conversation as resolved.

const display: ChatMessage = {
id: msg.id,
role: msg.role,
Expand All @@ -136,5 +147,6 @@ export function toDisplayMessage(msg: PersistedMessage): ChatMessage {

display.contexts = toDisplayContexts(msg.contexts)

displayMessageCache.set(msg, display)
return display
}
Loading