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

View File

@@ -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>
@@ -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>

View File

@@ -5,7 +5,7 @@ import { serializeMessagesForDB } from './serialization'
const logger = createLogger('CopilotMessagePersistence')
export async function persistMessages(params: {
interface PersistParams {
chatId: string
messages: CopilotMessage[]
sensitiveCredentialIds?: Set<string>
@@ -13,24 +13,29 @@ export async function persistMessages(params: {
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) {
@@ -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
}
}

View File

@@ -39,6 +39,7 @@ import {
buildToolCallsById,
normalizeMessagesForUI,
persistMessages,
persistMessagesBeacon,
saveMessageCheckpoint,
} from '@/lib/copilot/messages'
import type { CopilotTransportMode } from '@/lib/copilot/models'
@@ -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 {
@@ -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),