Skip to content

Commit ad8c127

Browse files
authored
improvement(mothership): load chat transcripts as windows from a stored UI projection (#9002)
* improvement(mothership): load chat transcripts as windows from a stored UI projection Chat reads returned every message's full content, tool-call params included, so opening or refreshing a long chat read and shipped tens of MB. Messages now store the chat UI's projection beside their content, the page opens on the newest window, and older history loads in behind it. * fix(mothership): keep older chat history loading through review findings Move the older-page loader out of the server-importable query module, keep a permanent page failure across reloads, bound 404 retries, cap find matches from the newest message, bound cursor seq, count only landed backfill writes, regenerate the schema mock, and skip the trigger test on push-built databases. * fix(mothership): bound chat find match allocation to the match limit
1 parent 2abc906 commit ad8c127

25 files changed

Lines changed: 31959 additions & 54 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { listMothershipChatOlderMessagesContract } from '@/lib/api/contracts/mothership-chats'
2+
import {
3+
defineInternalJsonRoute,
4+
internalJsonPresenters,
5+
internalOrchestrationErrorPolicy,
6+
internalRateLimits,
7+
internalSessionAuth,
8+
} from '@/lib/api/server/routes'
9+
import { readChatOlderMessages } from '@/lib/mothership/chat/application/read-older-messages'
10+
11+
/** Older messages of the owner's chat, which the chat page loads behind its newest window. */
12+
export const GET = defineInternalJsonRoute({
13+
contract: listMothershipChatOlderMessagesContract,
14+
operation: readChatOlderMessages.operation,
15+
auth: internalSessionAuth,
16+
rateLimit: internalRateLimits.none({
17+
reason:
18+
'Pages a transcript the chat detail read used to return whole, which has no separate rate bucket.',
19+
}),
20+
errorPolicy: internalOrchestrationErrorPolicy,
21+
mapInput: ({ params, query }) => ({ chatId: params.chatId, before: query.before }),
22+
useCase: readChatOlderMessages,
23+
present: internalJsonPresenters.withSuccess,
24+
})

‎apps/sim/app/api/mothership/chats/[chatId]/route.ts‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from '@/lib/mothership/chat/live-turn-snapshot'
2323
import { compactToolCallParams, normalizeMessage } from '@/lib/mothership/chat/persisted-message'
2424
import { reconcileChatStreamMarkers } from '@/lib/mothership/chat/stream-liveness'
25+
import { decodeTranscriptCursor } from '@/lib/mothership/chat/transcript-cursor'
2526
import { publishChatStatusChanged } from '@/lib/mothership/chat-status'
2627
import {
2728
authenticateCopilotRequestSessionOnly,
@@ -43,9 +44,22 @@ export const GET = withRouteHandler(
4344
const paramsResult = await parseRequest(getMothershipChatContract, request, context)
4445
if (!paramsResult.success) return paramsResult.response
4546
const { chatId } = paramsResult.data.params
47+
const { window, from } = paramsResult.data.query
48+
const fromPosition = from ? decodeTranscriptCursor(from) : null
49+
if (from && !fromPosition) {
50+
return NextResponse.json({ success: false, error: 'Invalid cursor' }, { status: 400 })
51+
}
4652

47-
const chat = await getAccessibleCopilotChatWithMessages(chatId, userId, { principal })
48-
if (!chat || chat.type !== 'mothership') {
53+
const chat = await getAccessibleCopilotChatWithMessages(chatId, userId, {
54+
principal,
55+
type: 'mothership',
56+
window: fromPosition
57+
? { kind: 'from', position: fromPosition }
58+
: window === 'latest'
59+
? { kind: 'latest' }
60+
: undefined,
61+
})
62+
if (!chat) {
4963
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
5064
}
5165

@@ -95,6 +109,8 @@ export const GET = withRouteHandler(
95109
activeStreamId: liveStreamId,
96110
resources: Array.isArray(chat.resources) ? chat.resources : [],
97111
effort: chat.effort,
112+
olderCursor: chat.olderCursor,
113+
windowStartCursor: chat.windowStartCursor,
98114
createdAt: chat.createdAt,
99115
updatedAt: chat.updatedAt,
100116
// Events stay out of the payload (the resume endpoint replays them),

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

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,9 @@ const OVERSCAN = 6
133133

134134
/**
135135
* How close to the bottom (px) the transcript must be to count as pinned for
136-
* re-pinning across container resizes. Covers the fractional sub-pixel gap a
137-
* DPR-scaled `scrollTop` can leave, without capturing a user who deliberately
138-
* scrolled up.
136+
* re-pinning across container resizes and older messages loading in above.
137+
* Covers the fractional sub-pixel gap a DPR-scaled `scrollTop` can leave,
138+
* without capturing a user who deliberately scrolled up.
139139
*/
140140
const PIN_THRESHOLD = 2
141141
/**
@@ -730,6 +730,32 @@ export function MothershipChat({
730730
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) =>
731731
item.index !== lastIndex && item.start < (instance.scrollElement?.scrollTop ?? 0)
732732

733+
/**
734+
* Older messages load in above the newest window while the chat is open, pushing every row
735+
* below them down. A viewport at the bottom is re-pinned to the last row, which also keeps a
736+
* landing scroll aimed at it rather than at the index it had before the insert; any other
737+
* viewport moves down by the inserted height and keeps showing what the user was reading.
738+
*/
739+
const firstRowKeyRef = useRef<string | undefined>(undefined)
740+
const firstRowKey = rowKeyByIndex[0]
741+
useLayoutEffect(() => {
742+
const previousFirstRowKey = firstRowKeyRef.current
743+
firstRowKeyRef.current = firstRowKey
744+
if (!previousFirstRowKey || previousFirstRowKey === firstRowKey) return
745+
const shiftedIndex = rowKeyByIndex.indexOf(previousFirstRowKey)
746+
const shifted = shiftedIndex > 0 ? virtualizer.measurementsCache[shiftedIndex] : undefined
747+
const first = virtualizer.measurementsCache[0]
748+
const scrollElement = scrollElementRef.current
749+
if (!shifted || !first || !scrollElement) return
750+
const inserted = shifted.start - first.start
751+
const { scrollTop, scrollHeight, clientHeight } = scrollElement
752+
if (scrollHeight - inserted - scrollTop - clientHeight <= PIN_THRESHOLD) {
753+
virtualizer.scrollToIndex(lastIndex, { align: 'end' })
754+
} else {
755+
virtualizer.scrollToOffset(scrollTop + inserted)
756+
}
757+
}, [firstRowKey, lastIndex, rowKeyByIndex, virtualizer])
758+
733759
const scrolledChatRef = useRef<string | undefined | typeof UNSCROLLED>(UNSCROLLED)
734760
const userInputRef = useRef<UserInputHandle>(null)
735761
const messageQueueRef = useRef(messageQueue)

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/use-chat-find.ts‎

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,27 @@ import { getChatFindText } from '@/app/workspace/[workspaceId]/home/components/m
2121
import { getUserMessageText } from '@/app/workspace/[workspaceId]/home/components/user-message-content/utils'
2222
import type { ChatMessage } from '@/app/workspace/[workspaceId]/home/types'
2323

24+
/**
25+
* Most matches a search keeps, counted back from the newest message: older history loads in
26+
* ahead of what is on screen, and a limit counted from the oldest would push the matches the
27+
* user is reading out of the list as it arrives. Within a message the first occurrences are
28+
* kept, the ones `findRanges` highlights.
29+
*/
2430
const MATCH_LIMIT = 500
2531
const EXCLUDED_CONTENT =
2632
'button, [aria-hidden="true"], [data-agent-group], [data-chat-activity], [data-interaction-card], [data-chat-find-ignore], [data-footnote-ref], [data-footnotes]'
2733
const TEXT_BLOCK = 'p, h1, h2, h3, h4, h5, h6, pre, li, td, th'
2834

35+
/** A find match by the message it is in and its occurrence there. */
36+
interface ChatFindMatchIdentity {
37+
messageId: string
38+
occurrence: number
39+
}
40+
41+
interface ChatFindMatch extends ChatFindMatchIdentity {
42+
messageIndex: number
43+
}
44+
2945
interface UseChatFindProps {
3046
chatId?: string
3147
messages: ChatMessage[]
@@ -124,40 +140,68 @@ export function useChatFind({
124140
const [scope, setScope] = useState(chatId)
125141
const [isOpen, setIsOpen] = useState(false)
126142
const [query, setQuery] = useState('')
127-
const [index, setIndex] = useState(0)
143+
/**
144+
* The active match by message and occurrence rather than position, so older messages
145+
* loading in ahead of it keep the same match active instead of shifting to another.
146+
*/
147+
const [activeMatch, setActiveMatch] = useState<ChatFindMatchIdentity | null>(null)
128148
if (scope !== chatId) {
129149
setScope(chatId)
130150
// A pending chat adopting its id (undefined → id) is the same conversation.
131151
if (scope !== undefined) {
132152
setIsOpen(false)
133153
setQuery('')
134-
setIndex(0)
154+
setActiveMatch(null)
135155
}
136156
}
137157
const deferredQuery = useDeferredValue(query)
138158
const term = isOpen ? deferredQuery.trim() : ''
139159
const result = useMemo(() => {
140-
const matches: { messageIndex: number; occurrence: number }[] = []
160+
const newestFirst: ChatFindMatch[][] = []
161+
let count = 0
141162
let truncated = false
142163
if (term) {
143-
for (const [messageIndex, message] of messages.entries()) {
144-
if (hiddenUserByIndex[messageIndex] || message.origin === 'task') continue
164+
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex--) {
165+
const message = messages[messageIndex]
166+
if (!message || hiddenUserByIndex[messageIndex] || message.origin === 'task') continue
145167
const text = getMessageSearchText(message, textCache)
146-
let occurrence = 0
168+
const occurrences: ChatFindMatch[] = []
169+
let found = 0
147170
forEachSearchOccurrence(text, term, () => {
148-
if (matches.length < MATCH_LIMIT) matches.push({ messageIndex, occurrence: occurrence++ })
149-
else truncated = true
171+
if (count + found < MATCH_LIMIT) {
172+
occurrences.push({ messageIndex, messageId: message.id, occurrence: found })
173+
}
174+
found++
150175
})
151-
if (truncated) break
176+
newestFirst.push(occurrences)
177+
count += occurrences.length
178+
if (occurrences.length < found) {
179+
truncated = true
180+
break
181+
}
152182
}
153183
}
154-
return { matches, truncated }
184+
return { matches: newestFirst.reverse().flat(), truncated }
155185
}, [messages, hiddenUserByIndex, term, textCache])
156-
const currentIndex = Math.min(index, Math.max(0, result.matches.length - 1))
186+
const pinnedIndex = activeMatch
187+
? result.matches.findIndex(
188+
(match) =>
189+
match.messageId === activeMatch.messageId && match.occurrence === activeMatch.occurrence
190+
)
191+
: -1
192+
const currentIndex = Math.max(0, pinnedIndex)
157193
const active = result.matches[currentIndex]
158194
const messageIndex = active?.messageIndex
159195
const occurrence = active?.occurrence
160196
const isStale = query !== deferredQuery
197+
if (!isStale && active && pinnedIndex < 0) {
198+
setActiveMatch({ messageId: active.messageId, occurrence: active.occurrence })
199+
}
200+
/** Read by `step`, so stepping keeps one identity while a streaming reply rebuilds the matches. */
201+
const matchesRef = useRef(result.matches)
202+
useLayoutEffect(() => {
203+
matchesRef.current = result.matches
204+
}, [result.matches])
161205

162206
const open = useCallback(() => {
163207
if (document.activeElement instanceof HTMLElement && !inputRef.current) {
@@ -176,7 +220,7 @@ export function useChatFind({
176220
const close = useCallback(() => {
177221
setIsOpen(false)
178222
setQuery('')
179-
setIndex(0)
223+
setActiveMatch(null)
180224
restoreFocus()
181225
}, [restoreFocus])
182226
useLayoutEffect(() => {
@@ -185,14 +229,17 @@ export function useChatFind({
185229
}, [chatId, restoreFocus])
186230
const onQueryChange = useCallback((value: string) => {
187231
setQuery(value)
188-
setIndex(0)
232+
setActiveMatch(null)
189233
}, [])
190234
const step = useCallback(
191235
(delta: number) => {
192-
if (result.matches.length && !isStale)
193-
setIndex((currentIndex + delta + result.matches.length) % result.matches.length)
236+
const matches = matchesRef.current
237+
const target = matches[(currentIndex + delta + matches.length) % matches.length]
238+
if (target && !isStale) {
239+
setActiveMatch({ messageId: target.messageId, occurrence: target.occurrence })
240+
}
194241
},
195-
[currentIndex, result.matches.length, isStale]
242+
[currentIndex, isStale]
196243
)
197244
const next = useCallback(() => step(1), [step])
198245
const prev = useCallback(() => step(-1), [step])

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/sources-resource-content.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,10 @@ export function SourcesResourceContent({ resource, chatId }: SourcesResourceCont
4747
sources.map((source) => <SourceCard key={source.url} source={source} />)
4848
) : (
4949
<p className='px-2 py-2 text-[var(--text-muted)] text-small'>
50-
{history.isPending
50+
{history.isPending ||
51+
(!persisted && history.data?.olderCursor && !history.data.olderMessagesFailed)
5152
? 'Loading sources'
52-
: history.isError
53+
: history.isError || (!persisted && history.data?.olderMessagesFailed)
5354
? 'Unable to load sources. Try reopening this conversation.'
5455
: 'No cited sources are available for this response.'}
5556
</p>

‎apps/sim/app/workspace/[workspaceId]/home/hooks/message-reconcile.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,9 +192,17 @@ export function markMessageStopped(message: PersistedMessage): PersistedMessage
192192
})
193193
}
194194

195+
/**
196+
* Changes whenever a server read of the chat changes what hydration acts on. Older messages
197+
* loaded in behind the window are left out: they are settled turns, and re-running hydration
198+
* for each page would restart reconnects and resource restores the window already handled.
199+
*/
195200
export function buildChatHistoryHydrationKey(chatHistory: MothershipChatHistory): string {
196201
const resourceKey = JSON.stringify(chatHistory.resources)
197-
const messageKey = chatHistory.messages.map((message) => message.id).join('|')
202+
const messageKey = chatHistory.messages
203+
.slice(chatHistory.olderMessageCount ?? 0)
204+
.map((message) => message.id)
205+
.join('|')
198206
const streamSnapshot = chatHistory.streamSnapshot
199207
const snapshotKey = streamSnapshot
200208
? [

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ import {
117117
finalizeResidualToolCalls,
118118
} from '@/app/workspace/[workspaceId]/home/hooks/stream'
119119
import { useNativeActiveTabIds } from '@/app/workspace/[workspaceId]/home/hooks/use-desktop-tab-resources'
120+
import { useMothershipChatOlderMessages } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-chat-older-messages'
120121
import { resolveEffectiveResourceId } from '@/app/workspace/[workspaceId]/home/resource-view-policy'
121122
import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
122123
import {
@@ -1381,6 +1382,7 @@ export function useChat(
13811382
isPending: isChatHistoryPending,
13821383
error: chatHistoryError,
13831384
} = useMothershipChatHistory(resolvedChatId)
1385+
useMothershipChatOlderMessages(resolvedChatId, chatHistory)
13841386
const requestModeRef = useRef<ChatRequestMode>(
13851387
options?.requestMode ?? (organizationId ? 'assistant' : 'agent')
13861388
)
@@ -2545,7 +2547,9 @@ export function useChat(
25452547
signal,
25462548
createTimeoutSignal(CHAT_HISTORY_RECOVERY_TIMEOUT_MS)
25472549
)
2548-
const history = await fetchMothershipChatHistory(chatId, fetchSignal)
2550+
const history = await fetchMothershipChatHistory(chatId, fetchSignal, () =>
2551+
queryClient.getQueryData<MothershipChatHistory>(mothershipChatKeys.detail(chatId))
2552+
)
25492553
if (signal?.aborted || fetchSignal?.aborted) return { loaded: false, streamId: null }
25502554
queryClient.setQueryData(mothershipChatKeys.detail(chatId), history)
25512555
return { loaded: true, streamId: history.activeStreamId ?? null }
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { useEffect } from 'react'
2+
import { createLogger } from '@sim/logger'
3+
import { toError } from '@sim/utils/errors'
4+
import { isCancelledError, useQueryClient } from '@tanstack/react-query'
5+
import {
6+
loadOlderMothershipChatMessages,
7+
type MothershipChatHistory,
8+
mothershipChatKeys,
9+
} from '@/hooks/queries/mothership-chats'
10+
11+
const logger = createLogger('MothershipChatOlderMessages')
12+
13+
/**
14+
* Loads a chat's older messages behind its newest window, a page at a time, so the whole
15+
* transcript is in place before the user scrolls back to it. Each page moves the cached
16+
* `olderCursor`, which loads the next, until the chat's first message arrives.
17+
*
18+
* Writing pages into the detail entry, rather than a separate infinite query, keeps every
19+
* reader of `detail.messages` (send, find, sources, fork) on one transcript.
20+
*/
21+
export function useMothershipChatOlderMessages(
22+
chatId: string | undefined,
23+
history: Pick<MothershipChatHistory, 'olderCursor' | 'olderMessagesFailed'> | undefined
24+
) {
25+
const queryClient = useQueryClient()
26+
const olderCursor = history?.olderMessagesFailed ? null : history?.olderCursor
27+
useEffect(() => {
28+
if (!chatId || !olderCursor) return
29+
const queryKey = mothershipChatKeys.olderMessages(chatId, olderCursor)
30+
loadOlderMothershipChatMessages(queryClient, chatId, olderCursor).catch((error) => {
31+
if (isCancelledError(error)) return
32+
logger.warn('Failed to load older chat messages', {
33+
chatId,
34+
error: toError(error).message,
35+
})
36+
// The page failed for good: record it so readers waiting on older history (the Sources
37+
// panel) settle on an error instead of loading forever.
38+
queryClient.setQueryData<MothershipChatHistory>(
39+
mothershipChatKeys.detail(chatId),
40+
(current) =>
41+
current?.olderCursor === olderCursor ? { ...current, olderMessagesFailed: true } : current
42+
)
43+
})
44+
return () => {
45+
void queryClient.cancelQueries({ queryKey, exact: true })
46+
}
47+
}, [queryClient, chatId, olderCursor])
48+
}

0 commit comments

Comments
 (0)