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
24 changes: 24 additions & 0 deletions apps/sim/app/api/mothership/chats/[chatId]/messages/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { listMothershipChatOlderMessagesContract } from '@/lib/api/contracts/mothership-chats'
import {
defineInternalJsonRoute,
internalJsonPresenters,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { readChatOlderMessages } from '@/lib/mothership/chat/application/read-older-messages'

/** Older messages of the owner's chat, which the chat page loads behind its newest window. */
export const GET = defineInternalJsonRoute({
contract: listMothershipChatOlderMessagesContract,
operation: readChatOlderMessages.operation,
auth: internalSessionAuth,
rateLimit: internalRateLimits.none({
reason:
'Pages a transcript the chat detail read used to return whole, which has no separate rate bucket.',
}),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params, query }) => ({ chatId: params.chatId, before: query.before }),
useCase: readChatOlderMessages,
present: internalJsonPresenters.withSuccess,
})
20 changes: 18 additions & 2 deletions apps/sim/app/api/mothership/chats/[chatId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from '@/lib/mothership/chat/live-turn-snapshot'
import { compactToolCallParams, normalizeMessage } from '@/lib/mothership/chat/persisted-message'
import { reconcileChatStreamMarkers } from '@/lib/mothership/chat/stream-liveness'
import { decodeTranscriptCursor } from '@/lib/mothership/chat/transcript-cursor'
import { publishChatStatusChanged } from '@/lib/mothership/chat-status'
import {
authenticateCopilotRequestSessionOnly,
Expand All @@ -43,9 +44,22 @@ export const GET = withRouteHandler(
const paramsResult = await parseRequest(getMothershipChatContract, request, context)
if (!paramsResult.success) return paramsResult.response
const { chatId } = paramsResult.data.params
const { window, from } = paramsResult.data.query
const fromPosition = from ? decodeTranscriptCursor(from) : null
if (from && !fromPosition) {
return NextResponse.json({ success: false, error: 'Invalid cursor' }, { status: 400 })
}

const chat = await getAccessibleCopilotChatWithMessages(chatId, userId, { principal })
if (!chat || chat.type !== 'mothership') {
const chat = await getAccessibleCopilotChatWithMessages(chatId, userId, {
principal,
type: 'mothership',
window: fromPosition
? { kind: 'from', position: fromPosition }
: window === 'latest'
? { kind: 'latest' }
: undefined,
})
if (!chat) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}

Expand Down Expand Up @@ -95,6 +109,8 @@ export const GET = withRouteHandler(
activeStreamId: liveStreamId,
resources: Array.isArray(chat.resources) ? chat.resources : [],
effort: chat.effort,
olderCursor: chat.olderCursor,
windowStartCursor: chat.windowStartCursor,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
// Events stay out of the payload (the resume endpoint replays them),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,9 @@ const OVERSCAN = 6

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

/**
* Older messages load in above the newest window while the chat is open, pushing every row
* below them down. A viewport at the bottom is re-pinned to the last row, which also keeps a
* landing scroll aimed at it rather than at the index it had before the insert; any other
* viewport moves down by the inserted height and keeps showing what the user was reading.
*/
const firstRowKeyRef = useRef<string | undefined>(undefined)
const firstRowKey = rowKeyByIndex[0]
useLayoutEffect(() => {
const previousFirstRowKey = firstRowKeyRef.current
firstRowKeyRef.current = firstRowKey
if (!previousFirstRowKey || previousFirstRowKey === firstRowKey) return
const shiftedIndex = rowKeyByIndex.indexOf(previousFirstRowKey)
const shifted = shiftedIndex > 0 ? virtualizer.measurementsCache[shiftedIndex] : undefined
const first = virtualizer.measurementsCache[0]
const scrollElement = scrollElementRef.current
if (!shifted || !first || !scrollElement) return
const inserted = shifted.start - first.start
const { scrollTop, scrollHeight, clientHeight } = scrollElement
if (scrollHeight - inserted - scrollTop - clientHeight <= PIN_THRESHOLD) {
virtualizer.scrollToIndex(lastIndex, { align: 'end' })
} else {
virtualizer.scrollToOffset(scrollTop + inserted)
}
}, [firstRowKey, lastIndex, rowKeyByIndex, virtualizer])

const scrolledChatRef = useRef<string | undefined | typeof UNSCROLLED>(UNSCROLLED)
const userInputRef = useRef<UserInputHandle>(null)
const messageQueueRef = useRef(messageQueue)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,27 @@ import { getChatFindText } from '@/app/workspace/[workspaceId]/home/components/m
import { getUserMessageText } from '@/app/workspace/[workspaceId]/home/components/user-message-content/utils'
import type { ChatMessage } from '@/app/workspace/[workspaceId]/home/types'

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

/** A find match by the message it is in and its occurrence there. */
interface ChatFindMatchIdentity {
messageId: string
occurrence: number
}

interface ChatFindMatch extends ChatFindMatchIdentity {
messageIndex: number
}

interface UseChatFindProps {
chatId?: string
messages: ChatMessage[]
Expand Down Expand Up @@ -124,40 +140,68 @@ export function useChatFind({
const [scope, setScope] = useState(chatId)
const [isOpen, setIsOpen] = useState(false)
const [query, setQuery] = useState('')
const [index, setIndex] = useState(0)
/**
* The active match by message and occurrence rather than position, so older messages
* loading in ahead of it keep the same match active instead of shifting to another.
*/
const [activeMatch, setActiveMatch] = useState<ChatFindMatchIdentity | null>(null)
if (scope !== chatId) {
setScope(chatId)
// A pending chat adopting its id (undefined → id) is the same conversation.
if (scope !== undefined) {
setIsOpen(false)
setQuery('')
setIndex(0)
setActiveMatch(null)
}
}
const deferredQuery = useDeferredValue(query)
const term = isOpen ? deferredQuery.trim() : ''
const result = useMemo(() => {
const matches: { messageIndex: number; occurrence: number }[] = []
const newestFirst: ChatFindMatch[][] = []
let count = 0
let truncated = false
if (term) {
for (const [messageIndex, message] of messages.entries()) {
if (hiddenUserByIndex[messageIndex] || message.origin === 'task') continue
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex--) {
const message = messages[messageIndex]
if (!message || hiddenUserByIndex[messageIndex] || message.origin === 'task') continue
const text = getMessageSearchText(message, textCache)
let occurrence = 0
const occurrences: ChatFindMatch[] = []
let found = 0
forEachSearchOccurrence(text, term, () => {
if (matches.length < MATCH_LIMIT) matches.push({ messageIndex, occurrence: occurrence++ })
else truncated = true
if (count + found < MATCH_LIMIT) {
occurrences.push({ messageIndex, messageId: message.id, occurrence: found })
}
found++
})
Comment thread
waleedlatif1 marked this conversation as resolved.
if (truncated) break
newestFirst.push(occurrences)
count += occurrences.length
if (occurrences.length < found) {
truncated = true
break
}
}
}
return { matches, truncated }
return { matches: newestFirst.reverse().flat(), truncated }
}, [messages, hiddenUserByIndex, term, textCache])
const currentIndex = Math.min(index, Math.max(0, result.matches.length - 1))
const pinnedIndex = activeMatch
? result.matches.findIndex(
(match) =>
match.messageId === activeMatch.messageId && match.occurrence === activeMatch.occurrence
)
: -1
const currentIndex = Math.max(0, pinnedIndex)
const active = result.matches[currentIndex]
const messageIndex = active?.messageIndex
const occurrence = active?.occurrence
const isStale = query !== deferredQuery
if (!isStale && active && pinnedIndex < 0) {
setActiveMatch({ messageId: active.messageId, occurrence: active.occurrence })
Comment thread
waleedlatif1 marked this conversation as resolved.
}
/** Read by `step`, so stepping keeps one identity while a streaming reply rebuilds the matches. */
const matchesRef = useRef(result.matches)
useLayoutEffect(() => {
matchesRef.current = result.matches
}, [result.matches])

const open = useCallback(() => {
if (document.activeElement instanceof HTMLElement && !inputRef.current) {
Expand All @@ -176,7 +220,7 @@ export function useChatFind({
const close = useCallback(() => {
setIsOpen(false)
setQuery('')
setIndex(0)
setActiveMatch(null)
restoreFocus()
}, [restoreFocus])
useLayoutEffect(() => {
Expand All @@ -185,14 +229,17 @@ export function useChatFind({
}, [chatId, restoreFocus])
const onQueryChange = useCallback((value: string) => {
setQuery(value)
setIndex(0)
setActiveMatch(null)
}, [])
const step = useCallback(
(delta: number) => {
if (result.matches.length && !isStale)
setIndex((currentIndex + delta + result.matches.length) % result.matches.length)
const matches = matchesRef.current
const target = matches[(currentIndex + delta + matches.length) % matches.length]
if (target && !isStale) {
setActiveMatch({ messageId: target.messageId, occurrence: target.occurrence })
}
},
[currentIndex, result.matches.length, isStale]
[currentIndex, isStale]
)
const next = useCallback(() => step(1), [step])
const prev = useCallback(() => step(-1), [step])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ export function SourcesResourceContent({ resource, chatId }: SourcesResourceCont
sources.map((source) => <SourceCard key={source.url} source={source} />)
) : (
<p className='px-2 py-2 text-[var(--text-muted)] text-small'>
{history.isPending
{history.isPending ||
(!persisted && history.data?.olderCursor && !history.data.olderMessagesFailed)
? 'Loading sources'
: history.isError
: history.isError || (!persisted && history.data?.olderMessagesFailed)
? 'Unable to load sources. Try reopening this conversation.'
: 'No cited sources are available for this response.'}
</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,17 @@ export function markMessageStopped(message: PersistedMessage): PersistedMessage
})
}

/**
* Changes whenever a server read of the chat changes what hydration acts on. Older messages
* loaded in behind the window are left out: they are settled turns, and re-running hydration
* for each page would restart reconnects and resource restores the window already handled.
*/
export function buildChatHistoryHydrationKey(chatHistory: MothershipChatHistory): string {
const resourceKey = JSON.stringify(chatHistory.resources)
const messageKey = chatHistory.messages.map((message) => message.id).join('|')
const messageKey = chatHistory.messages
.slice(chatHistory.olderMessageCount ?? 0)
.map((message) => message.id)
.join('|')
const streamSnapshot = chatHistory.streamSnapshot
const snapshotKey = streamSnapshot
? [
Expand Down
6 changes: 5 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ import {
finalizeResidualToolCalls,
} from '@/app/workspace/[workspaceId]/home/hooks/stream'
import { useNativeActiveTabIds } from '@/app/workspace/[workspaceId]/home/hooks/use-desktop-tab-resources'
import { useMothershipChatOlderMessages } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-chat-older-messages'
import { resolveEffectiveResourceId } from '@/app/workspace/[workspaceId]/home/resource-view-policy'
import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
import {
Expand Down Expand Up @@ -1381,6 +1382,7 @@ export function useChat(
isPending: isChatHistoryPending,
error: chatHistoryError,
} = useMothershipChatHistory(resolvedChatId)
useMothershipChatOlderMessages(resolvedChatId, chatHistory)
const requestModeRef = useRef<ChatRequestMode>(
options?.requestMode ?? (organizationId ? 'assistant' : 'agent')
)
Expand Down Expand Up @@ -2545,7 +2547,9 @@ export function useChat(
signal,
createTimeoutSignal(CHAT_HISTORY_RECOVERY_TIMEOUT_MS)
)
const history = await fetchMothershipChatHistory(chatId, fetchSignal)
const history = await fetchMothershipChatHistory(chatId, fetchSignal, () =>
queryClient.getQueryData<MothershipChatHistory>(mothershipChatKeys.detail(chatId))
)
if (signal?.aborted || fetchSignal?.aborted) return { loaded: false, streamId: null }
queryClient.setQueryData(mothershipChatKeys.detail(chatId), history)
return { loaded: true, streamId: history.activeStreamId ?? null }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useEffect } from 'react'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { isCancelledError, useQueryClient } from '@tanstack/react-query'
import {
loadOlderMothershipChatMessages,
type MothershipChatHistory,
mothershipChatKeys,
} from '@/hooks/queries/mothership-chats'

const logger = createLogger('MothershipChatOlderMessages')

/**
* Loads a chat's older messages behind its newest window, a page at a time, so the whole
* transcript is in place before the user scrolls back to it. Each page moves the cached
* `olderCursor`, which loads the next, until the chat's first message arrives.
*
* Writing pages into the detail entry, rather than a separate infinite query, keeps every
* reader of `detail.messages` (send, find, sources, fork) on one transcript.
*/
export function useMothershipChatOlderMessages(
chatId: string | undefined,
history: Pick<MothershipChatHistory, 'olderCursor' | 'olderMessagesFailed'> | undefined
) {
const queryClient = useQueryClient()
const olderCursor = history?.olderMessagesFailed ? null : history?.olderCursor
useEffect(() => {
if (!chatId || !olderCursor) return
const queryKey = mothershipChatKeys.olderMessages(chatId, olderCursor)
loadOlderMothershipChatMessages(queryClient, chatId, olderCursor).catch((error) => {
if (isCancelledError(error)) return
logger.warn('Failed to load older chat messages', {
chatId,
error: toError(error).message,
})
// The page failed for good: record it so readers waiting on older history (the Sources
// panel) settle on an error instead of loading forever.
queryClient.setQueryData<MothershipChatHistory>(
mothershipChatKeys.detail(chatId),
(current) =>
current?.olderCursor === olderCursor ? { ...current, olderMessagesFailed: true } : current
)
})
return () => {
void queryClient.cancelQueries({ queryKey, exact: true })
}
}, [queryClient, chatId, olderCursor])
}
Loading
Loading