From 7d28bcc26b3f6efeb2c9c551914970df19a52560 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 21 Aug 2026 01:47:39 +0800 Subject: [PATCH 1/6] joshenlim/fe 4204 notebooks intellisense toggle (#49300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context Adds an intellisense toggle for explorer notebooks similar to SQL editor + have QueryEditor render definitions via `useAddDefinition` image So intellisense will be running in the QueryEditor if intellisense is enabled + source selected is database, otherwise will not run. image Also updated `useAddDefinition` logic to flush the table columns + functions cache in react query - For context in the past we had users run into browser performance issues when definitions were loaded if their database is really big - Hence why we originally added this intellisense toggle - But we previously also required users to refresh the browser after disabling intellisense, as a manual way to flush the cache - So this change should remove the need to refresh the browser after disabling intellisense ## Summary by CodeRabbit * **New Features** * Added PostgreSQL IntelliSense with definitions, formatting, and code completions in SQL editors. * Added a notebook option to enable or disable IntelliSense, with the preference saved between sessions. * Improved the notebook’s empty-state appearance. * **Bug Fixes** * Improved IntelliSense cleanup and prevented duplicate registrations when disabled. * Improved query execution state handling while background IntelliSense data loads. * **Tests** * Added coverage for shared registration, cleanup, preference persistence, and IntelliSense-related query handling. --- .../Explorer/ExplorerNotebookTab.tsx | 29 +++- .../interfaces/Explorer/QueryEditor/index.tsx | 22 ++- .../__tests__/ExplorerNotebookTab.test.tsx | 32 +++- .../Explorer/__tests__/QueryTab.test.tsx | 8 +- .../SQLEditor/useAddDefinitions.test.ts | 151 ++++++++++++++++++ .../interfaces/SQLEditor/useAddDefinitions.ts | 127 +++++++++++---- 6 files changed, 321 insertions(+), 48 deletions(-) create mode 100644 apps/studio/components/interfaces/SQLEditor/useAddDefinitions.test.ts diff --git a/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx index 696408859c214..d772a94a77e27 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx @@ -12,15 +12,18 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable' import { acceptUntrustedSql } from '@supabase/pg-meta' -import { useParams } from 'common' +import { LOCAL_STORAGE_KEYS, useParams } from 'common' import { + Check, FileText, + Keyboard, Loader2, MoreVertical, Notebook, NotebookText, Play, Save, + SearchX, SquareCode, Trash, } from 'lucide-react' @@ -33,6 +36,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' @@ -59,6 +63,7 @@ import { } from '@/data/content/notebooks/notebook-schema' import { useUpsertNotebookMutation } from '@/data/content/notebooks/notebook-upsert-mutation' import { acceptUntrustedLogsSql } from '@/data/logs/safe-analytics-sql' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' import { createTabId, useTabsStateSnapshot } from '@/state/tabs' @@ -68,6 +73,11 @@ export const ExplorerNotebookTab = () => { const tabs = useTabsStateSnapshot() const snap = useNotebooksStateSnapshot() + const [isIntellisenseEnabled, setIsIntellisenseEnabled] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE, + true + ) + const currentNotebook = useCurrentNotebook() const { name, content } = currentNotebook?.notebook ?? {} const { isNotFound } = useLoadNotebook({ id, projectRef: ref }) @@ -181,9 +191,9 @@ export const ExplorerNotebookTab = () => { if (isNotFound) { return ( -
+
} + icon={} title="Notebook not found" description="This notebook may have been deleted or does not exist." contentClassName="[&>h3]:text-sm [&>p]:text-xs" @@ -231,7 +241,18 @@ export const ExplorerNotebookTab = () => { } /> - + + setIsIntellisenseEnabled(!isIntellisenseEnabled)} + > +
+ + Intellisense enabled +
+ {isIntellisenseEnabled && } +
+ setIsDeleteModalOpen(true)}> Delete notebook diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx index 8ddfc51f5d8de..15fd6e59fc622 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx @@ -1,3 +1,4 @@ +import { useMonaco } from '@monaco-editor/react' import { acceptUntrustedSql, untrustedSql, type UntrustedSqlFragment } from '@supabase/pg-meta' import { useFlag } from 'common' import { CodeSquare, Eye, EyeOff, Play } from 'lucide-react' @@ -34,6 +35,7 @@ import { QueryResultRenderer } from './QueryResultRenderer' import { QuerySourceMenu } from './QuerySourceMenu' import { useQueryEditorAi } from './useQueryEditorAi' import { LegacyLogsRewriteBanner } from '@/components/interfaces/Settings/Logs/LegacyLogsRewriteBanner' +import { useAddDefinitions } from '@/components/interfaces/SQLEditor/useAddDefinitions' import { ResizableAIWidget } from '@/components/ui/AIEditor/ResizableAIWidget' import { getEditorSelectionParts, type EditorSelection } from '@/components/ui/AIEditor/utils' import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' @@ -173,6 +175,9 @@ export const QueryEditor = forwardRef(funct const dialect = query._tag === 'logs' ? 'clickhouse' : 'postgres' const { requestCompletion, isCompletionLoading } = useQueryEditorAi({ dialect }) + const monaco = useMonaco() + useAddDefinitions('', monaco, { enabled: dialect === 'postgres' }) + const { data: databases, isPending: isLoadingDatabases } = useReadReplicasQuery( { projectRef: project?.ref }, { @@ -339,15 +344,11 @@ export const QueryEditor = forwardRef(funct onClick={() => setShowQuery((value) => !value)} /> } tooltip="Run query" disabled={ - isLoadingProject || - isExecuting || - pendingProposal !== null || - isRunDisabled || - sql.trim().length === 0 + isBusy || pendingProposal !== null || isRunDisabled || sql.trim().length === 0 } onClick={() => handleRunQuery()} > @@ -381,8 +382,13 @@ export const QueryEditor = forwardRef(funct placeholder={!promptState?.isOpen ? generatePlaceholder(os) : ''} placeholderClassName="top-[13px]" className={variant === 'embedded' ? 'h-44' : undefined} - actions={{ runQuery: { enabled: !isRunDisabled, callback: handleRunQuery } }} - options={{ minimap: { enabled: false }, padding: { top: 8 } }} + actions={{ + runQuery: { enabled: !isRunDisabled, callback: handleRunQuery }, + }} + options={{ + minimap: { enabled: false }, + padding: { top: 8 }, + }} onInputChange={(value) => onSqlChange(value ?? '')} onMount={(editor, monaco) => { editor.onDidBlurEditorWidget(() => onSqlCommitRef.current?.(sqlRef.current)) diff --git a/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx b/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx index 71425a3fb5eb2..389e07dcaf85e 100644 --- a/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx +++ b/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx @@ -1,5 +1,6 @@ import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common' import { HttpResponse } from 'msw' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -87,16 +88,25 @@ beforeEach(() => { seedNotebook([databaseCell, logCell, markdownCell]) }) -afterEach(() => notebooksState.needsSaving.clear()) +afterEach(() => { + notebooksState.needsSaving.clear() + safeLocalStorage.removeItem(LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE) +}) describe('ExplorerNotebookTab', () => { it('runs every database and log cell, and skips markdown cells, on "Run notebook"', async () => { + // `useAddDefinitions` fires its own background keywords/functions/schemas/table-columns + // fetches against this same generic pg-meta query endpoint (differentiated by the `key` + // search param) as soon as a database cell's editor mounts — those are expected and + // unrelated to the actual cell run. + const INTELLISENSE_KEYS = ['keywords', 'database-functions', 'schemas', 'table-columns'] const dbRequests: Request[] = [] addAPIMock({ method: 'post', path: '/platform/pg-meta/:ref/query', response: ({ request }) => { - dbRequests.push(request) + const key = new URL(request.url).searchParams.get('key') + if (!key || !INTELLISENSE_KEYS.includes(key)) dbRequests.push(request) return HttpResponse.json([{ result: 1 }]) }, }) @@ -130,4 +140,22 @@ describe('ExplorerNotebookTab', () => { const runNotebookButton = await screen.findByRole('button', { name: 'Run notebook' }) expect(runNotebookButton).toBeDisabled() }) + + it('toggles and persists the Intellisense enabled preference from "More options"', async () => { + const readPersistedValue = () => { + const item = safeLocalStorage.getItem(LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE) + return item === null ? true : (JSON.parse(item) as boolean) + } + const initialValue = readPersistedValue() + + renderNotebookTab() + + const moreOptionsButton = await screen.findByRole('button', { name: 'More options' }) + await userEvent.click(moreOptionsButton) + + const intellisenseItem = await screen.findByRole('menuitem', { name: 'Intellisense enabled' }) + await userEvent.click(intellisenseItem) + + await waitFor(() => expect(readPersistedValue()).toBe(!initialValue)) + }) }) diff --git a/apps/studio/components/interfaces/Explorer/__tests__/QueryTab.test.tsx b/apps/studio/components/interfaces/Explorer/__tests__/QueryTab.test.tsx index 9df7654ff50eb..9157409706477 100644 --- a/apps/studio/components/interfaces/Explorer/__tests__/QueryTab.test.tsx +++ b/apps/studio/components/interfaces/Explorer/__tests__/QueryTab.test.tsx @@ -130,12 +130,18 @@ describe('QueryTab execution', () => { return HttpResponse.json([]) }, }) + // `useAddDefinitions` fires its own background keywords/functions/schemas/table-columns + // fetches against this same generic pg-meta query endpoint (differentiated by the `key` + // search param) as soon as the editor mounts — those are expected and unrelated to the + // actual run. Only a request with no recognized intellisense `key` counts as an execution. + const INTELLISENSE_KEYS = ['keywords', 'database-functions', 'schemas', 'table-columns'] const requests: Request[] = [] addAPIMock({ method: 'post', path: '/platform/pg-meta/:ref/query', response: ({ request }) => { - requests.push(request) + const key = new URL(request.url).searchParams.get('key') + if (!key || !INTELLISENSE_KEYS.includes(key)) requests.push(request) return HttpResponse.json([]) }, }) diff --git a/apps/studio/components/interfaces/SQLEditor/useAddDefinitions.test.ts b/apps/studio/components/interfaces/SQLEditor/useAddDefinitions.test.ts new file mode 100644 index 0000000000000..d398f6d010b12 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useAddDefinitions.test.ts @@ -0,0 +1,151 @@ +import { QueryClient } from '@tanstack/react-query' +import { waitFor } from '@testing-library/react' +import { HttpResponse } from 'msw' +import { describe, expect, it, vi } from 'vitest' + +import { acquireSharedRegistration, useAddDefinitions } from './useAddDefinitions' +import { customRenderHook } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' +import { setupSqlEditorMocks } from '@/tests/lib/sql-editor-test-utils' + +vi.mock('@/components/ui/CodeEditor/Providers/PgSQLCompletionProvider', () => ({ + default: vi.fn((_monaco: unknown, pgInfoRef: unknown) => ({ __pgInfoRef: pgInfoRef })), +})) +vi.mock('@/components/ui/CodeEditor/Providers/PgSQLSignatureHelpProvider', () => ({ + default: vi.fn((_monaco: unknown, pgInfoRef: unknown) => ({ __pgInfoRef: pgInfoRef })), +})) + +describe('acquireSharedRegistration', () => { + it('only registers once for multiple concurrent callers sharing a key', () => { + const register = vi.fn(() => ({ dispose: vi.fn() })) + + acquireSharedRegistration('test-key-1', register) + acquireSharedRegistration('test-key-1', register) + acquireSharedRegistration('test-key-1', register) + + expect(register).toHaveBeenCalledTimes(1) + }) + + it('registers independently per key', () => { + const register = vi.fn(() => ({ dispose: vi.fn() })) + + acquireSharedRegistration('test-key-2a', register) + acquireSharedRegistration('test-key-2b', register) + + expect(register).toHaveBeenCalledTimes(2) + }) + + it('does not dispose while other callers are still holding the registration', () => { + const dispose = vi.fn() + const register = vi.fn(() => ({ dispose })) + + const releaseA = acquireSharedRegistration('test-key-3', register) + acquireSharedRegistration('test-key-3', register) + + releaseA() + + expect(dispose).not.toHaveBeenCalled() + }) + + it('disposes once the last caller releases', () => { + const dispose = vi.fn() + const register = vi.fn(() => ({ dispose })) + + const releaseA = acquireSharedRegistration('test-key-4', register) + const releaseB = acquireSharedRegistration('test-key-4', register) + + releaseA() + releaseB() + + expect(dispose).toHaveBeenCalledTimes(1) + }) + + it('registers again after a full release cycle', () => { + const register = vi.fn(() => ({ dispose: vi.fn() })) + + const release = acquireSharedRegistration('test-key-5', register) + release() + acquireSharedRegistration('test-key-5', register) + + expect(register).toHaveBeenCalledTimes(2) + }) + + it('is safe to release more times than acquired', () => { + const dispose = vi.fn() + const register = vi.fn(() => ({ dispose })) + + const release = acquireSharedRegistration('test-key-6', register) + release() + + expect(() => release()).not.toThrow() + expect(dispose).toHaveBeenCalledTimes(1) + }) +}) + +describe('useAddDefinitions', () => { + const createMonaco = () => + ({ + languages: { + registerCompletionItemProvider: vi.fn(() => ({ dispose: vi.fn() })), + registerSignatureHelpProvider: vi.fn(() => ({ dispose: vi.fn() })), + registerDocumentFormattingEditProvider: vi.fn(() => ({ dispose: vi.fn() })), + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + + it('keeps the registered provider reading fresh data after the registering editor unmounts, as long as a sibling editor is still active', async () => { + const getPgsqlCompletionProvider = ( + await import('@/components/ui/CodeEditor/Providers/PgSQLCompletionProvider') + ).default as unknown as ReturnType + + let keywordWords = ['select'] + setupSqlEditorMocks() + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: ({ request }) => { + const key = new URL(request.url).searchParams.get('key') + if (key === 'keywords') return HttpResponse.json(keywordWords.map((word) => ({ word }))) + return HttpResponse.json([]) + }, + }) + + const monaco = createMonaco() + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const renderOptions = { queryClient } + + // Two sibling notebook cells' editors, both mounted at once, sharing the same cache. + const editor1 = customRenderHook( + () => useAddDefinitions('', monaco, { enabled: true }), + renderOptions + ) + const editor2 = customRenderHook( + () => useAddDefinitions('', monaco, { enabled: true }), + renderOptions + ) + + await waitFor(() => + expect(monaco.languages.registerCompletionItemProvider).toHaveBeenCalledTimes(1) + ) + + // The first (and only, thanks to ref-counted registration) call registered the provider — + // capture the `pgInfoRef` it reads from. + const pgInfoRef = getPgsqlCompletionProvider.mock.calls[0][1] as { + current: { keywords: string[] } + } + await waitFor(() => expect(pgInfoRef.current.keywords).toEqual(['select'])) + + // Editor 1 — the one whose render happened to trigger the registration — closes. Editor 2 + // is still open, so the provider must stay registered and stay current. + editor1.unmount() + expect(monaco.languages.registerCompletionItemProvider).toHaveBeenCalledTimes(1) + + keywordWords = ['select', 'insert'] + queryClient.invalidateQueries({ queryKey: ['projects', 'default', 'keywords'] }) + editor2.rerender() + + await waitFor(() => expect(pgInfoRef.current.keywords).toEqual(['select', 'insert'])) + + editor2.unmount() + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/useAddDefinitions.ts b/apps/studio/components/interfaces/SQLEditor/useAddDefinitions.ts index 490c9b3a86a9f..8e903636956ed 100644 --- a/apps/studio/components/interfaces/SQLEditor/useAddDefinitions.ts +++ b/apps/studio/components/interfaces/SQLEditor/useAddDefinitions.ts @@ -1,11 +1,13 @@ import { Monaco } from '@monaco-editor/react' +import { useQueryClient } from '@tanstack/react-query' import { LOCAL_STORAGE_KEYS } from 'common' import type { IDisposable } from 'monaco-editor' -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' import getPgsqlCompletionProvider from '@/components/ui/CodeEditor/Providers/PgSQLCompletionProvider' import getPgsqlSignatureHelpProvider from '@/components/ui/CodeEditor/Providers/PgSQLSignatureHelpProvider' import { useDatabaseFunctionsQuery } from '@/data/database-functions/database-functions-query' +import { databaseKeys } from '@/data/database/keys' import { useKeywordsQuery } from '@/data/database/keywords-query' import { useSchemasQuery } from '@/data/database/schemas-query' import { useTableColumnsQuery } from '@/data/database/table-columns-query' @@ -15,6 +17,41 @@ import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { formatSql } from '@/lib/formatSql' import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' +/** + * Prevents double registration of Monaco providers (e.g registerCompletionItemProvider) + */ +const sharedRegistrations = new Map() + +/** + * Shared across every `useAddDefinitions` instance so the registered pgsql completion/signature + * providers always read the freshest data from *any* currently active editor, not just whichever + * instance's `register()` factory happened to run first. Without this, the provider stays bound + * to a single per-instance ref: if that instance unmounts while a sibling editor is still active, + * `acquireSharedRegistration`'s ref-count doesn't reach zero (so nothing gets disposed), but the + * provider is left reading a ref that will never be written to again. + */ +const sharedPgInfoRef: { current: any } = { current: null } + +export function acquireSharedRegistration(key: string, register: () => IDisposable) { + const existing = sharedRegistrations.get(key) + if (existing) { + existing.count += 1 + } else { + sharedRegistrations.set(key, { count: 1, disposable: register() }) + } + + return () => { + const entry = sharedRegistrations.get(key) + if (!entry) return + + entry.count -= 1 + if (entry.count <= 0) { + entry.disposable?.dispose() + sharedRegistrations.delete(key) + } + } +} + export const useAddDefinitions = ( id: string, monaco: Monaco | null, @@ -22,6 +59,7 @@ export const useAddDefinitions = ( ) => { const { data: project } = useSelectedProjectQuery() const snapV2 = useSqlEditorV2StateSnapshot() + const queryClient = useQueryClient() const [intellisenseEnabled] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE, @@ -57,8 +95,6 @@ export const useAddDefinitions = ( { enabled: enabled && intellisenseEnabled } ) - const pgInfoRef = useRef(null) - const filteredSchemas = useSchemasFilteredForHighAvailability(schemas) const isPgInfoReady = @@ -69,20 +105,47 @@ export const useAddDefinitions = ( isKeywordsSuccess && isFunctionsSuccess - if (isPgInfoReady) { - if (pgInfoRef.current === null) { - pgInfoRef.current = {} + // Keeps `sharedPgInfoRef` current for the registered pgsql completion/signature-help + // providers. Runs in an effect — not render — so only committed tree state writes the + // shared, module-level ref; mutating it directly during render risked a discarded or + // interrupted render pass leaking a write that no committed render ever produced. + useEffect(() => { + if (isPgInfoReady) { + if (sharedPgInfoRef.current === null) { + sharedPgInfoRef.current = {} + } + sharedPgInfoRef.current.tableColumns = tableColumns + sharedPgInfoRef.current.schemas = filteredSchemas + sharedPgInfoRef.current.keywords = keywords + sharedPgInfoRef.current.functions = functions + } else if (!intellisenseEnabled) { + // Release this instance's hold on the (potentially huge, for large databases) + // tableColumns/functions arrays so they're actually eligible for GC — see the + // cache-eviction effect below for why `enabled: false` alone isn't enough. + sharedPgInfoRef.current = null } - pgInfoRef.current.tableColumns = tableColumns - pgInfoRef.current.schemas = filteredSchemas - pgInfoRef.current.keywords = keywords - pgInfoRef.current.functions = functions - } + }, [isPgInfoReady, intellisenseEnabled, tableColumns, filteredSchemas, keywords, functions]) + + // Actively evict the cached tableColumns/functions data when intellisense is turned off + useEffect(() => { + if (intellisenseEnabled) return + + queryClient.removeQueries({ + queryKey: databaseKeys.tableColumns(project?.ref, undefined, undefined), + exact: true, + }) + queryClient.removeQueries({ + queryKey: databaseKeys.databaseFunctions(project?.ref), + exact: true, + }) + }, [intellisenseEnabled, project?.ref, queryClient]) // Enable pgsql format useEffect(() => { - if (monaco) { - const formatProvider = monaco.languages.registerDocumentFormattingEditProvider('pgsql', { + if (!monaco || !enabled) return + + return acquireSharedRegistration('pgsql-format', () => + monaco.languages.registerDocumentFormattingEditProvider('pgsql', { async provideDocumentFormattingEdits(model) { const value = model.getValue() const formatted = formatSql(value) @@ -90,31 +153,29 @@ export const useAddDefinitions = ( return [{ range: model.getFullModelRange(), text: formatted }] }, }) - return () => formatProvider.dispose() - } + ) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [monaco]) + }, [monaco, enabled]) // Register auto completion item provider for pgsql useEffect(() => { - let completeProvider: IDisposable | null = null - let signatureHelpProvider: IDisposable | null = null + if (!isPgInfoReady || !monaco) return - if (isPgInfoReady) { - if (monaco && isPgInfoReady) { - completeProvider = monaco.languages.registerCompletionItemProvider( - 'pgsql', - getPgsqlCompletionProvider(monaco, pgInfoRef) - ) - signatureHelpProvider = monaco.languages.registerSignatureHelpProvider( - 'pgsql', - getPgsqlSignatureHelpProvider(monaco, pgInfoRef) - ) + return acquireSharedRegistration('pgsql-completion', () => { + const completeProvider = monaco.languages.registerCompletionItemProvider( + 'pgsql', + getPgsqlCompletionProvider(monaco, sharedPgInfoRef) + ) + const signatureHelpProvider = monaco.languages.registerSignatureHelpProvider( + 'pgsql', + getPgsqlSignatureHelpProvider(monaco, sharedPgInfoRef) + ) + return { + dispose: () => { + completeProvider.dispose() + signatureHelpProvider.dispose() + }, } - } - return () => { - completeProvider?.dispose() - signatureHelpProvider?.dispose() - } + }) }, [isPgInfoReady, monaco]) } From 6edef9f067d9707a19c7aa116c981fe6f3544c32 Mon Sep 17 00:00:00 2001 From: Miranda Limonczenko Date: Thu, 20 Aug 2026 11:42:30 -0700 Subject: [PATCH 2/6] chore(www): unpublish the Launch Week 6 page (#49281) Closes [FE-4100](https://linear.app/supabase/issue/FE-4100/www-remove-httpssupabasecomlaunch-week6) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Content removal. ## What is the current behavior? `/launch-week/6` is still published. Launch Week 6 ran in December 2022. The page carries its own 1,085-line component, two CSS modules, and a Supabase client that reads the `lw6_creators` and `lw6_tickets` tables. ## What is the new behavior? - Delete the `/launch-week/6` page, its CSS modules, its day data, and its types. - Redirect `/launch-week/6` to `/blog/launch-week-6-wrap-up`, which holds the same content. - Drop the Launch Week 6 card from the archive section on `/launch-week/8`, leaving Launch Week 7. ## Additional context Scope is Launch Week 6 only. Whether the other launch week pages come down is still open with marketing. Assets under `public/images/launchweek/` are untouched. Several are shared across launch weeks, so they need their own audit. ## Manual testing 1. Open [https://zone-www-dot-com-git-www-remove-launchweek-supabase.vercel.app/launch-week/6](https://zone-www-dot-com-git-www-remove-launchweek-supabase.vercel.app/launch-week/6) on the deploy preview. It returns a 308 and lands on `/blog/launch-week-6-wrap-up`. 2. Open [the Launch Week 7 page](https://zone-www-dot-com-git-www-remove-launchweek-supabase.vercel.app/launch-week/7). It still loads. 3. Open [the Launch Week 8 page](https://zone-www-dot-com-git-www-remove-launchweek-supabase.vercel.app/launch-week/8) and scroll to "Previous Launch Weeks". Only the Launch Week 7 card shows. Co-authored-by: Claude Opus 5 --- .../www/components/LaunchWeek/6/lw6_days.json | 114 -- .../www/components/LaunchWeek/8/LWArchive.tsx | 20 +- apps/www/lib/redirects.js | 5 + apps/www/pages/launch-week/6/index.tsx | 1085 ----------------- .../6/styles/launchWeek6.module.css | 30 - .../launch-week/6/styles/utils6.module.css | 76 -- apps/www/types/launch-week-6.ts | 43 - 7 files changed, 7 insertions(+), 1366 deletions(-) delete mode 100644 apps/www/components/LaunchWeek/6/lw6_days.json delete mode 100644 apps/www/pages/launch-week/6/index.tsx delete mode 100644 apps/www/pages/launch-week/6/styles/launchWeek6.module.css delete mode 100644 apps/www/pages/launch-week/6/styles/utils6.module.css delete mode 100644 apps/www/types/launch-week-6.ts diff --git a/apps/www/components/LaunchWeek/6/lw6_days.json b/apps/www/components/LaunchWeek/6/lw6_days.json deleted file mode 100644 index 14ab538066481..0000000000000 --- a/apps/www/components/LaunchWeek/6/lw6_days.json +++ /dev/null @@ -1,114 +0,0 @@ -[ - { - "title": "Docs Updated", - "shipped": true, - "date": "12 Dec 2022", - "description": "Supabase Docs", - "d": 1, - "dd": "Monday", - "youtube_id": "OpPOaJI_Z28", - "blogpost": "https://supabase.com/blog/new-supabase-docs-built-with-nextjs", - "docs": "https://supabase.com/docs", - "steps": [ - { - "title": "Supabase Docs", - "blog": "/blog/new-supabase-docs-built-with-nextjs", - "docs": "/docs", - "isNew": true, - "description": "" - } - ] - }, - { - "title": "Image Processing: Resize V2 + CDN", - "shipped": true, - "date": "13 Dec 2022", - "description": "Image Transformations", - "d": 2, - "dd": "Tuesday", - "youtube_id": "iqZlPtl_b-I", - "blogpost": "https://supabase.com/blog", - "docs": "https://supabase.com/docs", - "steps": [ - { - "title": "Image Transformations", - "blog": "/blog/storage-image-resizing-smart-cdn", - "docs": "/docs/guides/storage/cdn/fundamentals", - "isNew": true, - "description": "" - }, - { - "title": "Smart CDN", - "description": "Faster asset delivery, now even faster.", - "blog": "/blog/storage-image-resizing-smart-cdn", - "docs": "/docs/guides/storage/cdn/fundamentals", - "isNew": true - } - ] - }, - { - "title": "Multi-factor Authentication", - "shipped": true, - "date": "14 Dec 2022", - "description": "Multi-factor Authentication", - "d": 3, - "dd": "Wednesday", - "youtube_id": "CGZr5tybW18", - "blogpost": "https://supabase.com/blog", - "docs": "https://supabase.com/docs", - "steps": [ - { - "title": "Multi-factor Authentication", - "blog": "/blog/mfa-auth-via-rls", - "docs": "/docs/guides/auth/auth-mfa", - "isNew": false, - "description": "" - } - ] - }, - { - "title": "Supabase Wrappers - FDW Framework", - "shipped": true, - "date": "15 Dec 2022", - "description": "Supabase Wrappers", - "d": 4, - "dd": "Thursday", - "youtube_id": "QA2qC5F-4OU", - "blogpost": "/blog/postgres-foreign-data-wrappers-rust", - "docs": "https://supabase.com/docs", - "steps": [ - { - "title": "Supabase Wrappers", - "blog": "/blog/postgres-foreign-data-wrappers-rust", - "docs": "https://supabase.github.io/wrappers/", - "isNew": true, - "description": "Connect Postgres to external systems with Postgres Foreign Data Wrappers" - } - ] - }, - { - "title": "Supabase Vault Release", - "shipped": true, - "date": "16 Dec 2022", - "description": "Vault Release", - "d": 5, - "dd": "Friday", - "youtube_id": "6bGQotxisoY", - "blogpost": "https://supabase.com/blog", - "docs": "https://supabase.com/docs", - "steps": [ - { - "title": "Vault Release", - "blog": "/blog/vault-now-in-beta", - "isNew": true, - "description": "" - }, - { - "title": "Transparent Column Encryption", - "description": "Faster asset delivery, now even faster.", - "blog": "/blog/transparent-column-encryption-with-postgres", - "isNew": true - } - ] - } -] diff --git a/apps/www/components/LaunchWeek/8/LWArchive.tsx b/apps/www/components/LaunchWeek/8/LWArchive.tsx index 38a88417eb435..44aeec837e68f 100644 --- a/apps/www/components/LaunchWeek/8/LWArchive.tsx +++ b/apps/www/components/LaunchWeek/8/LWArchive.tsx @@ -1,6 +1,7 @@ +import Image from 'next/image' import React from 'react' + import { StyledArticleBadge } from './Releases/components' -import Image from 'next/image' const LWArchive = () => { return ( @@ -13,23 +14,6 @@ const LWArchive = () => {

- - - 6 - -
- Launch Week 6 -
-
(null) - - const [creators, setCreators] = useState([]) - const [activeCreator, setActiveCreator] = useState(null) - const { query } = useRouter() - const ticketNumber = query.ticketNumber?.toString() - - useEffect(() => { - if (!supabase) { - setSupabase( - createClient( - process.env.NEXT_PUBLIC_MISC_USE_URL!, - process.env.NEXT_PUBLIC_MISC_USE_ANON_KEY! - ) - ) - } - }, []) - - useEffect(() => { - if (supabase) { - getCreators() - } - }, [supabase]) - - async function getCreators() { - try { - // setLoading(true) - let supa = await supabase.from('lw6_creators').select() - - let { data, error, status } = supa - - if (error && status !== 406) { - throw error - } - - if (data) { - setCreators(data) - } - } catch (error) { - // alert('Error loading user data!') - console.log(error) - } finally { - // setLoading(false) - } - } - - const AccordionHeader = ({ date, day, title, shipped }: any) => { - return ( -
-
- - {shipped ? 'Shipped' : 'Coming Soon'} - - - - {day} ・ {date} - -
- {title} -
- ) - } - - const SectionButtons = ({ blog, docs, video }) => { - return ( -
- -
- Blog post -
- -
-
-
- {docs && ( - -
- Docs -
- -
-
-
- )} - {video && ( - -
- Video -
- -
-
-
- )} -
- ) - } - const [day1, day2, day3, day4, day5] = days - - return ( - <> - - - -
-
- -
-

- Dec 12 – 16 at 6 AM PT | 9 AM ET -

-
-
-
-
-
- -
-
-
- - -
-
-
- -
-
- Who we hire at Supabase - Fireside chat with founders -
-
- -
-
- -
-
-
- -
-
- Wrap Up - Everything we shipped -
-
- -
-
- - - - - - - - {day1.steps.length > 0 && ( -
-
-
-
- {day1.description} - - Redesigned - -
- -
-
- )} -
-
- - - - - - {day2.steps.length > 0 && ( -
-
-
-
-
{day2.steps[0].title}
- - New - -
- -
-
-
-
- - New - - {day2.steps[1].title} -

{day2.steps[1].description}

-
- -
-
- )} -
-
- - - - - - {day3.steps.length > 0 && ( -
-
-
- -
-
- -
- -
- {day3.steps[0].title} - - Updated - -
- -
-
- )} -
-
- - - - - - {day4.steps.length > 0 && ( -
-
-
- -
-
- -
-
- {day4.steps[0].title} - - New - -
- -
-
- )} -
-
- - - - - - {day5.steps.length > 0 && ( - <> -
-
-
- -
-
- -
-
- {day5.steps[0].title} - - New - -
- -
-
-
- -
-
- -
-
- - New - - {day5.steps[1].title} -
- -
-
-

Community

- -

One more thing

-
-
-
- -
-
- -
-
- - Updated - - pg_graphql v1.0 -
- -
-
-
- -
-
- -
-
- - New - - Custom Domains -
- -
-
-
- -
-
- -
-
- - New - - Point-in-time recovery -
- -
-
-
- -
-
- -
-
- - Experimental - - pg_crdt -
- -
-
-
- -
-
- -
-
- - Upgrade - - Postgres 15 -
- -
-
-
- -
-
- -
-
- - Upgrade - - PostgREST 11 -
- -
-
- - )} -
-
-
-
- - - - - -
- - Submissions Closed - -

Launch Week Hackathon

-

- The traditional parallel Hackathon is back! Build a new open source project with - Supabase and you can win $1500 in GitHub sponsorships and a coveted Supabase Darkmode - Keyboard! For more info check the{' '} - - blog post - - . -

-
-
-
-
-

Prizes

-

- There are 5 categories to win, with prizes for winners and runner-ups of each - category. Each team member gets a prize. -

-
-
-

Judges

-

- The Supabase team will judge all the categories except the Best Edge Functions - Project, which will be judged by our friends at Deno. -

-
-
-
-

Community

-

- If you need help or advice when building, find other people to join your team, - or if you just want to chill and watch people build, come and join us! -

-
- - Join our Discord - -
-
-
-

Submission

-

- Submit your project through{' '} - - madewithsupabase.com - - . All submissions must be open source and publically available. Submissions close - Monday 19th Dec 00:01 AM PT. -

-
-
- -
-
- -
- {creators.map((creator: any, index: number) => { - return ( -
{ - setActiveCreator(index) - }} - style={{ - top: `${constellation[index][0]}%`, - left: `${constellation[index][1]}%`, - }} - > -
-
- - - -
-
- ) - })} -
-
- - Shipped - -

The Supabase Content Storm

-

- We worked with more than 30 content creators from around the world to drop a mountain - of content simultaneously! - - See all the content - - -

- {activeCreator !== null && ( -
-

- {activeCreator !== null - ? `${creators[activeCreator].first_name} ${creators[activeCreator].last_name}` - : 'Title'} -

- {activeCreator !== null && ( -

{creators[activeCreator].description}

- )} -

- - - {creators[activeCreator].link_title} - - - -

-
- )} -
-
-
- - ) -} diff --git a/apps/www/pages/launch-week/6/styles/launchWeek6.module.css b/apps/www/pages/launch-week/6/styles/launchWeek6.module.css deleted file mode 100644 index d31924eece8e4..0000000000000 --- a/apps/www/pages/launch-week/6/styles/launchWeek6.module.css +++ /dev/null @@ -1,30 +0,0 @@ -.mask { - mask-image: radial-gradient(black, transparent); -} - -.dark_community { - background-image: radial-gradient( - closest-side at 50% 50%, - #132121, - #132121db, - var(--background-default) - ); -} - -.community { - background-image: radial-gradient(closest-side at 50% 50%, #d9eeef, #dbeef0, white); -} - -.wrappers > span { - left: -200px !important; -} - -.community_wrappers > span { - left: -400px !important; -} - -@media (max-width: 768px) { - .community_wrappers > span > img { - object-fit: contain !important; - } -} diff --git a/apps/www/pages/launch-week/6/styles/utils6.module.css b/apps/www/pages/launch-week/6/styles/utils6.module.css deleted file mode 100644 index c4aba2ab6861c..0000000000000 --- a/apps/www/pages/launch-week/6/styles/utils6.module.css +++ /dev/null @@ -1,76 +0,0 @@ -.appear { - opacity: 0; - animation: appear 0.8s cubic-bezier(0.1, 0, 0.175, 1) forwards; -} - -.appear-first { - animation-delay: 0.4s; -} - -.appear-second { - animation-delay: 0.8s; -} - -.appear-third { - animation-delay: 1.2s; -} - -.appear-fourth { - animation-delay: 1.6s; -} - -.appear-fifth { - animation-delay: 2s; -} - -.appear-sixth { - animation-delay: 2.4s; -} - -@keyframes appear { - to { - opacity: 1; - } -} - -@media (min-width: 1200px) { - .hide-on-desktop { - display: none; - } -} - -.show-on-mobile { - display: block; -} - -.hide-on-mobile { - display: none; -} - -@media (min-width: 768px) { - .show-on-mobile { - display: none; - } - - .hide-on-mobile { - display: block; - } -} - -.hide-on-tablet { - display: block; -} - -.show-on-tablet { - display: none; -} - -@media (max-width: 1199px) and (min-width: 768px) { - .show-on-tablet { - display: block; - } - - .hide-on-tablet { - display: none; - } -} diff --git a/apps/www/types/launch-week-6.ts b/apps/www/types/launch-week-6.ts deleted file mode 100644 index fbea7cf9d0f2f..0000000000000 --- a/apps/www/types/launch-week-6.ts +++ /dev/null @@ -1,43 +0,0 @@ -export type Article = { - title: string - url: string - description?: string - products?: Product[] -} - -export type Announcement = { - title: string - url: string - description?: string - type: 'soc2' | 'producthunt' -} - -export type Product = { - title: string - url: string - description?: string -} - -export type Step = { - title: string - url: string - docs: string - description?: string -} - -export interface WeekDayProps { - shipped: boolean - title: string - description: string - date: string - imgUrl?: string - d?: number - dd?: 'Monday' | 'Tuesday' | 'Wednesday' | 'Thursday' | 'Friday' - articles?: Article[] - announcements?: Announcement[] - products?: Announcement[] - index: number - shippingHasStarted?: boolean - youtube_id?: string - steps: Step[] -} From ebd399ff8762f03d8b2c196ada3e027c2c8c1bae Mon Sep 17 00:00:00 2001 From: Miranda Limonczenko Date: Thu, 20 Aug 2026 11:43:51 -0700 Subject: [PATCH 3/6] fix(www): use li instead of ol in launch week summary lists (#49279) Closes [FE-4096](https://linear.app/supabase/issue/FE-4096/launch-week-summary-lists-ol-inside-ul-link-where-li-belongs-6-copies) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Accessibility bug fix. ## What is the current behavior? The launch week summary card renders at the bottom of launch week blog posts. Its two lists are invalid HTML in six copies of the component. - Each entry is an `
    ` nested directly inside a `
      `. Only `
    • ` is a valid child of `
        `. - The `` sits inside the `
          ` rather than inside an `
        1. `, so there are no list items at all. Screen readers announce a list of empty items wrapping nested lists instead of a flat list of links. ## What is the new behavior? - Swap every `
            ` for an `
          1. ` in the six summary components: LW X, 11, 12, 13, 14, and 15. - Class names and keys carry over unchanged. No visual change. ## Additional context The blog posts stay published. This is a markup fix only. ## Manual testing 1. Open [the Launch Week 15 top 10 post](https://zone-www-dot-com-git-www-fix-lw-summary-lists-supabase.vercel.app/blog/launch-week-15-top-10) on the deploy preview. 2. Scroll to the Launch Week 15 summary card below the article. It shows a Main Stage list and a Build Stage list. 3. Inspect either list. Every direct child of the `
              ` is an `
            • `, and no `
                ` appears inside. 4. Repeat on [the Launch Week 12 Wasm FDW post](https://zone-www-dot-com-git-www-fix-lw-summary-lists-supabase.vercel.app/blog/postgres-foreign-data-wrappers-with-wasm) for the Launch Week 12 card. Co-authored-by: Claude Opus 5 --- .../components/LaunchWeek/11/LW11Summary.tsx | 16 +++++++-------- .../components/LaunchWeek/12/LWSummary.tsx | 12 +++++------ .../LaunchWeek/13/Releases/LWSummary.tsx | 12 +++++------ .../LaunchWeek/14/Releases/LWSummary.tsx | 12 +++++------ .../components/LaunchWeek/15/LWSummary.tsx | 20 +++++++++---------- .../components/LaunchWeek/X/LWXSummary.tsx | 16 +++++++-------- 6 files changed, 44 insertions(+), 44 deletions(-) diff --git a/apps/www/components/LaunchWeek/11/LW11Summary.tsx b/apps/www/components/LaunchWeek/11/LW11Summary.tsx index 1fc7ecd83484a..8cdbec064eed9 100644 --- a/apps/www/components/LaunchWeek/11/LW11Summary.tsx +++ b/apps/www/components/LaunchWeek/11/LW11Summary.tsx @@ -38,14 +38,14 @@ const LW11Summary = () => { {days.map( (day, i: number) => day.shipped && ( -
                  +
                1. Day {i + 1} - {day.title} -
                + ) )}
            @@ -59,7 +59,7 @@ const LW11Summary = () => { {buildDays.map( (day, i: number) => day.is_shipped && ( -
              +
            1. { {day.title} -
            +
          2. ) )} -
              +
            1. Open Source Hackathon 2024 -
            -
              + +
            1. Community Meetups -
            +
diff --git a/apps/www/components/LaunchWeek/12/LWSummary.tsx b/apps/www/components/LaunchWeek/12/LWSummary.tsx index eeba78750a53c..2a50107df3a3d 100644 --- a/apps/www/components/LaunchWeek/12/LWSummary.tsx +++ b/apps/www/components/LaunchWeek/12/LWSummary.tsx @@ -22,14 +22,14 @@ const LW11Summary = () => { {days.map( (day, i: number) => day.shipped && ( -
    +
  1. Day {i + 1} - {day.title} -
+ ) )} @@ -43,7 +43,7 @@ const LW11Summary = () => { {buildDays.map( (day, i: number) => day.is_shipped && ( -
    +
  1. { {day.title} -
+ ) )} -
    +
  1. Community Meetups -
+ diff --git a/apps/www/components/LaunchWeek/13/Releases/LWSummary.tsx b/apps/www/components/LaunchWeek/13/Releases/LWSummary.tsx index 67503d47dfd6d..c0b20d9b064c4 100644 --- a/apps/www/components/LaunchWeek/13/Releases/LWSummary.tsx +++ b/apps/www/components/LaunchWeek/13/Releases/LWSummary.tsx @@ -22,14 +22,14 @@ const LW13Summary = () => { {days.map( (day, i: number) => day.shipped && ( -
    +
  1. Day {i + 1} - {day.title} -
+ ) )} @@ -43,7 +43,7 @@ const LW13Summary = () => { {buildDays.map( (day, i: number) => day.is_shipped && ( -
    +
  1. { {day.title} -
+ ) )} -
    +
  1. Community Meetups -
+ diff --git a/apps/www/components/LaunchWeek/14/Releases/LWSummary.tsx b/apps/www/components/LaunchWeek/14/Releases/LWSummary.tsx index 1b8611af83d0f..cc6d67f678dd1 100644 --- a/apps/www/components/LaunchWeek/14/Releases/LWSummary.tsx +++ b/apps/www/components/LaunchWeek/14/Releases/LWSummary.tsx @@ -26,12 +26,12 @@ const LW14Summary = () => { {days.map( (day, i: number) => day.shipped && ( -
    +
  1. Day {i + 1} - {day.title} -
+ ) )} @@ -43,7 +43,7 @@ const LW14Summary = () => { {buildDays.map( (day, i: number) => day.is_shipped && ( -
    +
  1. { {day.title} -
+ ) )} -
    +
  1. Community Meetups -
+ diff --git a/apps/www/components/LaunchWeek/15/LWSummary.tsx b/apps/www/components/LaunchWeek/15/LWSummary.tsx index d56ff6237c621..f62c2b5a1f4e2 100644 --- a/apps/www/components/LaunchWeek/15/LWSummary.tsx +++ b/apps/www/components/LaunchWeek/15/LWSummary.tsx @@ -27,7 +27,7 @@ const LW14Summary = () => { @@ -58,7 +58,7 @@ const LW14Summary = () => { diff --git a/apps/www/components/LaunchWeek/X/LWXSummary.tsx b/apps/www/components/LaunchWeek/X/LWXSummary.tsx index 32c1e6a2428d4..d17f1194cbc29 100644 --- a/apps/www/components/LaunchWeek/X/LWXSummary.tsx +++ b/apps/www/components/LaunchWeek/X/LWXSummary.tsx @@ -30,14 +30,14 @@ const LWXSummary = () => { {mainDays.map( (day, i: number) => day.shipped && ( -
    +
  1. Day {i + 1} - {day.description} -
+ ) )} @@ -51,7 +51,7 @@ const LWXSummary = () => { {buildDays.map( (day, i: number) => day.is_shipped && ( -
    +
  1. { {day.title} -
+ ) )} -
    +
  1. Supabase Launch Week X Hackathon -
-
    + +
  1. Supabase Launch Week X Community Meetups -
+ From 5d3b84945ea569ff9d1ec0d67d95c0653f09f4f4 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Thu, 20 Aug 2026 12:55:38 -0600 Subject: [PATCH 4/6] fix(studio): derive switch-to-preview refs from the branch (FE-4219) (#49320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Switch to preview" in the delete flow read its refs from the selected project, so on the branching overview `parent_project_ref` was undefined and the handler bailed with a `console.error` — persistent branches couldn't be deleted. Both refs now come from the `branch` prop, and the not-ready state shows on the confirm button instead of the console. Covered by a new MSW test that fails against the old code. Fixes FE-4219 ## Summary by CodeRabbit * **Bug Fixes** * Improved switching branches to Preview mode by using the selected branch’s project information. * Prevented confirmation when no branch is available. * Preserved success notifications and modal closing after a successful switch. * **Tests** * Added coverage for successful updates, API failures, error feedback, request details, and disabled confirmation states. --- .../SwitchToPreviewModal.test.tsx | 103 ++++++++++++++++++ .../BranchManagement/SwitchToPreviewModal.tsx | 21 ++-- 2 files changed, 112 insertions(+), 12 deletions(-) create mode 100644 apps/studio/components/interfaces/BranchManagement/SwitchToPreviewModal.test.tsx diff --git a/apps/studio/components/interfaces/BranchManagement/SwitchToPreviewModal.test.tsx b/apps/studio/components/interfaces/BranchManagement/SwitchToPreviewModal.test.tsx new file mode 100644 index 0000000000000..c935c186525c4 --- /dev/null +++ b/apps/studio/components/interfaces/BranchManagement/SwitchToPreviewModal.test.tsx @@ -0,0 +1,103 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { mockAnimationsApi } from 'jsdom-testing-mocks' +import { HttpResponse } from 'msw' +import { toast } from 'sonner' +import { describe, expect, test, vi } from 'vitest' + +import { SwitchToPreviewModal } from './SwitchToPreviewModal' +import type { components } from '@/data/api' +import type { Branch } from '@/data/branches/branches-query' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw' + +mockAnimationsApi() + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +type BranchUpdateResponse = components['schemas']['BranchUpdateResponse'] + +const PARENT_PROJECT_REF = 'parent-project-ref' +const BRANCH_PROJECT_REF = 'branch-project-ref' + +const BRANCH: Branch = { + created_at: '2026-01-01T00:00:00.000Z', + id: '00000000-0000-0000-0000-000000000001', + is_default: false, + name: 'docs-local-staging', + parent_project_ref: PARENT_PROJECT_REF, + persistent: true, + project_ref: BRANCH_PROJECT_REF, + status: 'MIGRATIONS_PASSED', + updated_at: '2026-01-01T00:00:00.000Z', + with_data: false, +} + +const mockBranchUpdate = () => { + const requests: Array<{ branchRef: string | undefined; body: unknown }> = [] + + addAPIMock({ + method: 'patch', + path: '/v1/branches/:branch_id_or_ref', + response: async ({ request, params }) => { + requests.push({ + branchRef: params.branch_id_or_ref as string | undefined, + body: await request.json(), + }) + return HttpResponse.json({ + message: 'ok', + workflow_run_id: 'workflow-run-1', + }) + }, + }) + + return requests +} + +const renderModal = (overrides: { branch?: Branch; onClose?: () => void } = {}) => { + const onClose = overrides.onClose ?? vi.fn() + customRender() + return { onClose } +} + +describe('SwitchToPreviewModal', () => { + test('switches the branch to preview using the refs on the branch', async () => { + const requests = mockBranchUpdate() + + const { onClose } = renderModal({ branch: BRANCH }) + + await userEvent.click(await screen.findByRole('button', { name: 'Switch to preview' })) + + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + expect(requests).toEqual([{ branchRef: BRANCH_PROJECT_REF, body: { persistent: false } }]) + expect(toast.success).toHaveBeenCalledWith('Successfully updated branch') + }) + + test('surfaces the error and keeps the modal open when the update fails', async () => { + addAPIMock({ + method: 'patch', + path: '/v1/branches/:branch_id_or_ref', + response: () => + HttpResponse.json({ message: 'Something exploded' }, { status: 500 }), + }) + + const { onClose } = renderModal({ branch: BRANCH }) + + const confirm = await screen.findByRole('button', { name: 'Switch to preview' }) + await userEvent.click(confirm) + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith('Failed to update branch: Something exploded') + ) + expect(onClose).not.toHaveBeenCalled() + expect(confirm).toBeEnabled() + }) + + test('disables the confirm button while the branch is unavailable', async () => { + renderModal() + + expect(await screen.findByRole('button', { name: 'Switch to preview' })).toBeDisabled() + }) +}) diff --git a/apps/studio/components/interfaces/BranchManagement/SwitchToPreviewModal.tsx b/apps/studio/components/interfaces/BranchManagement/SwitchToPreviewModal.tsx index d86c42a8389a3..d464477798287 100644 --- a/apps/studio/components/interfaces/BranchManagement/SwitchToPreviewModal.tsx +++ b/apps/studio/components/interfaces/BranchManagement/SwitchToPreviewModal.tsx @@ -3,7 +3,6 @@ import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' import { useBranchUpdateMutation } from '@/data/branches/branch-update-mutation' import { type Branch } from '@/data/branches/branches-query' -import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' interface SwitchToPreviewModalProps { open: boolean @@ -12,11 +11,6 @@ interface SwitchToPreviewModalProps { } export const SwitchToPreviewModal = ({ open, branch, onClose }: SwitchToPreviewModalProps) => { - const { data: project } = useSelectedProjectQuery() - - const branchRef = project?.ref - const projectRef = project?.parent_project_ref - const { mutate: updateBranch, isPending: isUpdatingBranch } = useBranchUpdateMutation({ onSuccess() { toast.success('Successfully updated branch') @@ -24,11 +18,13 @@ export const SwitchToPreviewModal = ({ open, branch, onClose }: SwitchToPreviewM }, }) - const onTogglePersistent = () => { - if (branchRef === undefined || projectRef === undefined || branch === undefined) { - return console.error('Branch metadata is required') - } - updateBranch({ branchRef, projectRef, persistent: !branch.persistent }) + const onSwitchToPreview = () => { + if (branch === undefined) return + updateBranch({ + branchRef: branch.project_ref, + projectRef: branch.parent_project_ref, + persistent: false, + }) } return ( @@ -38,8 +34,9 @@ export const SwitchToPreviewModal = ({ open, branch, onClose }: SwitchToPreviewM confirmLabel="Switch to preview" title="Switch branch to preview before deleting" loading={isUpdatingBranch} + disabled={branch === undefined} onCancel={() => onClose()} - onConfirm={onTogglePersistent} + onConfirm={onSwitchToPreview} >

You must switch the branch "{branch?.name}" to preview before deleting it. From ebd616fa901db16d4dd1272effa238cd022f25c9 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:08:19 -0400 Subject: [PATCH 5/6] fix(studio): auto-retry notebook updates on stale/invalid conflicts (#49323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - **Removed dead client-side refresh UI** in `NotebookProposalRenderer.tsx` and its test — the diff preview is always computed from live data, so the check was redundant with the tool's server-side re-validation - **Added typed `NotebookToolError`** in `notebook-tools.ts` with structured metadata (`{ exposeToAssistant: boolean }`) validated by a zod schema with a literal discriminant tag (`tag: 'notebook_tool_error'`) — tracks the two retryable failures: staleness conflict and invalid operations (unknown cell id) - **Encoded errors in `generate-v4.ts` onError** — the one place in the pipeline that holds the live `Error` before it becomes a string in the persisted message - **Extracted and fixed message history filter** into new `generate-assistant-response.utils.ts` — any tool-error whose `errorText` decodes against the `NotebookToolError` schema is let through (with `errorText` rewritten to plain prose so the model sees the message, not JSON), while other errors stay filtered as before Net effect: the assistant detects the specific, actionable rejection reason and retries on its own with no dead button or human intervention needed. ## Test plan - Existing unit tests in `NotebookProposalRenderer.test.tsx` pass (dead button test removed) - New unit tests in `notebook-tools.test.ts` cover encode/decode round-trips and error discrimination - New unit tests in `generate-assistant-response.utils.test.ts` cover message history filtering with all error states - `pnpm typecheck` is clean - `pnpm --filter studio run lint:ratchet` passes (no new ESLint warnings) ## Summary by CodeRabbit * **New Features** * Notebook update errors now provide clearer, structured explanations to the AI assistant. * Assistant responses preserve relevant notebook error details while filtering invalid or temporary tool states. * **Bug Fixes** * Improved handling of stale notebook revisions and invalid notebook update operations. * Notebook proposal rendering proceeds without an unnecessary refresh step. * **Tests** * Expanded coverage for notebook errors, message filtering, serialization, and error handling. --- .../NotebookProposalRenderer.test.tsx | 27 ---- .../NotebookProposalRenderer.tsx | 26 ---- .../lib/ai/generate-assistant-response.ts | 34 +---- .../generate-assistant-response.utils.test.ts | 125 ++++++++++++++++++ .../ai/generate-assistant-response.utils.ts | 50 +++++++ .../lib/ai/tools/notebook-tools.test.ts | 91 +++++++++---- apps/studio/lib/ai/tools/notebook-tools.ts | 57 +++++++- apps/studio/pages/api/ai/sql/generate-v4.ts | 4 + 8 files changed, 303 insertions(+), 111 deletions(-) create mode 100644 apps/studio/lib/ai/generate-assistant-response.utils.test.ts create mode 100644 apps/studio/lib/ai/generate-assistant-response.utils.ts diff --git a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx index 5d591f90644da..df86f4795e275 100644 --- a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx @@ -98,33 +98,6 @@ describe('NotebookProposalRenderer', () => { expect(onApprove).toHaveBeenCalledTimes(1) }) - it('warns and withholds the diff when the notebook changed since expected_updated_at', async () => { - const onApprove = vi.fn() - mockContentItem(mockNotebookRow({ updated_at: '2024-06-01T00:00:00.000Z' })) - - render( - - ) - - expect( - await screen.findByText('This notebook changed since the assistant planned this update') - ).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument() - expect(onApprove).not.toHaveBeenCalled() - }) - it('falls back to a raw-input admonition without dropping the confirm footer on a parse failure', async () => { const user = userEvent.setup() const onApprove = vi.fn() diff --git a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx index 0f2c665de5106..e59c5d6aa0681 100644 --- a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx @@ -238,8 +238,6 @@ function UpdateNotebookProposal({ isLoading, isError, error, - refetch, - isFetching, } = useNotebookQuery( { projectRef: ref, id: parsedInput.success ? parsedInput.data.id : undefined }, { enabled: parsedInput.success } @@ -284,30 +282,6 @@ function UpdateNotebookProposal({ ) } - const isStale = notebook.updated_at !== parsedInput.data.expected_updated_at - - if (isStale) { - return ( - refetch()} - > -

- -
- - ) - } - const diff = deriveNotebookDiff(toWireNotebook(notebook.content), parsedInput.data.operations) if (!diff.success) { diff --git a/apps/studio/lib/ai/generate-assistant-response.ts b/apps/studio/lib/ai/generate-assistant-response.ts index 9f457df4bc53f..1488882f504fa 100644 --- a/apps/studio/lib/ai/generate-assistant-response.ts +++ b/apps/studio/lib/ai/generate-assistant-response.ts @@ -2,7 +2,6 @@ import * as ai from 'ai' import { convertToModelMessages, isStepCount, - isToolUIPart, type LanguageModel, type ModelMessage, type SystemModelMessage, @@ -16,6 +15,7 @@ import type { AssistantEvalInput } from '@/evals/scorer' import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' import { buildAssistantContextMessages, NO_SCHEMA_ACCESS_MESSAGE } from '@/lib/ai/assistant-context' import { IS_TRACING_ENABLED } from '@/lib/ai/braintrust-logger' +import { prepareMessagesForModel } from '@/lib/ai/generate-assistant-response.utils' import { CHAT_PROMPT, GENERAL_PROMPT, @@ -23,7 +23,6 @@ import { NOTEBOOKS_PROMPT, SECURITY_PROMPT, } from '@/lib/ai/prompts' -import { sanitizeMessagePart } from '@/lib/ai/tools/tool-sanitizer' const { streamText: tracedStreamText } = wrapAISDK(ai) @@ -74,36 +73,7 @@ export async function generateAssistantResponse({ const shouldTrace = allowTracing ?? IS_TRACING_ENABLED const run = async (span?: Span) => { - // Only returns last 7 messages - // Filters out tools with invalid states - // Filters out tool outputs based on opt-in level - const messages = (rawMessages || []).slice(-7).map((msg) => { - if (msg && msg.role === 'assistant' && 'results' in msg) { - const cleanedMsg = { ...msg } - delete cleanedMsg.results - return cleanedMsg - } - if (msg && msg.role === 'assistant' && msg.parts) { - const cleanedParts = msg.parts - .filter((part) => { - if (isToolUIPart(part)) { - const invalidStates = [ - 'input-streaming', - 'input-available', - 'approval-requested', - 'output-error', - ] - return !invalidStates.includes(part.state) - } - return true - }) - .map((part) => { - return sanitizeMessagePart(part, aiOptInLevel) - }) - return { ...msg, parts: cleanedParts } - } - return msg - }) + const messages = prepareMessagesForModel(rawMessages, aiOptInLevel) const schemasString = aiOptInLevel !== 'disabled' && getSchemas diff --git a/apps/studio/lib/ai/generate-assistant-response.utils.test.ts b/apps/studio/lib/ai/generate-assistant-response.utils.test.ts new file mode 100644 index 0000000000000..7e5d23429df5b --- /dev/null +++ b/apps/studio/lib/ai/generate-assistant-response.utils.test.ts @@ -0,0 +1,125 @@ +import type { ToolUIPart, UIMessage } from 'ai' +import { describe, expect, it } from 'vitest' + +import { prepareMessagesForModel } from './generate-assistant-response.utils' +import { encodeNotebookToolError, NotebookToolError } from './tools/notebook-tools' + +function assistantMessage(parts: UIMessage['parts']): UIMessage { + return { id: 'msg-1', role: 'assistant', parts } +} + +function toolPart(overrides: Partial): ToolUIPart { + return { + type: 'tool-update_notebook', + toolCallId: 'call-1', + state: 'output-available', + input: {}, + ...overrides, + } as ToolUIPart +} + +describe('prepareMessagesForModel', () => { + it('filters out a plain output-error tool part', () => { + const messages = [ + assistantMessage([toolPart({ state: 'output-error', errorText: 'Network error' })]), + ] + + const result = prepareMessagesForModel(messages, 'schema') + + expect(result[0].parts).toEqual([]) + }) + + it('keeps a stale-update_notebook conflict and rewrites errorText to the plain message', () => { + const error = new NotebookToolError('Notebook changed since expected_updated_at', { + exposeToAssistant: true, + }) + const messages = [ + assistantMessage([ + toolPart({ state: 'output-error', errorText: encodeNotebookToolError(error)! }), + ]), + ] + + const result = prepareMessagesForModel(messages, 'schema') + + expect(result[0].parts).toEqual([ + toolPart({ state: 'output-error', errorText: 'Notebook changed since expected_updated_at' }), + ]) + }) + + it('still filters out an unrelated update_notebook output-error', () => { + const messages = [ + assistantMessage([ + toolPart({ state: 'output-error', errorText: 'Unexpected upstream failure' }), + ]), + ] + + const result = prepareMessagesForModel(messages, 'schema') + + expect(result[0].parts).toEqual([]) + }) + + it('still filters out JSON-shaped output-error text lacking the notebook_tool_error tag', () => { + const messages = [ + assistantMessage([ + toolPart({ + state: 'output-error', + errorText: JSON.stringify({ + exposeToAssistant: true, + message: 'not a real notebook error', + }), + }), + ]), + ] + + const result = prepareMessagesForModel(messages, 'schema') + + expect(result[0].parts).toEqual([]) + }) + + it('exposes any tool part carrying a validly-tagged NotebookToolError, not just update_notebook', () => { + const error = new NotebookToolError('Notebook changed since expected_updated_at', { + exposeToAssistant: true, + }) + const messages = [ + assistantMessage([ + toolPart({ + type: 'tool-execute_sql', + state: 'output-error', + errorText: encodeNotebookToolError(error)!, + }), + ]), + ] + + const result = prepareMessagesForModel(messages, 'schema') + + expect(result[0].parts).toEqual([ + toolPart({ + type: 'tool-execute_sql', + state: 'output-error', + errorText: 'Notebook changed since expected_updated_at', + }), + ]) + }) + + it('still filters out input-streaming, input-available, and approval-requested parts', () => { + const messages = [ + assistantMessage([ + toolPart({ state: 'input-streaming' }), + toolPart({ state: 'input-available' }), + toolPart({ state: 'approval-requested', approval: { id: 'a1' } } as Partial), + ]), + ] + + const result = prepareMessagesForModel(messages, 'schema') + + expect(result[0].parts).toEqual([]) + }) + + it('keeps non-tool parts untouched', () => { + const messages = [assistantMessage([{ type: 'text', text: 'hello' }])] + + const result = prepareMessagesForModel(messages, 'schema') + + expect(result[0].parts).toEqual([{ type: 'text', text: 'hello' }]) + }) +}) diff --git a/apps/studio/lib/ai/generate-assistant-response.utils.ts b/apps/studio/lib/ai/generate-assistant-response.utils.ts new file mode 100644 index 0000000000000..3d2851656ec61 --- /dev/null +++ b/apps/studio/lib/ai/generate-assistant-response.utils.ts @@ -0,0 +1,50 @@ +import { isToolUIPart, type DynamicToolUIPart, type ToolUIPart, type UIMessage } from 'ai' + +import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' +import { decodeNotebookToolError } from '@/lib/ai/tools/notebook-tools' +import { sanitizeMessagePart } from '@/lib/ai/tools/tool-sanitizer' + +const INVALID_TOOL_STATES = [ + 'input-streaming', + 'input-available', + 'approval-requested', + 'output-error', +] + +/** + * Notebook tool errors are opted into model visibility explicitly (NotebookToolError, + * encoded in generate-v4.ts's onError). A successful parse against the schema — including + * its literal `tag` — is proof enough that this was a NotebookToolError; every other + * output-error stays hidden, same as before. Rewrites errorText back to the plain message + * so the model sees prose, not JSON. + */ +function exposedNotebookErrorPart( + part: ToolUIPart | DynamicToolUIPart +): ToolUIPart | DynamicToolUIPart | null { + if (part.state !== 'output-error') return null + const decoded = decodeNotebookToolError(part.errorText) + if (!decoded?.exposeToAssistant) return null + return { ...part, errorText: decoded.message } +} + +/** Trims history to the last 7 messages and strips tool parts the model shouldn't see. */ +export function prepareMessagesForModel(rawMessages: UIMessage[], aiOptInLevel: AiOptInLevel) { + return (rawMessages || []).slice(-7).map((msg) => { + if (msg && msg.role === 'assistant' && 'results' in msg) { + const cleanedMsg = { ...msg } + delete cleanedMsg.results + return cleanedMsg + } + if (msg && msg.role === 'assistant' && msg.parts) { + const cleanedParts = msg.parts.flatMap((part) => { + if (!isToolUIPart(part)) return [part] + if (!INVALID_TOOL_STATES.includes(part.state)) + return [sanitizeMessagePart(part, aiOptInLevel)] + const exposed = exposedNotebookErrorPart(part) + return exposed ? [sanitizeMessagePart(exposed, aiOptInLevel)] : [] + }) + return { ...msg, parts: cleanedParts } + } + return msg + }) +} diff --git a/apps/studio/lib/ai/tools/notebook-tools.test.ts b/apps/studio/lib/ai/tools/notebook-tools.test.ts index 2650d326ca044..fb692afbf20b6 100644 --- a/apps/studio/lib/ai/tools/notebook-tools.test.ts +++ b/apps/studio/lib/ai/tools/notebook-tools.test.ts @@ -2,7 +2,12 @@ import { components } from 'api-types' import { HttpResponse } from 'msw' import { describe, expect, it } from 'vitest' -import { getNotebookTools } from './notebook-tools' +import { + decodeNotebookToolError, + encodeNotebookToolError, + getNotebookTools, + NotebookToolError, +} from './notebook-tools' import type { AgentNotebook } from '@/data/content/notebooks/notebook-schema' import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw' @@ -432,40 +437,80 @@ describe('ai/tools/notebook-tools', () => { expect(result).toEqual({ id: 'notebook-1', name: 'Signup funnel' }) }) - it('should throw a descriptive error instead of PUTting when an operation targets an unknown cell id', async () => { + it('should throw a descriptive, assistant-exposable error instead of PUTting when an operation targets an unknown cell id', async () => { mockGetNotebook() const tools = getNotebookTools({ projectRef: 'test-project' }) if (!tools.update_notebook.execute) throw new Error('execute is undefined') - await expect( - tools.update_notebook.execute( - { - id: 'notebook-1', - expected_updated_at: '2026-01-01T00:00:00.000Z', - operations: [{ _tag: 'delete_cell', cell_id: 'missing-cell' }], - }, - { toolCallId: 'test', messages: [], context: {} } - ) - ).rejects.toThrow('No cell with id "missing-cell"') + const execute = tools.update_notebook.execute( + { + id: 'notebook-1', + expected_updated_at: '2026-01-01T00:00:00.000Z', + operations: [{ _tag: 'delete_cell', cell_id: 'missing-cell' }], + }, + { toolCallId: 'test', messages: [], context: {} } + ) + + await expect(execute).rejects.toThrow('No cell with id "missing-cell"') + await expect(execute).rejects.toBeInstanceOf(NotebookToolError) + await expect(execute).rejects.toMatchObject({ metadata: { exposeToAssistant: true } }) }) - it('should throw instead of PUTting when the notebook changed since expected_updated_at', async () => { + it('should throw an assistant-exposable error instead of PUTting when the notebook changed since expected_updated_at', async () => { mockGetNotebook() const tools = getNotebookTools({ projectRef: 'test-project' }) if (!tools.update_notebook.execute) throw new Error('execute is undefined') - await expect( - tools.update_notebook.execute( - { - id: 'notebook-1', - expected_updated_at: '2025-12-31T00:00:00.000Z', - operations: [{ _tag: 'delete_cell', cell_id: 'cell-3' }], - }, - { toolCallId: 'test', messages: [], context: {} } - ) - ).rejects.toThrow(/changed since expected_updated_at/) + const execute = tools.update_notebook.execute( + { + id: 'notebook-1', + expected_updated_at: '2025-12-31T00:00:00.000Z', + operations: [{ _tag: 'delete_cell', cell_id: 'cell-3' }], + }, + { toolCallId: 'test', messages: [], context: {} } + ) + + await expect(execute).rejects.toThrow(/changed since expected_updated_at/) + await expect(execute).rejects.toBeInstanceOf(NotebookToolError) + await expect(execute).rejects.toMatchObject({ metadata: { exposeToAssistant: true } }) + }) + }) + + describe('encodeNotebookToolError / decodeNotebookToolError', () => { + it('round-trips a NotebookToolError through JSON', () => { + const error = new NotebookToolError('Notebook changed since expected_updated_at', { + exposeToAssistant: true, + }) + + const encoded = encodeNotebookToolError(error) + expect(encoded).not.toBeNull() + + const decoded = decodeNotebookToolError(encoded!) + expect(decoded).toEqual({ + exposeToAssistant: true, + tag: 'notebook_tool_error', + message: 'Notebook changed since expected_updated_at', + }) + }) + + it('does not encode a plain Error', () => { + expect(encodeNotebookToolError(new Error('boom'))).toBeNull() + }) + + it('does not decode a plain error message', () => { + expect(decodeNotebookToolError('boom')).toBeNull() + }) + + it('does not decode unrelated JSON', () => { + expect(decodeNotebookToolError(JSON.stringify({ foo: 'bar' }))).toBeNull() + }) + + it('does not decode JSON that merely looks like a NotebookToolError but lacks the tag', () => { + expect( + decodeNotebookToolError(JSON.stringify({ exposeToAssistant: true, message: 'boom' })) + ).toBeNull() }) }) }) diff --git a/apps/studio/lib/ai/tools/notebook-tools.ts b/apps/studio/lib/ai/tools/notebook-tools.ts index 1ec84bdb88dae..2c4498d459e85 100644 --- a/apps/studio/lib/ai/tools/notebook-tools.ts +++ b/apps/studio/lib/ai/tools/notebook-tools.ts @@ -24,6 +24,54 @@ export type NotebookToolsContext = { authorization?: string } +const notebookToolErrorMetadataSchema = z.object({ + exposeToAssistant: z.boolean(), +}) + +export type NotebookToolErrorMetadata = z.infer + +const notebookToolErrorSchema = notebookToolErrorMetadataSchema.extend({ + tag: z.literal('notebook_tool_error'), + message: z.string(), +}) + +export type EncodedNotebookToolError = z.infer + +/** Thrown by update_notebook for failures the assistant can act on by retrying. */ +export class NotebookToolError extends Error { + readonly metadata: NotebookToolErrorMetadata + + constructor(message: string, metadata: NotebookToolErrorMetadata) { + super(message) + this.name = 'NotebookToolError' + this.metadata = notebookToolErrorMetadataSchema.parse(metadata) + } +} + +/** Called from the stream's `onError` (generate-v4.ts), which still has the live Error. */ +export function encodeNotebookToolError(error: unknown): string | null { + if (!(error instanceof NotebookToolError)) return null + return JSON.stringify( + notebookToolErrorSchema.parse({ + ...error.metadata, + tag: 'notebook_tool_error', + message: error.message, + }) + ) +} + +/** Called from the history filter (generate-assistant-response.ts) on a persisted errorText. */ +export function decodeNotebookToolError(errorText: string): EncodedNotebookToolError | null { + let parsed: unknown + try { + parsed = JSON.parse(errorText) + } catch { + return null + } + const result = notebookToolErrorSchema.safeParse(parsed) + return result.success ? result.data : null +} + export const getNotebookTools = (ctx: NotebookToolsContext = {}) => { const { projectRef, authorization } = ctx const authHeaders = authorization ? { Authorization: authorization } : undefined @@ -153,8 +201,9 @@ export const getNotebookTools = (ctx: NotebookToolsContext = {}) => { const notebook = await getNotebook({ projectRef, id }, undefined, authHeaders) if (notebook.updated_at !== expected_updated_at) { - throw new Error( - `Notebook "${id}" changed since expected_updated_at (${expected_updated_at}); it is now ${notebook.updated_at}. Call get_notebook again and reissue update_notebook against the current content.` + throw new NotebookToolError( + `Notebook "${id}" changed since expected_updated_at (${expected_updated_at}); it is now ${notebook.updated_at}. Call get_notebook again and reissue update_notebook against the current content.`, + { exposeToAssistant: true } ) } @@ -165,7 +214,9 @@ export const getNotebookTools = (ctx: NotebookToolsContext = {}) => { const result = applyNotebookOperations(wireNotebook, operations) if (!result.success) { - throw new Error(describeNotebookOperationError(result.error)) + throw new NotebookToolError(describeNotebookOperationError(result.error), { + exposeToAssistant: true, + }) } // Same promotion as create_notebook above, inlined here for the same auditability diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts index d8900ab88db77..1b056af7c6440 100644 --- a/apps/studio/pages/api/ai/sql/generate-v4.ts +++ b/apps/studio/pages/api/ai/sql/generate-v4.ts @@ -26,6 +26,7 @@ import { type AssistantModelId, } from '@/lib/ai/model.utils' import { getTools } from '@/lib/ai/tools' +import { encodeNotebookToolError } from '@/lib/ai/tools/notebook-tools' import { apiWrapper } from '@/lib/api/apiWrapper' import { executeQuery } from '@/lib/api/self-hosted/query' import { getURL } from '@/lib/helpers' @@ -250,6 +251,9 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw onError: (error) => { console.error('Assistant stream error:', error) + const encoded = encodeNotebookToolError(error) + if (encoded !== null) return encoded + if (error == null) { return 'unknown error' } From 717927f4f296421b8529fed3f7eeb1212006bf8f Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:26:12 -0400 Subject: [PATCH 6/6] fix(studio): AI assistant notebooks no longer set an invalid database_identifier (#49326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - The AI assistant's `create_notebook`/`update_notebook` tools could set a `database_cell`'s `database_identifier` to a value that doesn't correspond to any real database, because no tool exposes a project's actual read-replica identifiers to the model. - An unresolvable `database_identifier` silently breaks the cell: `QueryEditor`'s connection-string lookup fails to find a match, and running the cell fails with `Unable to run query: Connection string is missing` — even though the exact same SQL runs fine when pasted into a manually-created cell (which never sets this field). - Fix: strip `database_identifier` from the agent-facing schema (`agentCellSchema` in `notebook-schema.ts`) entirely, so the model can no longer emit it at all. **This is a temporary fix** until we wire in real read-replica support for the AI assistant (e.g. a tool exposing a project's valid replica identifiers) — the field can be reintroduced once the model has a legitimate source of truth to pull a valid identifier from. - Updated tests that relied on agent cells carrying `database_identifier` to reflect the new behavior, and added a regression test asserting `agentNotebookSchema` rejects a `database_cell` with that field set. Resolves FE-4224 ## Test plan - [x] `notebook-schema.test.ts`, `notebook-operations.test.ts`, `notebook-tools.test.ts`, `AssistantNotebookPreview.test.tsx`, `AssistantNotebookPreview.utils.test.ts` all pass - [x] `tsc --noEmit` clean - [x] Prettier clean on touched files ## Summary by CodeRabbit * **Bug Fixes** * Improved handling of database notebook cells when database metadata is unavailable. * Cells without database identifiers now display “No metadata” instead of an incorrect replica identifier. * Prevented invalid database identifiers from being accepted in agent-generated notebook content. --- .../AssistantNotebookPreview.test.tsx | 9 ++++----- .../AssistantNotebookPreview.utils.test.ts | 15 +++++++------- .../AssistantNotebookPreview.utils.ts | 7 +++++-- .../content/notebooks/notebook-schema.test.ts | 20 +++++++++++++++++++ .../data/content/notebooks/notebook-schema.ts | 9 ++++++++- 5 files changed, 44 insertions(+), 16 deletions(-) diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.test.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.test.tsx index b796ea2e5e0c9..6f01d839212a1 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.test.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.test.tsx @@ -23,11 +23,10 @@ const wireDatabaseCell = (id: string, database_identifier?: string): CellWire => database_identifier, }) -const agentDatabaseCell = (database_identifier?: string): AgentCell => ({ +const agentDatabaseCell = (): AgentCell => ({ _tag: 'database_cell', sql: 'select 1', row_limit: 100, - database_identifier, }) describe('AssistantNotebookPreview', () => { @@ -68,17 +67,17 @@ describe('AssistantNotebookPreview', () => { { _tag: 'replaced', before: wireDatabaseCell('cell-1', 'primary'), - after: agentDatabaseCell('replica-3'), + after: agentDatabaseCell(), operationIndex: 0, }, ] render() - expect(screen.getByText('Database: primary → Database: replica-3')).toBeInTheDocument() + expect(screen.getByText('Database: primary → No metadata')).toBeInTheDocument() expect( screen.getByRole('button', { name: 'Replaced Query: Untitled query' }) - ).toHaveTextContent('Database: primary → Database: replica-3') + ).toHaveTextContent('Database: primary → No metadata') }) it('hides entries past the limit behind a "Show N more" button', async () => { diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts index 9a7536d73bf9a..2d4f52218e878 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts @@ -38,11 +38,10 @@ const wireLogCell = (id: string): CellWire => ({ time_range: { _tag: 'relative_time_range', unit: 'day', amount: 7 }, }) -const agentDatabaseCell = (database_identifier?: string): AgentCell => ({ +const agentDatabaseCell = (): AgentCell => ({ _tag: 'database_cell', sql: 'select 1', row_limit: 100, - database_identifier, }) describe('getEntryKey', () => { @@ -146,26 +145,26 @@ describe('getEntryMetadataLine', () => { ).toBe('Database: replica-3') }) - it('returns a before → after pair when a replacement changes only metadata', () => { + it('returns a before → after pair when a replacement drops the database metadata', () => { expect( getEntryMetadataLine({ _tag: 'replaced', before: wireDatabaseCell('cell-1', 'Signups', 'primary'), - after: agentDatabaseCell('replica-3'), + after: agentDatabaseCell(), operationIndex: 0, }) - ).toBe('Database: primary → Database: replica-3') + ).toBe('Database: primary → No metadata') }) it('returns a single line when replacement metadata is unchanged', () => { expect( getEntryMetadataLine({ _tag: 'replaced', - before: wireDatabaseCell('cell-1', 'Signups', 'primary'), - after: agentDatabaseCell('primary'), + before: wireDatabaseCell('cell-1', undefined, undefined), + after: agentDatabaseCell(), operationIndex: 0, }) - ).toBe('Database: primary') + ).toBe(null) }) }) diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts index b8aefa9bd82f8..72d5e2988252f 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts @@ -83,8 +83,11 @@ export function getCellMetadataLine(cell: OperationResultCell): string | null { switch (cell._tag) { case 'markdown_cell': return null - case 'database_cell': - return cell.database_identifier ? `Database: ${cell.database_identifier}` : null + case 'database_cell': { + const databaseIdentifier = + 'database_identifier' in cell ? cell.database_identifier : undefined + return databaseIdentifier ? `Database: ${databaseIdentifier}` : null + } case 'log_cell': return `Time range: ${formatTimeRange(cell.time_range)}` } diff --git a/apps/studio/data/content/notebooks/notebook-schema.test.ts b/apps/studio/data/content/notebooks/notebook-schema.test.ts index a459382cbe0b4..244d795c45cd4 100644 --- a/apps/studio/data/content/notebooks/notebook-schema.test.ts +++ b/apps/studio/data/content/notebooks/notebook-schema.test.ts @@ -260,6 +260,26 @@ describe('agentNotebookSchema', () => { expect(result.success).toBe(false) }) + + it('rejects a database_cell that carries a database_identifier', () => { + // Agents have no way to discover a project's real read-replica identifiers, so an + // invented one silently breaks the cell (its connection string never resolves) — the + // field is stripped from the agent-facing schema entirely rather than left for a model + // to guess at. + const result = agentNotebookSchema.safeParse({ + schema_version: 1, + cells: [ + { + _tag: 'database_cell', + sql: 'select 1', + row_limit: 100, + database_identifier: 'replica-1', + }, + ], + }) + + expect(result.success).toBe(false) + }) }) describe('writableNotebookSchema', () => { diff --git a/apps/studio/data/content/notebooks/notebook-schema.ts b/apps/studio/data/content/notebooks/notebook-schema.ts index 7d4b058ea76a5..8ac77ee239b7e 100644 --- a/apps/studio/data/content/notebooks/notebook-schema.ts +++ b/apps/studio/data/content/notebooks/notebook-schema.ts @@ -154,11 +154,18 @@ export type WritableNotebook = Omit, 'cel type WritableCellWire = z.infer type WritableNotebookWire = z.infer +// Agents cannot yet target a specific read replica: there's no tool exposing a project's +// real replica identifiers, so a model asked to fill this field has no legitimate value to +// put there — and an invented one silently breaks the cell, since its connection string can +// never resolve (see QueryEditor's run handler). Omit the field entirely until replica +// selection is actually wired up for agents, rather than leave it for a model to guess at. +const agentDatabaseFieldsSchema = databaseFieldsSchema.omit({ database_identifier: true }) + // Agents have restrictions on writing IDs to preserve guarantees about ID // uniqueness export const agentCellSchema = z.discriminatedUnion('_tag', [ markdownFieldsSchema.extend({ _tag: z.literal('markdown_cell') }).strict(), - databaseFieldsSchema.extend({ _tag: z.literal('database_cell') }).strict(), + agentDatabaseFieldsSchema.extend({ _tag: z.literal('database_cell') }).strict(), logFieldsSchema.extend({ _tag: z.literal('log_cell') }).strict(), ])