diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
index 7ff6fa6330b..0d79bee8d04 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
@@ -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'
@@ -237,7 +237,7 @@ interface ChatContentProps {
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
}
-export function ChatContent({
+function ChatContentInner({
content,
isStreaming = false,
onOptionSelect,
@@ -335,3 +335,5 @@ export function ChatContent({
)
}
+
+export const ChatContent = memo(ChatContentInner)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx
index 73c5b371948..b512908ec0a 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx
@@ -1,5 +1,6 @@
'use client'
+import { memo, useMemo } from 'react'
import {
Read as ReadTool,
ToolSearchToolRegex,
@@ -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
@@ -537,3 +538,5 @@ export function MessageContent({
)
}
+
+export const MessageContent = memo(MessageContentInner)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
index 4693b19de4a..809c190ffea 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
@@ -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'
@@ -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,
@@ -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 (
+
+ {hasAttachments && (
+
+ )}
+
+
+
+
+ )
+})
+
+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
+ }
+
+ const hasRenderableAssistant = assistantMessageHasRenderableContent(blocks, message.content ?? '')
+ if (!hasRenderableAssistant && !trimmedContent && !isStreaming) {
+ return null
+ }
+
+ const showActions = !isStreaming && (message.content || hasAnyBlocks)
+
+ return (
+
+
+ {showActions && (
+
+
+
+ )}
+
+ )
+})
+
export function MothershipChat({
messages,
isSending,
@@ -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 = []
- 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 = []
+ 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(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
@@ -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 (
-
- {hasAttachments && (
-
- )}
-
-
-
-
+
)
}
- 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
- }
-
- 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 (
-
-
- {!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
-
-
-
- )}
-
+
)
})}
diff --git a/apps/sim/lib/copilot/chat/display-message.ts b/apps/sim/lib/copilot/chat/display-message.ts
index 51622070009..b0e38557021 100644
--- a/apps/sim/lib/copilot/chat/display-message.ts
+++ b/apps/sim/lib/copilot/chat/display-message.ts
@@ -112,7 +112,18 @@ function toDisplayContexts(
}))
}
+const displayMessageCache = new WeakMap()
+
+/**
+ * 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
+
const display: ChatMessage = {
id: msg.id,
role: msg.role,
@@ -136,5 +147,6 @@ export function toDisplayMessage(msg: PersistedMessage): ChatMessage {
display.contexts = toDisplayContexts(msg.contexts)
+ displayMessageCache.set(msg, display)
return display
}