Files
supabase/apps/studio/lib/ai/notebook-cache-invalidation.ts
Charis 632e099487 feat(studio): track assistant notebook divergence (#49539)
## Summary
- record server divergence when an assistant changes a notebook with
local unsaved edits
- clear the session-only marker after a successful save or notebook
removal
- cover update, delete, saved eviction, and lifecycle behavior

## Verification
- pnpm --dir apps/studio exec vitest run
state/notebooks/notebooks-state.test.ts
lib/ai/notebook-cache-invalidation.test.ts
- pnpm --dir apps/studio exec eslint state/notebooks/notebooks-state.ts
state/notebooks/notebooks-state.test.ts
lib/ai/notebook-cache-invalidation.ts
lib/ai/notebook-cache-invalidation.test.ts
- pnpm --dir apps/studio typecheck

Stacked on the approval-warning PR for FE-4255.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved handling of server changes to notebooks with unsaved local
edits.
* Server updates and deletions are now tracked as divergences instead of
being silently skipped.
* Divergence indicators are cleared when changes are saved or notebooks
are removed.
  * Unrelated notebook changes no longer create false conflicts.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-25 11:42:46 -04:00

82 lines
2.4 KiB
TypeScript

import type { QueryClient } from '@tanstack/react-query'
import type { ToolUIPart, UIMessage } from 'ai'
import { notebookToolOutputSchema } from '@/components/ui/AIAssistantPanel/Message.utils'
import { contentKeys } from '@/data/content/keys'
import { evictNotebookFromCaches } from '@/data/content/notebooks/notebook-cache'
import { notebooksState } from '@/state/notebooks/notebooks-state'
export type NotebookCacheEffect =
| { _tag: 'upserted'; toolCallId: string; id: string }
| { _tag: 'deleted'; toolCallId: string; id: string }
const NOTEBOOK_MUTATION_TOOL_TYPES = new Set([
'tool-create_notebook',
'tool-update_notebook',
'tool-delete_notebook',
])
function isNotebookMutationPart(part: UIMessage['parts'][number]): part is ToolUIPart {
return NOTEBOOK_MUTATION_TOOL_TYPES.has(part.type)
}
export function collectNotebookCacheEffects(
messages: Array<UIMessage>,
processed: ReadonlySet<string>
): Array<NotebookCacheEffect> {
const effects: Array<NotebookCacheEffect> = []
for (const message of messages) {
if (message.role !== 'assistant') continue
for (const part of message.parts ?? []) {
if (!isNotebookMutationPart(part)) continue
if (part.state !== 'output-available') continue
if (processed.has(part.toolCallId)) continue
const result = notebookToolOutputSchema.safeParse(part.output)
if (!result.success) continue
effects.push({
_tag: part.type === 'tool-delete_notebook' ? 'deleted' : 'upserted',
toolCallId: part.toolCallId,
id: result.data.id,
})
}
}
return effects
}
export async function applyNotebookCacheEffects({
queryClient,
projectRef,
effects,
}: {
queryClient: QueryClient
projectRef: string
effects: Array<NotebookCacheEffect>
}): Promise<void> {
if (effects.length === 0) return
await Promise.all([
queryClient.invalidateQueries({ queryKey: contentKeys.allContentLists(projectRef) }),
queryClient.invalidateQueries({ queryKey: contentKeys.infiniteList(projectRef) }),
])
await Promise.all(
effects.map((effect) => {
const stateNotebook = notebooksState.notebooks[effect.id]
if (stateNotebook && stateNotebook.status !== 'saved') {
notebooksState.markServerDivergence({
id: effect.id,
type: effect._tag === 'deleted' ? 'deleted' : 'updated',
})
return
}
return evictNotebookFromCaches({ queryClient, projectRef, id: effect.id })
})
)
}