Files
supabase/apps/studio/data/content/notebooks/notebook-cache.ts
Charis de3a8799d6 fix(studio): invalidate notebook caches after assistant create/update (#49415)
## Summary
- The assistant's `create_notebook`/`update_notebook` tools run entirely
server-side, so an open notebook tab's React Query cache and Valtio
store never learn a write happened — the tab keeps showing stale content
until a manual reload.
- Adds `collectNotebookCacheEffects`/`applyNotebookCacheEffects`
(`apps/studio/lib/ai/notebook-cache-invalidation.ts`), which scan
finished assistant messages for completed
`create_notebook`/`update_notebook` tool calls and evict the affected
notebook via `evictNotebookFromCaches`
(`apps/studio/data/content/notebooks/notebook-cache.ts`), plus
invalidate the nav list.
- Wired into `createChatInstance`'s `onFinish` in
`state/ai-assistant-state.tsx`, with per-chat dedupe so replayed history
isn't reprocessed.
- Removes the cache entry outright rather than invalidating it, since a
remounting `useNotebookQuery` would otherwise read the stale cached
value synchronously before its refetch lands.
- Explicitly skips eviction when the open tab has unsaved local edits,
so an assistant write can't silently discard them.

Related: [FE-4235](https://linear.app/supabase/issue/FE-4235)

**Out of scope:** this only protects the client-side cache/store from
being clobbered after the fact. Preventing the assistant's
`update_notebook` tool call itself from overwriting a user's unsaved
edits (a data-layer conflict, not a cache-freshness one) is tracked
separately in [FE-4255](https://linear.app/supabase/issue/FE-4255).

## Test plan
- [x] `pnpm test:studio -- notebook-cache notebook-cache-invalidation
ai-assistant-state.notebook-cache-invalidation
ExplorerNotebookTab.assistant-cache-invalidation
ExplorerNotebookTabCoordinator` — all passing
- [x] Reproduction-first component test
(`ExplorerNotebookTab.assistant-cache-invalidation.test.tsx`) — verified
it fails without the fix (stale content persists) and passes with it
- [x] Regression test for the dirty-notebook guard (an edited, unsaved
notebook is left untouched by an assistant write)
- [x] `pnpm typecheck --filter=studio` / `pnpm lint --filter=studio`
clean


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

* **Bug Fixes**
* Notebook changes made through the AI assistant now appear correctly in
open notebook tabs and after reopening them.
* Saved notebook caches are refreshed after completed create or update
actions, preventing stale content from being displayed.
  * Unsaved notebook changes are preserved during cache cleanup.
  * Closing a notebook tab now consistently removes its cached content.

* **Tests**
* Added coverage for assistant-driven updates, remounts, duplicate
actions, project context changes, and cache behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-25 14:15:27 +08:00

56 lines
2.0 KiB
TypeScript

import type { QueryClient } from '@tanstack/react-query'
import type { Snapshot } from 'valtio'
import { contentKeys } from '@/data/content/keys'
import { notebooksState } from '@/state/notebooks/notebooks-state'
import type { StateNotebook } from '@/state/notebooks/types'
import { hasUnsavedChanges } from '@/state/sql-editor/sql-editor-lifecycle'
/**
* Whether a notebook has edits worth discarding on close: anything not yet
* saved, except a never-persisted notebook that's still empty (nothing to
* lose by closing it).
*/
export function hasDiscardableChanges(
stateNotebook: StateNotebook | Snapshot<StateNotebook> | undefined
): boolean {
if (!hasUnsavedChanges(stateNotebook?.status)) return false
const isEmptyNewNotebook =
stateNotebook?.status === 'new' && (stateNotebook.notebook.content?.cells.length ?? 0) === 0
return !isEmptyNewNotebook
}
/**
* Evicts a notebook from the React Query cache and the Valtio store together.
* Applies to a persisted notebook (always safe to refetch) and to a dirty
* unsaved notebook (safe once the caller has confirmed discarding it).
*
* Removes the query entry outright rather than invalidating it: a mounted
* `useNotebookQuery` observer would otherwise read the stale cached value
* synchronously, before its refetch lands, and `notebooksState.setNotebook`'s
* merge guard would treat that stale merge as already-loaded and drop the
* real update.
*
* @returns A boolean indicating whether the notebook was successfully evicted
* from the cache.
*/
export async function evictNotebookFromCaches({
queryClient,
projectRef,
id,
}: {
queryClient: QueryClient
projectRef: string
id: string
}): Promise<boolean> {
const stateNotebook = notebooksState.notebooks[id]
const canEvict = stateNotebook?.status === 'saved' || hasDiscardableChanges(stateNotebook)
if (!canEvict) return false
notebooksState.removeNotebook({ id })
queryClient.removeQueries({ queryKey: contentKeys.resource(projectRef, id) })
return true
}