Skip to content

Commit 630db5c

Browse files
authored
improvement(mothership-chat): memoize message rows for long-transcript load speed (#4520)
* improvement(mothership-chat): memoize message rows for long-transcript load speed * improvement(mothership-chat): hoist EMPTY_BLOCKS constant to keep MessageContent memoized for block-free messages
1 parent 3c33c20 commit 630db5c

4 files changed

Lines changed: 160 additions & 64 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

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

240-
export function ChatContent({
240+
function ChatContentInner({
241241
content,
242242
isStreaming = false,
243243
onOptionSelect,
@@ -335,3 +335,5 @@ export function ChatContent({
335335
</div>
336336
)
337337
}
338+
339+
export const ChatContent = memo(ChatContentInner)

apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client'
22

3+
import { memo, useMemo } from 'react'
34
import {
45
Read as ReadTool,
56
ToolSearchToolRegex,
@@ -407,14 +408,14 @@ interface MessageContentProps {
407408
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
408409
}
409410

410-
export function MessageContent({
411+
function MessageContentInner({
411412
blocks,
412413
fallbackContent,
413414
isStreaming = false,
414415
onOptionSelect,
415416
onWorkspaceResourceSelect,
416417
}: MessageContentProps) {
417-
const parsed = blocks.length > 0 ? parseBlocks(blocks) : []
418+
const parsed = useMemo(() => (blocks.length > 0 ? parseBlocks(blocks) : []), [blocks])
418419

419420
const segments: MessageSegment[] =
420421
parsed.length > 0
@@ -537,3 +538,5 @@ export function MessageContent({
537538
</div>
538539
)
539540
}
541+
542+
export const MessageContent = memo(MessageContentInner)

apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx

Lines changed: 139 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useLayoutEffect, useRef } from 'react'
3+
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
44
import { cn } from '@/lib/core/utils/cn'
55
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
66
import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments'
@@ -17,6 +17,9 @@ import {
1717
import { UserMessageContent } from '@/app/workspace/[workspaceId]/home/components/user-message-content'
1818
import type {
1919
ChatMessage,
20+
ChatMessageAttachment,
21+
ChatMessageContext,
22+
ContentBlock,
2023
FileAttachmentForApi,
2124
MothershipResource,
2225
QueuedMessage,
@@ -78,6 +81,100 @@ const LAYOUT_STYLES = {
7881
},
7982
} as const
8083

84+
const EMPTY_BLOCKS: ContentBlock[] = []
85+
86+
interface UserMessageRowProps {
87+
content: string
88+
contexts?: ChatMessageContext[]
89+
attachments?: ChatMessageAttachment[]
90+
rowClassName: string
91+
bubbleClassName: string
92+
attachmentWidthClassName: string
93+
}
94+
95+
const UserMessageRow = memo(function UserMessageRow({
96+
content,
97+
contexts,
98+
attachments,
99+
rowClassName,
100+
bubbleClassName,
101+
attachmentWidthClassName,
102+
}: UserMessageRowProps) {
103+
const hasAttachments = Boolean(attachments?.length)
104+
return (
105+
<div className={rowClassName}>
106+
{hasAttachments && (
107+
<ChatMessageAttachments
108+
attachments={attachments ?? []}
109+
align='end'
110+
className={attachmentWidthClassName}
111+
/>
112+
)}
113+
<div className={bubbleClassName}>
114+
<UserMessageContent content={content} contexts={contexts} />
115+
</div>
116+
</div>
117+
)
118+
})
119+
120+
interface AssistantMessageRowProps {
121+
message: ChatMessage
122+
isStreaming: boolean
123+
precedingUserContent?: string
124+
chatId?: string
125+
rowClassName: string
126+
onOptionSelect?: (id: string) => void
127+
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
128+
}
129+
130+
const AssistantMessageRow = memo(function AssistantMessageRow({
131+
message,
132+
isStreaming,
133+
precedingUserContent,
134+
chatId,
135+
rowClassName,
136+
onOptionSelect,
137+
onWorkspaceResourceSelect,
138+
}: AssistantMessageRowProps) {
139+
const blocks = message.contentBlocks ?? EMPTY_BLOCKS
140+
const hasAnyBlocks = blocks.length > 0
141+
const trimmedContent = message.content?.trim() ?? ''
142+
143+
if (!hasAnyBlocks && !trimmedContent && isStreaming) {
144+
return <PendingTagIndicator />
145+
}
146+
147+
const hasRenderableAssistant = assistantMessageHasRenderableContent(blocks, message.content ?? '')
148+
if (!hasRenderableAssistant && !trimmedContent && !isStreaming) {
149+
return null
150+
}
151+
152+
const showActions = !isStreaming && (message.content || hasAnyBlocks)
153+
154+
return (
155+
<div className={rowClassName}>
156+
<MessageContent
157+
blocks={blocks}
158+
fallbackContent={message.content}
159+
isStreaming={isStreaming}
160+
onOptionSelect={onOptionSelect}
161+
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
162+
/>
163+
{showActions && (
164+
<div className='mt-2.5'>
165+
<MessageActions
166+
content={message.content}
167+
chatId={chatId}
168+
userQuery={precedingUserContent}
169+
requestId={message.requestId}
170+
messageId={message.id}
171+
/>
172+
</div>
173+
)}
174+
</div>
175+
)
176+
})
177+
81178
export function MothershipChat({
82179
messages,
83180
isSending,
@@ -111,17 +208,31 @@ export function MothershipChat({
111208
const { staged: stagedMessages, isStaging } = useProgressiveList(messages, stagingKey)
112209
const stagedMessageCount = stagedMessages.length
113210
const stagedOffset = messages.length - stagedMessages.length
114-
const precedingUserContentByIndex: Array<string | undefined> = []
115-
let lastUserContent: string | undefined
116-
for (const [index, message] of messages.entries()) {
117-
precedingUserContentByIndex[index] = lastUserContent
118-
if (message.role === 'user') {
119-
lastUserContent = message.content
211+
const precedingUserContentByIndex = useMemo(() => {
212+
const out: Array<string | undefined> = []
213+
let lastUserContent: string | undefined
214+
for (const [index, message] of messages.entries()) {
215+
out[index] = lastUserContent
216+
if (message.role === 'user') lastUserContent = message.content
120217
}
121-
}
218+
return out
219+
}, [messages])
122220
const initialScrollDoneRef = useRef(false)
123221
const userInputRef = useRef<UserInputHandle>(null)
124222

223+
const onSubmitRef = useRef(onSubmit)
224+
const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect)
225+
useEffect(() => {
226+
onSubmitRef.current = onSubmit
227+
onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect
228+
}, [onSubmit, onWorkspaceResourceSelect])
229+
const stableOnOptionSelect = useCallback((id: string) => {
230+
onSubmitRef.current(id)
231+
}, [])
232+
const stableOnWorkspaceResourceSelect = useCallback((resource: MothershipResource) => {
233+
onWorkspaceResourceSelectRef.current?.(resource)
234+
}, [])
235+
125236
function handleSendQueuedHead() {
126237
const topMessage = messageQueue[0]
127238
if (!topMessage) return
@@ -164,63 +275,31 @@ export function MothershipChat({
164275
{stagedMessages.map((msg, localIndex) => {
165276
const index = stagedOffset + localIndex
166277
if (msg.role === 'user') {
167-
const hasAttachments = Boolean(msg.attachments?.length)
168278
return (
169-
<div key={msg.id} className={styles.userRow}>
170-
{hasAttachments && (
171-
<ChatMessageAttachments
172-
attachments={msg.attachments ?? []}
173-
align='end'
174-
className={styles.attachmentWidth}
175-
/>
176-
)}
177-
<div className={styles.userBubble}>
178-
<UserMessageContent content={msg.content} contexts={msg.contexts} />
179-
</div>
180-
</div>
279+
<UserMessageRow
280+
key={msg.id}
281+
content={msg.content}
282+
contexts={msg.contexts}
283+
attachments={msg.attachments}
284+
rowClassName={styles.userRow}
285+
bubbleClassName={styles.userBubble}
286+
attachmentWidthClassName={styles.attachmentWidth}
287+
/>
181288
)
182289
}
183290

184-
const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
185-
const hasRenderableAssistant = assistantMessageHasRenderableContent(
186-
msg.contentBlocks ?? [],
187-
msg.content ?? ''
188-
)
189-
const isLastAssistant = index === messages.length - 1
190-
const isThisStreaming = isStreamActive && isLastAssistant
191-
192-
if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
193-
return <PendingTagIndicator key={msg.id} />
194-
}
195-
196-
if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) {
197-
return null
198-
}
199-
200-
const isLastMessage = index === messages.length - 1
201-
const precedingUserContent = precedingUserContentByIndex[index]
202-
291+
const isLast = index === messages.length - 1
203292
return (
204-
<div key={msg.id} className={styles.assistantRow}>
205-
<MessageContent
206-
blocks={msg.contentBlocks || []}
207-
fallbackContent={msg.content}
208-
isStreaming={isThisStreaming}
209-
onOptionSelect={isLastMessage ? onSubmit : undefined}
210-
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
211-
/>
212-
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
213-
<div className='mt-2.5'>
214-
<MessageActions
215-
content={msg.content}
216-
chatId={chatId}
217-
userQuery={precedingUserContent}
218-
requestId={msg.requestId}
219-
messageId={msg.id}
220-
/>
221-
</div>
222-
)}
223-
</div>
293+
<AssistantMessageRow
294+
key={msg.id}
295+
message={msg}
296+
isStreaming={isStreamActive && isLast}
297+
precedingUserContent={precedingUserContentByIndex[index]}
298+
chatId={chatId}
299+
rowClassName={styles.assistantRow}
300+
onOptionSelect={isLast ? stableOnOptionSelect : undefined}
301+
onWorkspaceResourceSelect={stableOnWorkspaceResourceSelect}
302+
/>
224303
)
225304
})}
226305
</div>

apps/sim/lib/copilot/chat/display-message.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,18 @@ function toDisplayContexts(
112112
}))
113113
}
114114

115+
const displayMessageCache = new WeakMap<PersistedMessage, ChatMessage>()
116+
117+
/**
118+
* Maps a `PersistedMessage` (server wire shape) to a `ChatMessage` (UI shape).
119+
* Reference-stable: returns the same object for a given `PersistedMessage`
120+
* instance so `React.memo` boundaries downstream of React Query's structural
121+
* sharing can short-circuit on identity.
122+
*/
115123
export function toDisplayMessage(msg: PersistedMessage): ChatMessage {
124+
const cached = displayMessageCache.get(msg)
125+
if (cached) return cached
126+
116127
const display: ChatMessage = {
117128
id: msg.id,
118129
role: msg.role,
@@ -136,5 +147,6 @@ export function toDisplayMessage(msg: PersistedMessage): ChatMessage {
136147

137148
display.contexts = toDisplayContexts(msg.contexts)
138149

150+
displayMessageCache.set(msg, display)
139151
return display
140152
}

0 commit comments

Comments
 (0)