Compare commits

..

1 Commits

Author SHA1 Message Date
waleed
031866e07c fix(copilot): persist thinking blocks on page refresh via sendBeacon
- Use navigator.sendBeacon in beforeunload handler to reliably persist
  in-progress messages (including thinking blocks) during page teardown
- Flush batched streaming updates before beacon persistence
- Fall back to sendBeacon in abortMessage when page is unloading
- Fix double-digit ordered list clipping in thinking block (pl-6 → pl-8)
2026-02-10 22:46:39 -08:00
4 changed files with 99 additions and 44 deletions

View File

@@ -38,7 +38,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const isInternalCall = auth.authType === 'internal_jwt'
const userId = auth.userId || null const userId = auth.userId || null
let workflowData = await getWorkflowById(workflowId) let workflowData = await getWorkflowById(workflowId)
@@ -48,14 +47,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
} }
if (isInternalCall && !userId) { // Check if user has access to this workflow
// Internal system calls (e.g. workflow-in-workflow executor) may not carry a userId. if (!userId) {
// These are already authenticated via internal JWT; allow read access.
logger.info(`[${requestId}] Internal API call for workflow ${workflowId}`)
} else if (!userId) {
logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`) logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} else { }
const authorization = await authorizeWorkflowByWorkspacePermission({ const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId, workflowId,
userId, userId,
@@ -74,7 +71,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
{ status: authorization.status } { status: authorization.status }
) )
} }
}
logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from normalized tables`) logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from normalized tables`)
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)

View File

@@ -108,7 +108,7 @@ const SmoothThinkingText = memo(
return ( return (
<div <div
ref={textRef} 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} /> <CopilotMarkdownRenderer content={displayedContent} />
</div> </div>
@@ -355,7 +355,7 @@ export function ThinkingBlock({
isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0' 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} /> <CopilotMarkdownRenderer content={cleanContent} />
</div> </div>
</div> </div>

View File

@@ -5,7 +5,7 @@ import { serializeMessagesForDB } from './serialization'
const logger = createLogger('CopilotMessagePersistence') const logger = createLogger('CopilotMessagePersistence')
export async function persistMessages(params: { interface PersistParams {
chatId: string chatId: string
messages: CopilotMessage[] messages: CopilotMessage[]
sensitiveCredentialIds?: Set<string> sensitiveCredentialIds?: Set<string>
@@ -13,24 +13,29 @@ export async function persistMessages(params: {
mode?: string mode?: string
model?: string model?: string
conversationId?: string conversationId?: string
}): Promise<boolean> { }
try {
/** Builds the JSON body used by both fetch and sendBeacon persistence paths. */
function buildPersistBody(params: PersistParams): string {
const dbMessages = serializeMessagesForDB( const dbMessages = serializeMessagesForDB(
params.messages, params.messages,
params.sensitiveCredentialIds ?? new Set<string>() params.sensitiveCredentialIds ?? new Set<string>()
) )
const response = await fetch(COPILOT_UPDATE_MESSAGES_API_PATH, { return JSON.stringify({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chatId: params.chatId, chatId: params.chatId,
messages: dbMessages, messages: dbMessages,
...(params.planArtifact !== undefined ? { planArtifact: params.planArtifact } : {}), ...(params.planArtifact !== undefined ? { planArtifact: params.planArtifact } : {}),
...(params.mode || params.model ...(params.mode || params.model ? { config: { mode: params.mode, model: params.model } } : {}),
? { config: { mode: params.mode, model: params.model } }
: {}),
...(params.conversationId ? { conversationId: params.conversationId } : {}), ...(params.conversationId ? { conversationId: params.conversationId } : {}),
}), })
}
export async function persistMessages(params: PersistParams): Promise<boolean> {
try {
const response = await fetch(COPILOT_UPDATE_MESSAGES_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: buildPersistBody(params),
}) })
return response.ok return response.ok
} catch (error) { } catch (error) {
@@ -41,3 +46,27 @@ export async function persistMessages(params: {
return false 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
}
}

View File

@@ -39,6 +39,7 @@ import {
buildToolCallsById, buildToolCallsById,
normalizeMessagesForUI, normalizeMessagesForUI,
persistMessages, persistMessages,
persistMessagesBeacon,
saveMessageCheckpoint, saveMessageCheckpoint,
} from '@/lib/copilot/messages' } from '@/lib/copilot/messages'
import type { CopilotTransportMode } from '@/lib/copilot/models' import type { CopilotTransportMode } from '@/lib/copilot/models'
@@ -78,6 +79,28 @@ let _isPageUnloading = false
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
_isPageUnloading = true _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 { function isPageUnloading(): boolean {
@@ -1461,19 +1484,26 @@ export const useCopilotStore = create<CopilotStore>()(
// Immediately put all in-progress tools into aborted state // Immediately put all in-progress tools into aborted state
abortAllInProgressTools(set, get) 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() const { currentChat, streamingPlanContent, mode, selectedModel } = get()
if (currentChat) { if (currentChat) {
try { try {
const currentMessages = get().messages const currentMessages = get().messages
void persistMessages({ const persistParams = {
chatId: currentChat.id, chatId: currentChat.id,
messages: currentMessages, messages: currentMessages,
sensitiveCredentialIds: get().sensitiveCredentialIds, sensitiveCredentialIds: get().sensitiveCredentialIds,
planArtifact: streamingPlanContent || null, planArtifact: streamingPlanContent || null,
mode, mode,
model: selectedModel, model: selectedModel,
}) }
if (isPageUnloading()) {
persistMessagesBeacon(persistParams)
} else {
void persistMessages(persistParams)
}
} catch (error) { } catch (error) {
logger.warn('[Copilot] Failed to queue abort snapshot persistence', { logger.warn('[Copilot] Failed to queue abort snapshot persistence', {
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),