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 }