Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ const SmoothThinkingText = memo(
return (
<div
ref={textRef}
className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'
className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-8 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'
>
<CopilotMarkdownRenderer content={displayedContent} />
</div>
Expand Down Expand Up @@ -355,7 +355,7 @@ export function ThinkingBlock({
isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0'
)}
>
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'>
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-8 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'>
<CopilotMarkdownRenderer content={cleanContent} />
</div>
</div>
Expand Down
59 changes: 44 additions & 15 deletions apps/sim/lib/copilot/messages/persist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,37 @@ import { serializeMessagesForDB } from './serialization'

const logger = createLogger('CopilotMessagePersistence')

export async function persistMessages(params: {
interface PersistParams {
chatId: string
messages: CopilotMessage[]
sensitiveCredentialIds?: Set<string>
planArtifact?: string | null
mode?: string
model?: string
conversationId?: string
}): Promise<boolean> {
}

/** Builds the JSON body used by both fetch and sendBeacon persistence paths. */
function buildPersistBody(params: PersistParams): string {
const dbMessages = serializeMessagesForDB(
params.messages,
params.sensitiveCredentialIds ?? new Set<string>()
)
return JSON.stringify({
chatId: params.chatId,
messages: dbMessages,
...(params.planArtifact !== undefined ? { planArtifact: params.planArtifact } : {}),
...(params.mode || params.model ? { config: { mode: params.mode, model: params.model } } : {}),
...(params.conversationId ? { conversationId: params.conversationId } : {}),
})
}

export async function persistMessages(params: PersistParams): Promise<boolean> {
try {
const dbMessages = serializeMessagesForDB(
params.messages,
params.sensitiveCredentialIds ?? new Set<string>()
)
const response = await fetch(COPILOT_UPDATE_MESSAGES_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chatId: params.chatId,
messages: dbMessages,
...(params.planArtifact !== undefined ? { planArtifact: params.planArtifact } : {}),
...(params.mode || params.model
? { config: { mode: params.mode, model: params.model } }
: {}),
...(params.conversationId ? { conversationId: params.conversationId } : {}),
}),
body: buildPersistBody(params),
})
return response.ok
} catch (error) {
Expand All @@ -41,3 +46,27 @@ export async function persistMessages(params: {
return false
}
}

/**
* Persists messages using navigator.sendBeacon, which is reliable during page unload.
* Unlike fetch, sendBeacon is guaranteed to be queued even when the page is being torn down.
*/
export function persistMessagesBeacon(params: PersistParams): boolean {
try {
const body = buildPersistBody(params)
const blob = new Blob([body], { type: 'application/json' })
const sent = navigator.sendBeacon(COPILOT_UPDATE_MESSAGES_API_PATH, blob)
if (!sent) {
logger.warn('sendBeacon returned false — browser may have rejected the request', {
chatId: params.chatId,
})
}
return sent
} catch (error) {
logger.warn('Failed to persist messages via sendBeacon', {
chatId: params.chatId,
error: error instanceof Error ? error.message : String(error),
})
return false
}
}
36 changes: 33 additions & 3 deletions apps/sim/stores/panel/copilot/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
buildToolCallsById,
normalizeMessagesForUI,
persistMessages,
persistMessagesBeacon,
saveMessageCheckpoint,
} from '@/lib/copilot/messages'
import type { CopilotTransportMode } from '@/lib/copilot/models'
Expand Down Expand Up @@ -78,6 +79,28 @@ let _isPageUnloading = false
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
_isPageUnloading = true

// Emergency persistence: flush any pending streaming updates to the store and
// persist via sendBeacon (which is guaranteed to be queued during page teardown).
// Without this, thinking blocks and in-progress content are lost on refresh.
try {
const state = useCopilotStore.getState()
if (state.isSendingMessage && state.currentChat) {
// Flush batched streaming updates into the store messages
flushStreamingUpdates(useCopilotStore.setState.bind(useCopilotStore))
const flushedState = useCopilotStore.getState()
persistMessagesBeacon({
chatId: flushedState.currentChat!.id,
messages: flushedState.messages,
sensitiveCredentialIds: flushedState.sensitiveCredentialIds,
planArtifact: flushedState.streamingPlanContent || null,
mode: flushedState.mode,
model: flushedState.selectedModel,
})
}
} catch {
// Best-effort — don't let errors prevent page unload
}
})
}
function isPageUnloading(): boolean {
Expand Down Expand Up @@ -1461,19 +1484,26 @@ export const useCopilotStore = create<CopilotStore>()(
// Immediately put all in-progress tools into aborted state
abortAllInProgressTools(set, get)

// Persist whatever contentBlocks/text we have to keep ordering for reloads
// Persist whatever contentBlocks/text we have to keep ordering for reloads.
// During page unload, use sendBeacon which is guaranteed to be queued even
// as the page tears down. Regular async fetch won't complete in time.
const { currentChat, streamingPlanContent, mode, selectedModel } = get()
if (currentChat) {
try {
const currentMessages = get().messages
void persistMessages({
const persistParams = {
chatId: currentChat.id,
messages: currentMessages,
sensitiveCredentialIds: get().sensitiveCredentialIds,
planArtifact: streamingPlanContent || null,
mode,
model: selectedModel,
})
}
if (isPageUnloading()) {
persistMessagesBeacon(persistParams)
} else {
void persistMessages(persistParams)
}
} catch (error) {
logger.warn('[Copilot] Failed to queue abort snapshot persistence', {
error: error instanceof Error ? error.message : String(error),
Expand Down