Files
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

272 lines
9.5 KiB
TypeScript

import { type UniqueIdentifier } from '@dnd-kit/core'
import { arrayMove } from '@dnd-kit/sortable'
import { useParams } from 'common'
import { useMemo } from 'react'
import { proxy, snapshot, useSnapshot, type Snapshot } from 'valtio'
import { proxyMap } from 'valtio/utils'
import type { Notebook, StateNotebook } from './types'
import { isQueryCell } from '@/data/content/notebooks/notebook-schema'
import type { SnippetStatus } from '@/data/content/snippet-status'
import type { Notebooks } from '@/types'
function statusOnEdit(status: SnippetStatus): SnippetStatus {
return status === 'saved' ? 'unsaved' : status
}
type NotebookCellLocalState = {
showQuery?: boolean
}
export const notebooksState = proxy({
notebooks: {} as Record<string, StateNotebook>,
needsSaving: proxyMap<string, boolean>([]),
/** Session-only UI state keyed by cell ID; never persisted with notebook content. */
cellLocalState: proxyMap<string, NotebookCellLocalState>([]),
/** Session-only conflicts where an assistant changed the server while local edits remain. */
serverDivergedWhileDirty: proxyMap<string, 'updated' | 'deleted'>([]),
/**
* Load notebook into the Valtio store. No-ops if already present.
*/
addNotebook: ({ projectRef, notebook }: { projectRef: string; notebook: Notebook }) => {
if (notebooksState.notebooks[notebook.id]) return
notebooksState.notebooks[notebook.id] = { projectRef, notebook, status: 'new' }
},
/**
* Load notebook content into the store. Notebooks fetched from the list
* endpoint don't have `content` loaded (to keep that response small), so
* content is fetched separately and merged in here on demand.
*
* Unlike `addNotebook` (for locally-created notebooks, status 'new'), a
* notebook reaching this function was already persisted, so it's inserted
* with status 'saved'.
*/
setNotebook: ({ projectRef, notebook }: { projectRef: string; notebook: Notebook }) => {
const stateNotebook = notebooksState.notebooks[notebook.id]
if (stateNotebook) {
if (!stateNotebook.notebook.content) {
stateNotebook.notebook.content = notebook.content
}
} else {
notebooksState.notebooks[notebook.id] = { projectRef, notebook, status: 'saved' }
}
},
/**
* Marks a notebook as persisted after its first successful save. Every
* later save cycle is covered by `updateCells`'s `statusOnEdit` ('saved' ->
* 'unsaved' -> ...), but the one-time 'new' -> 'saved' transition has no
* other trigger — the resource query that would otherwise pick it up is
* disabled while the notebook is still 'new'.
*/
markSaved: ({ id }: { id: string }) => {
const stateNotebook = notebooksState.notebooks[id]
if (stateNotebook) stateNotebook.status = 'saved'
notebooksState.clearServerDivergence({ id })
},
markServerDivergence: ({ id, type }: { id: string; type: 'updated' | 'deleted' }) =>
notebooksState.serverDivergedWhileDirty.set(id, type),
clearServerDivergence: ({ id }: { id: string }) =>
notebooksState.serverDivergedWhileDirty.delete(id),
/**
* Rename follows its own async save directly at the call site rather than going
* through needsSaving/the debounced scheduler.
*/
renameNotebook: ({ id, name }: { id: string; name: string }) => {
const stateNotebook = notebooksState.notebooks[id]
if (stateNotebook) {
stateNotebook.notebook.name = name
}
},
/**
* Remove notebook from the store, and optionally remove it from the sync
* saving queue. Also clears its session-only query-cell UI state.
*/
removeNotebook: ({ id, skipSave = false }: { id: string; skipSave?: boolean }) => {
const { [id]: notebook, ...otherNotebooks } = notebooksState.notebooks
notebook?.notebook.content?.cells.forEach((cell) =>
notebooksState.cellLocalState.delete(cell._id)
)
notebooksState.notebooks = otherNotebooks
if (!skipSave) notebooksState.needsSaving.delete(id)
notebooksState.clearServerDivergence({ id })
},
/**
* Replace a notebook's full cell array and queue it for sync saving. The
* single entry point for every cell-level change — adding, removing,
* reordering, or editing a cell's content all compute the next `cells` array
* at the call site and pass it here, since the notebook is saved as one JSON
* document rather than per-cell.
*/
updateCells: ({
id,
cells,
skipSave,
}: {
id: string
cells: Snapshot<Notebooks.Cell>[]
skipSave?: boolean
}) => {
const stateNotebook = notebooksState.notebooks[id]
if (!stateNotebook?.notebook.content) return
stateNotebook.notebook.content.cells = cells as Notebooks.Cell[]
stateNotebook.status = statusOnEdit(stateNotebook.status)
if (!skipSave) notebooksState.needsSaving.set(id, false)
},
/**
* Insert a cell right after `cellId` in a notebook's cell array — or at the
* end, if `cellId` is omitted or isn't found (e.g. an empty notebook has no
* cell to insert after). The caller builds the cell to insert (e.g. via
* `createMarkdownCellSkeleton`/`createQueryCellSkeleton`) since deciding
* what kind of cell to create is a UI concern, not a state one.
*/
insertCellAfter: ({
id,
cellId,
cell,
}: {
id: string
cellId?: string
cell: Notebooks.Cell
}) => {
const stateNotebook = notebooksState.notebooks[id]
if (!stateNotebook?.notebook.content) return
const cells = stateNotebook.notebook.content.cells
const insertAt = cellId ? cells.findIndex((c) => c._id === cellId) : -1
const nextCells = [...cells]
nextCells.splice(insertAt === -1 ? cells.length : insertAt + 1, 0, cell)
if (isQueryCell(cell)) notebooksState.cellLocalState.set(cell._id, { showQuery: true })
notebooksState.updateCells({ id, cells: nextCells })
},
/**
* Update a single cell in a notebook's cell array via an updater callback.
* The caller decides how the cell's content should change (e.g. field
* defaults, tag conversion) since that's a UI concern, not a state one —
* this only finds the cell by id and re-saves the array.
*/
updateCell: ({
id,
cellId,
updater,
}: {
id: string
cellId: string
updater: (cell: Notebooks.Cell) => Notebooks.Cell
}) => {
const stateNotebook = notebooksState.notebooks[id]
if (!stateNotebook?.notebook.content) return
const nextCells = stateNotebook.notebook.content.cells.map((cell) =>
cell._id === cellId ? updater(cell) : cell
)
notebooksState.updateCells({ id, cells: nextCells })
},
setQueryVisibility: ({ cellId, showQuery }: { cellId: string; showQuery: boolean }) =>
notebooksState.cellLocalState.set(cellId, {
...notebooksState.cellLocalState.get(cellId),
showQuery,
}),
/**
* Remove a single cell from a notebook's cell array.
*/
removeCell: ({ id, cellId }: { id: string; cellId: string }) => {
const stateNotebook = notebooksState.notebooks[id]
if (!stateNotebook?.notebook.content) return
const nextCells = stateNotebook.notebook.content.cells.filter((c) => c._id !== cellId)
notebooksState.cellLocalState.delete(cellId)
notebooksState.updateCells({ id, cells: nextCells })
},
/**
* Shift a cell one position up or down in a notebook's cell array. No-ops if
* the cell is already at that boundary (or isn't found).
*/
moveCell: ({
id,
cellId,
direction,
}: {
id: string
cellId: string
direction: 'up' | 'down'
}) => {
const stateNotebook = notebooksState.notebooks[id]
if (!stateNotebook?.notebook.content) return
const cells = stateNotebook.notebook.content.cells
const currentIndex = cells.findIndex((c) => c._id === cellId)
if (currentIndex === -1) return
const nextIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1
if (nextIndex < 0 || nextIndex >= cells.length) return
notebooksState.updateCells({ id, cells: arrayMove([...cells], currentIndex, nextIndex) })
},
/**
* Reorder a notebook's cell array by moving the cell at `activeCellId` to
* where `overCellId` currently sits (dnd-kit's drag-end positions).
*/
reorderCells: ({
id,
activeCellId,
overCellId,
}: {
id: string
activeCellId: UniqueIdentifier
overCellId: UniqueIdentifier
}) => {
const stateNotebook = notebooksState.notebooks[id]
if (!stateNotebook?.notebook.content) return
const cells = stateNotebook.notebook.content.cells
const oldIndex = cells.findIndex((c) => c._id === activeCellId)
const newIndex = cells.findIndex((c) => c._id === overCellId)
if (oldIndex === -1 || newIndex === -1) return
notebooksState.updateCells({ id, cells: arrayMove([...cells], oldIndex, newIndex) })
},
addNeedsSaving: (id: string) => notebooksState.needsSaving.set(id, true),
})
export const getNotebooksStateSnapshot = () => snapshot(notebooksState)
export const useNotebooksStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) =>
useSnapshot(notebooksState, options)
export const useNotebooks = (projectRef: string) => {
const snapshot = useNotebooksStateSnapshot()
return useMemo(
() =>
Object.values(snapshot.notebooks)
.filter((x) => x.projectRef === projectRef)
.map((x) => x.notebook),
[projectRef, snapshot.notebooks]
)
}
export const useCurrentNotebook = () => {
const { id, ref } = useParams()
const snapshot = useNotebooksStateSnapshot()
const currentNotebook = id ? snapshot.notebooks[id] : undefined
if (!currentNotebook || currentNotebook.projectRef !== ref) return undefined
return currentNotebook
}