From d93defe1e02031e599cd74a3c4c7fb6ee1cc3001 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 21 Aug 2026 16:27:51 +1000 Subject: [PATCH 01/11] feat(studio): refine notebook query cell layout (#49350) image ## 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? Studio UI improvement. ## What is the current behavior? Explorer notebook query cells can extend beyond the intended reading width, and saved notebooks open with SQL code expanded. ## What is the new behavior? - Caps Explorer notebook query cells at `max-w-6xl`. - Hides SQL code by default in saved notebooks. - Keeps SQL visible by default for new notebooks. ## To test 1. Open a saved Explorer notebook with query cells. Confirm each cell is capped at the wider notebook width and its SQL editor is initially collapsed. 2. Expand a saved query cell and confirm the existing SQL and result remain available. 3. Create a new notebook, add a query cell, and confirm its SQL editor is initially visible. ## Summary by CodeRabbit * **New Features** * Added controls to show or hide SQL for individual query cells. * Query visibility is preserved when switching notebook tabs or reopening them. * New notebooks display SQL by default, while saved notebooks can hide SQL editors. * Expanded the query editor width for improved readability. * **Bug Fixes** * Prevented visibility settings from affecting notebook save status or unrelated cells. --- .../interfaces/Explorer/ExplorerQueryTab.tsx | 4 ++ .../interfaces/Explorer/QueryCell/index.tsx | 9 +++- .../interfaces/Explorer/QueryEditor/index.tsx | 9 ++-- .../__tests__/ExplorerNotebookTab.test.tsx | 48 +++++++++++++++---- .../AIAssistantPanel/AssistantQueryCell.tsx | 4 ++ .../state/notebooks/notebooks-state.test.ts | 26 ++++++++++ .../studio/state/notebooks/notebooks-state.ts | 21 +++++++- 7 files changed, 106 insertions(+), 15 deletions(-) diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx index 69b05ad8388fe..6d8c727b67411 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx @@ -19,6 +19,7 @@ export const ExplorerQueryTab = () => { const querySnap = useExplorerQueryStateSnapshot() const [restoredQueryKey, setRestoredQueryKey] = useState() + const [showQuery, setShowQuery] = useState(true) const stateDraft = id ? querySnap.drafts[id] : undefined const draft = stateDraft?.projectRef === ref ? stateDraft : undefined @@ -38,6 +39,7 @@ export const ExplorerQueryTab = () => { useEffect(() => { if (!id || !ref) return + setShowQuery(true) const restored = explorerQueryState.restoreDraft({ id, projectRef: ref }) const restoredDraft = explorerQueryState.drafts[id] if (restored && restoredDraft) { @@ -102,6 +104,8 @@ export const ExplorerQueryTab = () => { title={draft.name} query={query} result={result} + showQuery={showQuery} + onShowQueryChange={setShowQuery} roleImpersonationState={roleImpersonationState} onTitleChange={(value) => { const name = value.trim() || 'Untitled query' diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx b/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx index 5293f0f3ecc3b..8692f526bf227 100644 --- a/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx @@ -40,6 +40,8 @@ export const QueryCell = forwardRef(function const roleImpersonationState = useLocalRoleImpersonationState() const title = cell.title ?? 'Untitled query' + const showQuery = + snap.cellLocalState.get(cell._id)?.showQuery ?? currentNotebook?.status === 'new' /** * Applies an update to this cell. The updater runs against the cell as the store holds @@ -54,7 +56,10 @@ export const QueryCell = forwardRef(function snap.updateCell({ id: notebookId, cellId: cell._id, - updater: (candidate) => (isQueryCell(candidate) ? updater(candidate) : candidate), + updater: (candidate) => { + if (!isQueryCell(candidate)) return candidate + return updater(candidate) + }, }) } @@ -101,6 +106,8 @@ export const QueryCell = forwardRef(function title={title} query={toQueryModel(cell, sql)} result={result} + showQuery={showQuery} + onShowQueryChange={(showQuery) => snap.setQueryVisibility({ cellId: cell._id, showQuery })} roleImpersonationState={roleImpersonationState} display={getCellDisplay(cell)} onTitleChange={handleTitleChange} diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx index 15fd6e59fc622..8285eb878f02c 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx @@ -109,6 +109,8 @@ type QueryEditorProps = { display?: QueryDisplay toolbarActions?: ReactNode className?: string + showQuery: boolean + onShowQueryChange: (showQuery: boolean) => void /** When true, toolbar and editor run actions are disabled. */ isRunDisabled?: boolean onTitleChange: (title: string) => void @@ -137,6 +139,8 @@ export const QueryEditor = forwardRef(funct display, toolbarActions, className, + showQuery, + onShowQueryChange, isRunDisabled = false, onTitleChange, onSqlChange, @@ -162,7 +166,6 @@ export const QueryEditor = forwardRef(funct const rowLimit = query._tag === 'database' ? query.rowLimit : undefined const databaseIdentifier = query._tag === 'database' ? query.database_identifier : undefined - const [showQuery, setShowQuery] = useState(true) const [promptInput, setPromptInput] = useState('') const [pendingProposal, setPendingProposal] = useState(null) const pendingProposalRef = useLatest(pendingProposal) @@ -307,7 +310,7 @@ export const QueryEditor = forwardRef(funct }, [promptState?.isOpen]) return ( - + @@ -341,7 +344,7 @@ export const QueryEditor = forwardRef(funct icon={showQuery ? : } disabled={pendingProposal !== null} tooltip={showQuery ? 'Hide query' : 'Show query'} - onClick={() => setShowQuery((value) => !value)} + onClick={() => onShowQueryChange(!showQuery)} /> { +/** Clears any prior fixture so seeding exercises the same store-loading path as the app. */ +const seedNotebook = (cells: Notebooks.Cell[], status: 'new' | 'saved' = 'saved') => { + delete notebooksState.notebooks[NOTEBOOK_ID] const notebook: Notebook = { id: NOTEBOOK_ID, type: 'notebook', @@ -68,8 +65,8 @@ const seedNotebook = (cells: Notebooks.Cell[]) => { project_id: 1, content: { schema_version: 1, cells }, } - const stateNotebook: StateNotebook = { projectRef: 'default', notebook, status: 'saved' } - notebooksState.notebooks[NOTEBOOK_ID] = stateNotebook + if (status === 'new') notebooksState.addNotebook({ projectRef: 'default', notebook }) + else notebooksState.setNotebook({ projectRef: 'default', notebook }) } const renderNotebookTab = () => @@ -90,10 +87,43 @@ beforeEach(() => { afterEach(() => { notebooksState.needsSaving.clear() + notebooksState.cellLocalState.clear() safeLocalStorage.removeItem(LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE) }) describe('ExplorerNotebookTab', () => { + it('hides SQL by default for saved notebooks and caps query cells at 6xl', () => { + renderNotebookTab() + + const queryCells = Array.from(document.querySelectorAll('[data-slot="explorer-query"]')) + expect(queryCells).toHaveLength(2) + queryCells.forEach((cell) => expect(cell).toHaveClass('max-w-6xl')) + + expect(screen.queryByRole('textbox', { name: 'SQL editor' })).not.toBeInTheDocument() + }) + + it('shows SQL by default for a new notebook', async () => { + seedNotebook([databaseCell, logCell, markdownCell], 'new') + + renderNotebookTab() + + expect(await screen.findAllByRole('textbox', { name: 'SQL editor' })).toHaveLength(2) + }) + + it('keeps query visibility when the notebook tab remounts', async () => { + const view = renderNotebookTab() + + const showQueryButton = document.querySelector('.lucide-eye')?.closest('button') + expect(showQueryButton).toBeInstanceOf(HTMLButtonElement) + await userEvent.click(showQueryButton as HTMLButtonElement) + expect(await screen.findAllByRole('textbox', { name: 'SQL editor' })).toHaveLength(1) + + view.unmount() + renderNotebookTab() + + expect(await screen.findAllByRole('textbox', { name: 'SQL editor' })).toHaveLength(1) + }) + 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` diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx index 10df1e3a58e12..6036059820a95 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx @@ -62,6 +62,7 @@ export const AssistantQueryCell = ({ const [title, setTitle] = useState(fallbackTitle) const [query, setQuery] = useState(() => createAssistantQueryModel(initialSql, source)) + const [showQuery, setShowQuery] = useState(true) // undefined uses the tool output; null intentionally clears it after changing source. const [resultOverride, setResultOverride] = useState() const [localDisplay, setLocalDisplay] = useState(undefined) @@ -71,6 +72,7 @@ export const AssistantQueryCell = ({ previousId.current = id setTitle(fallbackTitle) setQuery(createAssistantQueryModel(initialSql, source)) + setShowQuery(true) setResultOverride(undefined) setLocalDisplay(undefined) } @@ -140,6 +142,8 @@ export const AssistantQueryCell = ({ title={title} query={query} result={result} + showQuery={showQuery} + onShowQueryChange={setShowQuery} roleImpersonationState={roleImpersonationState} display={display} isRunDisabled={isConfirming} diff --git a/apps/studio/state/notebooks/notebooks-state.test.ts b/apps/studio/state/notebooks/notebooks-state.test.ts index 7619736810f47..388c12172faa6 100644 --- a/apps/studio/state/notebooks/notebooks-state.test.ts +++ b/apps/studio/state/notebooks/notebooks-state.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { notebooksState } from './notebooks-state' import type { Notebook } from './types' +import type { Notebooks } from '@/types' function makeNotebook(id: string, overrides: Partial = {}): Notebook { return { @@ -25,6 +26,7 @@ describe('notebooksState', () => { delete notebooksState.notebooks[id] } notebooksState.needsSaving.clear() + notebooksState.cellLocalState.clear() }) it('addNotebook marks a locally-created notebook as new', () => { @@ -39,6 +41,30 @@ describe('notebooksState', () => { expect(notebooksState.notebooks['notebook-1'].status).toBe('saved') }) + it('keeps query visibility in session state without marking the notebook as edited', () => { + const queryCell = { + _tag: 'database_cell' as const, + _id: 'cell-1', + unchecked_sql: '' as Notebooks.DatabaseCell['unchecked_sql'], + row_limit: 100, + view: 'table' as const, + } + notebooksState.setNotebook({ + projectRef: 'ref', + notebook: makeNotebook('notebook-1', { + content: { schema_version: 1, cells: [queryCell] }, + }), + }) + + expect(notebooksState.cellLocalState.has('cell-1')).toBe(false) + + notebooksState.setQueryVisibility({ cellId: 'cell-1', showQuery: true }) + + expect(notebooksState.cellLocalState.get('cell-1')).toEqual({ showQuery: true }) + expect(notebooksState.notebooks['notebook-1'].status).toBe('saved') + expect(notebooksState.needsSaving.has('notebook-1')).toBe(false) + }) + it('editing a loaded (saved) notebook transitions it to unsaved and queues it for saving', () => { notebooksState.setNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') }) diff --git a/apps/studio/state/notebooks/notebooks-state.ts b/apps/studio/state/notebooks/notebooks-state.ts index 8591de569a4e5..48534a5ae4e5c 100644 --- a/apps/studio/state/notebooks/notebooks-state.ts +++ b/apps/studio/state/notebooks/notebooks-state.ts @@ -6,6 +6,7 @@ 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' @@ -13,9 +14,15 @@ function statusOnEdit(status: SnippetStatus): SnippetStatus { return status === 'saved' ? 'unsaved' : status } +type NotebookCellLocalState = { + showQuery?: boolean +} + export const notebooksState = proxy({ notebooks: {} as Record, needsSaving: proxyMap([]), + /** Session-only UI state keyed by cell ID; never persisted with notebook content. */ + cellLocalState: proxyMap([]), /** * Load notebook into the Valtio store. No-ops if already present. @@ -58,11 +65,13 @@ export const notebooksState = proxy({ /** * Remove notebook from the store, and optionally remove it from the sync - * saving queue. Also clears any cached query-cell results for this notebook - * from the ephemeral session store. + * 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) }, @@ -113,6 +122,7 @@ export const notebooksState = proxy({ 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 }) }, @@ -141,6 +151,12 @@ export const notebooksState = proxy({ 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. */ @@ -149,6 +165,7 @@ export const notebooksState = proxy({ 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 }) }, From 1d47a3190b7b400b0c9a549e0b566a79e464af4c Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 21 Aug 2026 14:57:07 +0800 Subject: [PATCH 02/11] Debounce notebook search (#49368) ## Context Sets up debouncing for notebooks search in the explorer so that we're not hammering the API when searching image ## Summary by CodeRabbit * **Bug Fixes** * Improved notebook search responsiveness by delaying searches until 500 ms after typing stops. * Empty searches now update immediately. --- .../ExplorerLayout/ExplorerNavNotebooks.tsx | 5 +- apps/studio/routeTree.gen.ts | 52 +++++++++---------- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavNotebooks.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavNotebooks.tsx index 65a379cfaacbc..b921210796107 100644 --- a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavNotebooks.tsx +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavNotebooks.tsx @@ -1,3 +1,4 @@ +import { useDebounce } from '@uidotdev/usehooks' import { useParams } from 'common' import { NotebookText } from 'lucide-react' import Link from 'next/link' @@ -47,7 +48,9 @@ const NotebookListItem = ({ export const ExplorerNavNotebooks = ({ onBack }: { onBack: () => void }) => { const router = useRouter() const { ref, id } = useParams() + const [search, setSearch] = useState('') + const debouncedSearch = useDebounce(search, 500) const { data: notebooksData, @@ -58,7 +61,7 @@ export const ExplorerNavNotebooks = ({ onBack }: { onBack: () => void }) => { } = useNotebooksInfiniteQuery({ projectRef: ref, limit: 100, - name: search, + name: search.length === 0 ? search : debouncedSearch, }) const notebooks = useMemo(() => { diff --git a/apps/studio/routeTree.gen.ts b/apps/studio/routeTree.gen.ts index 244dfef9eae31..c49f7edcbd936 100644 --- a/apps/studio/routeTree.gen.ts +++ b/apps/studio/routeTree.gen.ts @@ -106,7 +106,6 @@ import { Route as ProjectRefSqlExamplesRouteImport } from './routes/project/$ref import { Route as ProjectRefSqlIdRouteImport } from './routes/project/$ref/sql/$id' import { Route as ProjectRefSettingsLogDrainsRouteImport } from './routes/project/$ref/settings/log-drains' import { Route as ProjectRefSettingsIntegrationsRouteImport } from './routes/project/$ref/settings/integrations' -import { Route as ProjectRefSettingsInfrastructureIndexRouteImport } from './routes/project/$ref/settings/infrastructure/index' import { Route as ProjectRefSettingsGeneralRouteImport } from './routes/project/$ref/settings/general' import { Route as ProjectRefSettingsDashboardRouteImport } from './routes/project/$ref/settings/dashboard' import { Route as ProjectRefSettingsApiKeysRouteImport } from './routes/project/$ref/settings/api-keys' @@ -206,6 +205,7 @@ import { Route as ProjectRefStorageFilesIndexRouteImport } from './routes/projec import { Route as ProjectRefStorageAnalyticsIndexRouteImport } from './routes/project/$ref/storage/analytics/index' import { Route as ProjectRefSettingsWebhooksIndexRouteImport } from './routes/project/$ref/settings/webhooks/index' import { Route as ProjectRefSettingsJwtIndexRouteImport } from './routes/project/$ref/settings/jwt/index' +import { Route as ProjectRefSettingsInfrastructureIndexRouteImport } from './routes/project/$ref/settings/infrastructure/index' import { Route as ProjectRefSettingsApiKeysIndexRouteImport } from './routes/project/$ref/settings/api-keys/index' import { Route as ProjectRefLogsExplorerIndexRouteImport } from './routes/project/$ref/logs/explorer/index' import { Route as ProjectRefIntegrationsIdIndexRouteImport } from './routes/project/$ref/integrations/$id/index' @@ -824,12 +824,6 @@ const ProjectRefSettingsIntegrationsRoute = path: '/integrations', getParentRoute: () => ProjectRefSettingsRoute, } as any) -const ProjectRefSettingsInfrastructureIndexRoute = - ProjectRefSettingsInfrastructureIndexRouteImport.update({ - id: '/infrastructure/', - path: '/infrastructure/', - getParentRoute: () => ProjectRefSettingsRoute, - } as any) const ProjectRefSettingsGeneralRoute = ProjectRefSettingsGeneralRouteImport.update({ id: '/general', @@ -1382,6 +1376,12 @@ const ProjectRefSettingsJwtIndexRoute = path: '/jwt/', getParentRoute: () => ProjectRefSettingsRoute, } as any) +const ProjectRefSettingsInfrastructureIndexRoute = + ProjectRefSettingsInfrastructureIndexRouteImport.update({ + id: '/infrastructure/', + path: '/infrastructure/', + getParentRoute: () => ProjectRefSettingsRoute, + } as any) const ProjectRefSettingsApiKeysIndexRoute = ProjectRefSettingsApiKeysIndexRouteImport.update({ id: '/', @@ -2256,7 +2256,6 @@ export interface FileRoutesByFullPath { '/project/$ref/settings/api-keys': typeof ProjectRefSettingsApiKeysRouteWithChildren '/project/$ref/settings/dashboard': typeof ProjectRefSettingsDashboardRoute '/project/$ref/settings/general': typeof ProjectRefSettingsGeneralRoute - '/project/$ref/settings/infrastructure/': typeof ProjectRefSettingsInfrastructureIndexRoute '/project/$ref/settings/integrations': typeof ProjectRefSettingsIntegrationsRoute '/project/$ref/settings/log-drains': typeof ProjectRefSettingsLogDrainsRoute '/project/$ref/sql/$id': typeof ProjectRefSqlIdRoute @@ -2345,6 +2344,7 @@ export interface FileRoutesByFullPath { '/project/$ref/integrations/$id/': typeof ProjectRefIntegrationsIdIndexRoute '/project/$ref/logs/explorer/': typeof ProjectRefLogsExplorerIndexRoute '/project/$ref/settings/api-keys/': typeof ProjectRefSettingsApiKeysIndexRoute + '/project/$ref/settings/infrastructure/': typeof ProjectRefSettingsInfrastructureIndexRoute '/project/$ref/settings/jwt/': typeof ProjectRefSettingsJwtIndexRoute '/project/$ref/settings/webhooks/': typeof ProjectRefSettingsWebhooksIndexRoute '/project/$ref/storage/analytics/': typeof ProjectRefStorageAnalyticsIndexRoute @@ -2557,7 +2557,6 @@ export interface FileRoutesByTo { '/project/$ref/settings/api': typeof ProjectRefSettingsApiRoute '/project/$ref/settings/dashboard': typeof ProjectRefSettingsDashboardRoute '/project/$ref/settings/general': typeof ProjectRefSettingsGeneralRoute - '/project/$ref/settings/infrastructure': typeof ProjectRefSettingsInfrastructureIndexRoute '/project/$ref/settings/integrations': typeof ProjectRefSettingsIntegrationsRoute '/project/$ref/settings/log-drains': typeof ProjectRefSettingsLogDrainsRoute '/project/$ref/sql/$id': typeof ProjectRefSqlIdRoute @@ -2646,6 +2645,7 @@ export interface FileRoutesByTo { '/project/$ref/integrations/$id': typeof ProjectRefIntegrationsIdIndexRoute '/project/$ref/logs/explorer': typeof ProjectRefLogsExplorerIndexRoute '/project/$ref/settings/api-keys': typeof ProjectRefSettingsApiKeysIndexRoute + '/project/$ref/settings/infrastructure': typeof ProjectRefSettingsInfrastructureIndexRoute '/project/$ref/settings/jwt': typeof ProjectRefSettingsJwtIndexRoute '/project/$ref/settings/webhooks': typeof ProjectRefSettingsWebhooksIndexRoute '/project/$ref/storage/analytics': typeof ProjectRefStorageAnalyticsIndexRoute @@ -2875,7 +2875,6 @@ export interface FileRoutesById { '/project/$ref/settings/api-keys': typeof ProjectRefSettingsApiKeysRouteWithChildren '/project/$ref/settings/dashboard': typeof ProjectRefSettingsDashboardRoute '/project/$ref/settings/general': typeof ProjectRefSettingsGeneralRoute - '/project/$ref/settings/infrastructure/': typeof ProjectRefSettingsInfrastructureIndexRoute '/project/$ref/settings/integrations': typeof ProjectRefSettingsIntegrationsRoute '/project/$ref/settings/log-drains': typeof ProjectRefSettingsLogDrainsRoute '/project/$ref/sql/$id': typeof ProjectRefSqlIdRoute @@ -2964,6 +2963,7 @@ export interface FileRoutesById { '/project/$ref/integrations/$id/': typeof ProjectRefIntegrationsIdIndexRoute '/project/$ref/logs/explorer/': typeof ProjectRefLogsExplorerIndexRoute '/project/$ref/settings/api-keys/': typeof ProjectRefSettingsApiKeysIndexRoute + '/project/$ref/settings/infrastructure/': typeof ProjectRefSettingsInfrastructureIndexRoute '/project/$ref/settings/jwt/': typeof ProjectRefSettingsJwtIndexRoute '/project/$ref/settings/webhooks/': typeof ProjectRefSettingsWebhooksIndexRoute '/project/$ref/storage/analytics/': typeof ProjectRefStorageAnalyticsIndexRoute @@ -3192,7 +3192,6 @@ export interface FileRouteTypes { | '/project/$ref/settings/api-keys' | '/project/$ref/settings/dashboard' | '/project/$ref/settings/general' - | '/project/$ref/settings/infrastructure/' | '/project/$ref/settings/integrations' | '/project/$ref/settings/log-drains' | '/project/$ref/sql/$id' @@ -3281,6 +3280,7 @@ export interface FileRouteTypes { | '/project/$ref/integrations/$id/' | '/project/$ref/logs/explorer/' | '/project/$ref/settings/api-keys/' + | '/project/$ref/settings/infrastructure/' | '/project/$ref/settings/jwt/' | '/project/$ref/settings/webhooks/' | '/project/$ref/storage/analytics/' @@ -3493,7 +3493,6 @@ export interface FileRouteTypes { | '/project/$ref/settings/api' | '/project/$ref/settings/dashboard' | '/project/$ref/settings/general' - | '/project/$ref/settings/infrastructure' | '/project/$ref/settings/integrations' | '/project/$ref/settings/log-drains' | '/project/$ref/sql/$id' @@ -3582,6 +3581,7 @@ export interface FileRouteTypes { | '/project/$ref/integrations/$id' | '/project/$ref/logs/explorer' | '/project/$ref/settings/api-keys' + | '/project/$ref/settings/infrastructure' | '/project/$ref/settings/jwt' | '/project/$ref/settings/webhooks' | '/project/$ref/storage/analytics' @@ -3810,7 +3810,6 @@ export interface FileRouteTypes { | '/project/$ref/settings/api-keys' | '/project/$ref/settings/dashboard' | '/project/$ref/settings/general' - | '/project/$ref/settings/infrastructure/' | '/project/$ref/settings/integrations' | '/project/$ref/settings/log-drains' | '/project/$ref/sql/$id' @@ -3899,6 +3898,7 @@ export interface FileRouteTypes { | '/project/$ref/integrations/$id/' | '/project/$ref/logs/explorer/' | '/project/$ref/settings/api-keys/' + | '/project/$ref/settings/infrastructure/' | '/project/$ref/settings/jwt/' | '/project/$ref/settings/webhooks/' | '/project/$ref/storage/analytics/' @@ -4761,13 +4761,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectRefSettingsIntegrationsRouteImport parentRoute: typeof ProjectRefSettingsRoute } - '/project/$ref/settings/infrastructure/': { - id: '/project/$ref/settings/infrastructure/' - path: '/infrastructure' - fullPath: '/project/$ref/settings/infrastructure/' - preLoaderRoute: typeof ProjectRefSettingsInfrastructureIndexRouteImport - parentRoute: typeof ProjectRefSettingsRoute - } '/project/$ref/settings/general': { id: '/project/$ref/settings/general' path: '/general' @@ -5461,6 +5454,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectRefSettingsJwtIndexRouteImport parentRoute: typeof ProjectRefSettingsRoute } + '/project/$ref/settings/infrastructure/': { + id: '/project/$ref/settings/infrastructure/' + path: '/infrastructure' + fullPath: '/project/$ref/settings/infrastructure/' + preLoaderRoute: typeof ProjectRefSettingsInfrastructureIndexRouteImport + parentRoute: typeof ProjectRefSettingsRoute + } '/project/$ref/settings/api-keys/': { id: '/project/$ref/settings/api-keys/' path: '/' @@ -6822,15 +6822,15 @@ interface ProjectRefSettingsRouteChildren { ProjectRefSettingsApiKeysRoute: typeof ProjectRefSettingsApiKeysRouteWithChildren ProjectRefSettingsDashboardRoute: typeof ProjectRefSettingsDashboardRoute ProjectRefSettingsGeneralRoute: typeof ProjectRefSettingsGeneralRoute - ProjectRefSettingsInfrastructureIndexRoute: typeof ProjectRefSettingsInfrastructureIndexRoute - ProjectRefSettingsInfrastructureReplicaReplicaIdRoute: typeof ProjectRefSettingsInfrastructureReplicaReplicaIdRoute ProjectRefSettingsIntegrationsRoute: typeof ProjectRefSettingsIntegrationsRoute ProjectRefSettingsLogDrainsRoute: typeof ProjectRefSettingsLogDrainsRoute ProjectRefSettingsBillingUsageRoute: typeof ProjectRefSettingsBillingUsageRoute ProjectRefSettingsJwtLegacyRoute: typeof ProjectRefSettingsJwtLegacyRoute ProjectRefSettingsWebhooksEndpointIdRoute: typeof ProjectRefSettingsWebhooksEndpointIdRoute + ProjectRefSettingsInfrastructureIndexRoute: typeof ProjectRefSettingsInfrastructureIndexRoute ProjectRefSettingsJwtIndexRoute: typeof ProjectRefSettingsJwtIndexRoute ProjectRefSettingsWebhooksIndexRoute: typeof ProjectRefSettingsWebhooksIndexRoute + ProjectRefSettingsInfrastructureReplicaReplicaIdRoute: typeof ProjectRefSettingsInfrastructureReplicaReplicaIdRoute } const ProjectRefSettingsRouteChildren: ProjectRefSettingsRouteChildren = { @@ -6839,18 +6839,18 @@ const ProjectRefSettingsRouteChildren: ProjectRefSettingsRouteChildren = { ProjectRefSettingsApiKeysRoute: ProjectRefSettingsApiKeysRouteWithChildren, ProjectRefSettingsDashboardRoute: ProjectRefSettingsDashboardRoute, ProjectRefSettingsGeneralRoute: ProjectRefSettingsGeneralRoute, - ProjectRefSettingsInfrastructureIndexRoute: - ProjectRefSettingsInfrastructureIndexRoute, - ProjectRefSettingsInfrastructureReplicaReplicaIdRoute: - ProjectRefSettingsInfrastructureReplicaReplicaIdRoute, ProjectRefSettingsIntegrationsRoute: ProjectRefSettingsIntegrationsRoute, ProjectRefSettingsLogDrainsRoute: ProjectRefSettingsLogDrainsRoute, ProjectRefSettingsBillingUsageRoute: ProjectRefSettingsBillingUsageRoute, ProjectRefSettingsJwtLegacyRoute: ProjectRefSettingsJwtLegacyRoute, ProjectRefSettingsWebhooksEndpointIdRoute: ProjectRefSettingsWebhooksEndpointIdRoute, + ProjectRefSettingsInfrastructureIndexRoute: + ProjectRefSettingsInfrastructureIndexRoute, ProjectRefSettingsJwtIndexRoute: ProjectRefSettingsJwtIndexRoute, ProjectRefSettingsWebhooksIndexRoute: ProjectRefSettingsWebhooksIndexRoute, + ProjectRefSettingsInfrastructureReplicaReplicaIdRoute: + ProjectRefSettingsInfrastructureReplicaReplicaIdRoute, } const ProjectRefSettingsRouteWithChildren = From f20874343288db7224d9d361dbc06d7d3b0e0203 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Fri, 21 Aug 2026 15:01:50 +0800 Subject: [PATCH 03/11] fix(www): changelog md negotiation slug-set gate (#49357) Bare-URL `Accept: text/markdown` negotiation never fires on changelog entries authored after the GitHub-discussions backfill: the middleware gate `/^changelog\/\d+/` only matches legacy numeric slugs (from `legacy_gh_discussion` frontmatter), so agents that signal markdown via Accept get HTML on every new entry. I found this in the independent review round on #48475; pre-existing, not introduced there. **Changed:** - **Non-legacy entries negotiate markdown**: `generateMdContent.mjs` now lists `public/changelog/*.md` (written moments earlier by `generateStaticContent.mjs` in the same `content:build:core` chain) and emits a `CHANGELOG_PAGES` set into the generated module; the middleware regex becomes a set lookup, so negotiation coverage derives from the exact static files served and can't drift from what's published. - **Unknown and deep changelog paths stop negotiating**: the old regex prefix-matched paths like `changelog/100/bar` and nonexistent numeric slugs, rewriting them to missing `.md` files (404 under a markdown Accept); they now pass through to the dynamic route's canonicalizing 308/404. - **Build guard**: zero collected changelog slugs on Vercel fails the build (today a zero-entry changelog fetch ships empty output with a green build), and a shape assertion fails the build if collected slugs ever lose the `changelog/` prefix the middleware matches on. Locally without `CHANGELOG_SYNC_APP_*` secrets it warns and changelog negotiation is off, matching the absent content. - **`/changelog` index gated the same way**: the index slug is emitted into the set only when `public/changelog.md` was generated, replacing the hardcoded `slug === 'changelog'` branch; locally without secrets the index no longer rewrites to a nonexistent file. **Note:** script order in `content:build:core` is load-bearing (static content generation must precede md content generation); the Vercel guard turns a reorder into a loud build failure instead of a silent empty gate. ## To test Tested on the Vercel preview (`zone-www-dot-com` deployment of head `f451da3`): - [x] `curl -sI -H "Accept: text/markdown" /changelog` and `curl -sI /changelog.md`: got 200 `text/markdown` (index via the generated gate) - [x] `curl -sI -H "Accept: text/markdown" /changelog/pipelines`: got 200 `text/markdown` (prod today returns `text/html`) - [x] Same curl against the legacy numeric slug `48235-migration-of-...`: got 200 `text/markdown` (no regression) - [x] `curl -sI -H "Accept: application/json" /changelog/pipelines`: got 406 (prod today returns 200 HTML) - [x] Explicit `.md` fetches for both slug shapes (`/changelog/pipelines.md`, `/changelog/48235-....md`): got 200 `text/markdown` - [x] `curl -sI -H "Accept: text/markdown" /changelog/does-not-exist-xyz`: got a 404 HTML passthrough from the dynamic route, not a 406 - [x] `pnpm test middleware.test.ts` in `apps/www` at head: 41/41 pass (36 pre-existing + 5 new). No CI job runs the www vitest suite, so this local run is the only oracle for the new tests. ## Linear - fixes GROWTH-1062 ## Summary by CodeRabbit - **New Features** - Improved changelog page handling, including markdown versions of published entries. - Added content negotiation for supported changelog formats, with clear responses for unsupported requests. - **Bug Fixes** - Prevented unpublished numeric-prefix pages from being treated as published. - Fixed deep links under published changelog entries. - **Reliability** - Changelog availability is now detected automatically, with improved validation during content generation. --- apps/www/middleware.test.ts | 47 ++++++++++++++++++++++++++ apps/www/middleware.ts | 4 +-- apps/www/scripts/generateMdContent.mjs | 39 ++++++++++++++++++++- 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/apps/www/middleware.test.ts b/apps/www/middleware.test.ts index 6950e7c6c7c96..9e486bef4f822 100644 --- a/apps/www/middleware.test.ts +++ b/apps/www/middleware.test.ts @@ -11,6 +11,7 @@ import { middleware } from './middleware' vi.mock('./app/api-v2/md/content.generated', () => ({ MD_CONTENT: new Map(), MD_PAGES: new Set(['homepage', 'auth', 'pricing']), + CHANGELOG_PAGES: new Set(['changelog', 'changelog/100', 'changelog/pipelines']), })) function makeRequest( @@ -132,6 +133,18 @@ describe('www middleware', () => { expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/changelog/100.md') }) + it('serves markdown for explicit non-legacy changelog .md requests even when Accept excludes it', () => { + const req = makeRequest('/changelog/pipelines.md', { + accept: 'application/x-content-negotiation-probe', + }) + const res = middleware(req) + + expect(res.status).not.toBe(406) + expect(res.headers.get('x-middleware-rewrite')).toBe( + 'https://supabase.com/changelog/pipelines.md' + ) + }) + it('rewrites the changelog index .md request without doubling the suffix', () => { const req = makeRequest('/changelog.md', { accept: 'text/markdown' }) const res = middleware(req) @@ -185,6 +198,31 @@ describe('www middleware', () => { expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/changelog/100.md') }) + it('negotiates markdown for non-legacy changelog slugs', () => { + const req = makeRequest('/changelog/pipelines', { accept: 'text/markdown' }) + const res = middleware(req) + + expect(res.headers.get('x-middleware-rewrite')).toBe( + 'https://supabase.com/changelog/pipelines.md' + ) + }) + + it('passes through unpublished numeric-prefix changelog slugs', () => { + const req = makeRequest('/changelog/999-not-published', { accept: 'text/markdown' }) + const res = middleware(req) + + expect(res.headers.get('x-middleware-rewrite')).toBeNull() + expect(res.status).not.toBe(406) + }) + + it('passes through deep paths under a published changelog slug', () => { + const req = makeRequest('/changelog/100/bar', { accept: 'text/markdown' }) + const res = middleware(req) + + expect(res.headers.get('x-middleware-rewrite')).toBeNull() + expect(res.status).not.toBe(406) + }) + it('rewrites the bare changelog index to its static .md file', () => { const req = makeRequest('/changelog', { accept: 'text/markdown' }) const res = middleware(req) @@ -291,6 +329,15 @@ describe('www middleware', () => { expect(res.status).toBe(406) }) + it('returns 406 for non-legacy changelog entries when Accept matches nothing', () => { + const req = makeRequest('/changelog/pipelines', { + accept: 'application/x-content-negotiation-probe', + }) + const res = middleware(req) + + expect(res.status).toBe(406) + }) + it('sets Cache-Control: no-store and Vary: Accept on 406 responses', () => { const req = makeRequest('/pricing', { accept: 'application/x-content-negotiation-probe' }) const res = middleware(req) diff --git a/apps/www/middleware.ts b/apps/www/middleware.ts index ab96127d5eea8..75aa9a995377d 100644 --- a/apps/www/middleware.ts +++ b/apps/www/middleware.ts @@ -2,7 +2,7 @@ import { stampFirstReferrerCookie } from 'common/first-referrer-cookie' import { negotiateMarkdown } from 'common/markdown-negotiation' import { NextResponse, type NextRequest } from 'next/server' -import { MD_PAGES } from './app/api-v2/md/content.generated' +import { CHANGELOG_PAGES, MD_PAGES } from './app/api-v2/md/content.generated' export function middleware(request: NextRequest) { const { pathname } = request.nextUrl @@ -14,7 +14,7 @@ export function middleware(request: NextRequest) { // entry — NextURL preserves trailing-slash style on rewrite targets. const slug = (basePathname === '/' ? 'homepage' : basePathname.slice(1)).replace(/\/$/, '') const isMdEligible = MD_PAGES.has(slug) - const isChangelogEntry = slug === 'changelog' || /^changelog\/\d+/.test(slug) + const isChangelogEntry = CHANGELOG_PAGES.has(slug) const decision = negotiateMarkdown( { acceptHeader: request.headers.get('accept') ?? '' }, diff --git a/apps/www/scripts/generateMdContent.mjs b/apps/www/scripts/generateMdContent.mjs index 2dddb0351e3cb..842a9c1081131 100644 --- a/apps/www/scripts/generateMdContent.mjs +++ b/apps/www/scripts/generateMdContent.mjs @@ -17,6 +17,7 @@ import { mdxBodyToMarkdown } from './lib/mdxToMarkdown.mjs' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const wwwDir = path.join(__dirname, '..') const contentDir = path.join(wwwDir, 'content/md') +const changelogMdDir = path.join(wwwDir, 'public/changelog') const outputPath = path.join(wwwDir, 'app/api-v2/md/content.generated.ts') // Matches lib/posts.tsx FILENAME_SUBSTRING — strips YYYY-MM-DD- (11 chars). @@ -213,6 +214,40 @@ for (const entry of allEntries) { seen.add(entry.slug) } +// public/changelog/*.md is written by generateStaticContent.mjs earlier in +// content:build:core; absent locally e.g. when CHANGELOG_SYNC_APP_* secrets are unset. +const changelogSlugs = (await collectMdFiles(changelogMdDir, 'changelog')).sort() + +// The middleware matches request slugs against these keys verbatim, and its +// tests mock this set — a dropped prefix would ship silently with green tests. +if (changelogSlugs.some((s) => !s.startsWith('changelog/'))) { + console.error('❌ Changelog slugs must carry the changelog/ prefix the middleware matches on.') + process.exit(1) +} + +if (changelogSlugs.length === 0) { + if (process.env.VERCEL) { + console.error( + '❌ No changelog slugs found in public/changelog — changelog generation produced nothing.' + ) + process.exit(1) + } + console.warn( + '⚠️ No changelog slugs found in public/changelog — changelog md negotiation off in this build' + ) +} + +let changelogIndexExists = false +try { + await fs.access(path.join(wwwDir, 'public/changelog.md')) + changelogIndexExists = true +} catch (err) { + if (err.code !== 'ENOENT') throw err +} +if (changelogIndexExists) { + changelogSlugs.unshift('changelog') +} + const contentEntries = liveEntries .map((e) => ` [${JSON.stringify(e.slug)}, ${JSON.stringify(e.content)}]`) .join(',\n') @@ -228,9 +263,11 @@ ${contentEntries}, export const MD_PAGES = new Set([ ${pageEntries}, ]) + +export const CHANGELOG_PAGES = new Set(${JSON.stringify(changelogSlugs, null, 2)}) ` await fs.writeFile(outputPath, output, 'utf-8') console.log( - `✅ Generated ${outputPath} (${liveEntries.length} files, ${allPageSlugs.length} pages)` + `✅ Generated ${outputPath} (${liveEntries.length} files, ${allPageSlugs.length} pages, ${changelogSlugs.length} changelog)` ) From 86105ca5ec379b7e1cbe55aab641f8c323bed146 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 21 Aug 2026 17:09:05 +1000 Subject: [PATCH 04/11] feat(studio): align assistant message parts (#49351) image ## 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? Studio UI improvement. ## Stack context Builds on #49350. ## What is the current behavior? The Assistant conversation uses one outer width constraint. This leaves query and notebook previews too narrow, separates consecutive generic tool rows, and leaves message actions aligned to the far left. ## What is the new behavior? - Gives Assistant query cells and notebook previews a `max-w-6xl` container. - Keeps text and other regular message parts at their existing `max-w-3xl` width. - Keeps consecutive generic tool rows such as Reasoned and Ran load_knowledge compact. - Aligns message action rows with regular message content. ## To test 1. In the Assistant, produce a response containing text plus a SQL query or notebook preview. Confirm the preview is wide while regular text remains at the normal width. 2. Produce a response that reasons and runs consecutive non-preview tools. Confirm those rows remain close together with their separators. 3. Hover an Assistant response and confirm copy, rating, and branch actions align with the regular message content. 4. Hover a user message and confirm edit and delete actions use the same alignment. ## Summary by CodeRabbit - **Style** - Improved AI Assistant message layout with centered, consistent content widths. - Expanded notebooks, SQL results, and query-related content where additional space is helpful. - Improved alignment and spacing for actions, tool outputs, loading states, errors, and disclaimers. - Improved query editor visibility when switching between cells. - Loading indicators now respect reduced-motion preferences. - **Tests** - Added coverage for message layouts, tool grouping, and notebook preview sizing. --------- Co-authored-by: Cursor Agent Co-authored-by: Saxon Fletcher --- .../ui/AIAssistantPanel/AssistantChat.tsx | 37 +++--- .../AssistantNotebookPreview.test.tsx | 3 +- .../AssistantNotebookPreview.tsx | 2 +- .../AIAssistantPanel/AssistantQueryCell.tsx | 2 +- .../AIAssistantPanel/Message.Actions.test.tsx | 18 +++ .../ui/AIAssistantPanel/Message.Actions.tsx | 2 +- .../ui/AIAssistantPanel/Message.Display.tsx | 8 +- .../AIAssistantPanel/Message.Parts.test.tsx | 36 ++++++ .../ui/AIAssistantPanel/Message.Parts.tsx | 106 ++++++++++++------ .../ui/AIAssistantPanel/Message.tsx | 2 +- .../ui/AIAssistantPanel/elements/Tool.tsx | 2 +- 11 files changed, 158 insertions(+), 60 deletions(-) create mode 100644 apps/studio/components/ui/AIAssistantPanel/Message.Actions.test.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/Message.Parts.test.tsx diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx index 25d7f4cad6e53..ea02bd9397166 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx @@ -3,7 +3,7 @@ import { useChat } from '@ai-sdk/react' import { lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai' import { LOCAL_STORAGE_KEYS, useFlag } from 'common' import { useParams, useSearchParamsShallow } from 'common/hooks' -import { AnimatePresence, motion } from 'framer-motion' +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' import { Eraser, Pencil, X } from 'lucide-react' import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { Button, cn, KeyboardShortcut } from 'ui' @@ -98,6 +98,7 @@ export const AssistantChat = ({ useSelectedOrganizationQuery() const disablePrompts = useFlag('disableAssistantPrompts') + const shouldReduceMotion = useReducedMotion() const snap = useAiAssistantStateSnapshot() const state = useAiAssistantState() const currentChat = snap.chats[chatId] @@ -488,10 +489,10 @@ export const AssistantChat = ({ })} {hasMessages ? ( - + {renderedMessages} - {error && ( - <> +
+ {error && ( } /> - - )} - {isChatLoading && ( - - )} + )} + {isChatLoading && ( + + )} -

- The Assistant can make mistakes. Double check responses. -

+

+ The Assistant can make mistakes. Double check responses. +

+
diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.test.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.test.tsx index 6f01d839212a1..c1cdc1fcdefa7 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.test.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.test.tsx @@ -55,11 +55,12 @@ describe('AssistantNotebookPreview', () => { { _tag: 'unchanged', cell: wireMarkdownCell('b', 'two') }, ] - render() + const { container } = render() expect(screen.getByRole('toolbar', { name: 'Notebook toolbar' })).toBeInTheDocument() expect(screen.getByText('2 cells')).toBeInTheDocument() expect(screen.getByText('New notebook')).toBeInTheDocument() + expect(container.firstElementChild).toHaveClass('max-w-6xl') }) it('surfaces a metadata-only change on a replaced cell even when the sql is unchanged', () => { diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.tsx index 959378d65e559..26c243b6c530b 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.tsx @@ -54,7 +54,7 @@ export const AssistantNotebookPreview = ({ expandedOverrides[getEntryKey(entry)] === true return ( -
+
diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx index 6036059820a95..6369797c9154c 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx @@ -127,7 +127,7 @@ export const AssistantQueryCell = ({ return ( { + it('uses the standard centered message width', () => { + const { container } = customRender( + + + + ) + + expect(container.firstChild).toHaveClass('w-full', 'max-w-3xl', 'mx-auto') + }) +}) diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx index e0f05f01e7746..7e4bd8ef7ed57 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx @@ -23,7 +23,7 @@ export function MessageActions({ alwaysShow = false, }: PropsWithChildren<{ alwaysShow?: boolean }>) { return ( -
+
{children} diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Display.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Display.tsx index a6cd55409fa5f..1c30480f7039c 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.Display.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.Display.tsx @@ -56,9 +56,11 @@ function MessageDisplayContent({ message }: { message: VercelMessage }) { return }) : content && ( - - {content} - +
+ + {content} + +
)}
) diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Parts.test.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.test.tsx new file mode 100644 index 0000000000000..8f8042d8377e4 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.test.tsx @@ -0,0 +1,36 @@ +import type { ToolUIPart } from 'ai' +import { describe, expect, it } from 'vitest' + +import { MessagePartSwitcher } from './Message.Parts' +import { customRender } from '@/tests/lib/custom-render' + +type MessagePart = Parameters[0]['part'] + +describe('MessagePartSwitcher', () => { + it('keeps consecutive generic tool parts as direct siblings', () => { + const reasoningPart = { + type: 'reasoning', + state: 'done', + text: 'I will look up the project details.', + } satisfies Extract + const toolPart = { + type: 'tool-load_knowledge', + toolCallId: 'load-knowledge-1', + state: 'output-available', + input: {}, + output: {}, + } satisfies ToolUIPart + + const { container } = customRender( + <> + + + + ) + + const toolRows = container.querySelectorAll('.tool-item') + expect(toolRows).toHaveLength(2) + expect(toolRows[0].nextElementSibling).toBe(toolRows[1]) + expect(toolRows[0]).toHaveClass('max-w-3xl') + }) +}) diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx index a08a24a8ad86c..b3b4fe25584d0 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx @@ -1,6 +1,7 @@ import { UIMessage as VercelMessage } from '@ai-sdk/react' import { type DynamicToolUIPart, type ReasoningUIPart, type TextUIPart, type ToolUIPart } from 'ai' import { BrainIcon, CheckIcon, Loader2 } from 'lucide-react' +import { type ReactNode } from 'react' import { cn } from 'ui' import { AssistantQueryCell } from './AssistantQueryCell' @@ -281,49 +282,84 @@ const MessagePart = { NotebookProposal: MessagePartNotebookProposal, } as const +function MessagePartContainer({ + children, + isWide = false, +}: { + children: ReactNode + isWide?: boolean +}) { + return
{children}
+} + +const isWideMessagePart = (part: NonNullable[number]) => + part.type === 'tool-execute_sql' || + part.type === 'tool-query_logs' || + part.type === 'tool-create_notebook' || + part.type === 'tool-update_notebook' || + (part.type === 'dynamic-tool' && part.toolName === 'query_logs') || + // Unlabelled code fences resolve to SQL in MessageMarkdown, too. + (part.type === 'text' && /```(?:sql)?(?:\s|$)/i.test(part.text)) + +const isCompactToolPart = (part: NonNullable[number]) => + part.type === 'reasoning' || + (part.type === 'dynamic-tool' && part.toolName !== 'query_logs') || + part.type === 'tool-list_policies' || + part.type === 'tool-search_docs' || + part.type === 'tool-get_active_incidents' || + part.type === 'tool-load_knowledge' + export function MessagePartSwitcher({ part, }: { part: NonNullable[number] }) { - switch (part.type) { - case 'dynamic-tool': { - if (part.toolName === 'query_logs') { + const content = (() => { + switch (part.type) { + case 'dynamic-tool': { + if (part.toolName === 'query_logs') { + return + } + return + } + case 'tool-list_policies': + case 'tool-search_docs': + case 'tool-get_active_incidents': + case 'tool-load_knowledge': { + return + } + case 'reasoning': + return + case 'text': + return + + case 'tool-execute_sql': { + return + } + case 'tool-query_logs': { return } - return - } - case 'tool-list_policies': - case 'tool-search_docs': - case 'tool-get_active_incidents': - case 'tool-load_knowledge': { - return - } - case 'reasoning': - return - case 'text': - return + case 'tool-deploy_edge_function': { + return + } + case 'tool-create_notebook': { + return + } + case 'tool-update_notebook': { + return + } - case 'tool-execute_sql': { - return - } - case 'tool-query_logs': { - return - } - case 'tool-deploy_edge_function': { - return - } - case 'tool-create_notebook': { - return - } - case 'tool-update_notebook': { - return + case 'source-url': + case 'source-document': + case 'file': + default: + return null } + })() - case 'source-url': - case 'source-document': - case 'file': - default: - return null - } + if (content === null) return null + // Tool rows depend on being direct siblings to share their compact spacing and dividers. + if (isCompactToolPart(part)) return content + + return {content} } diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.tsx index 6ec5780bec6c6..b719c2cab0fd9 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.tsx @@ -71,7 +71,7 @@ function UserMessage({ message }: { message: VercelMessage }) { )} onClick={state === 'predecessor-editing' ? onCancelEdit : undefined} > - + diff --git a/apps/studio/components/ui/AIAssistantPanel/elements/Tool.tsx b/apps/studio/components/ui/AIAssistantPanel/elements/Tool.tsx index fef049927a651..8677810b62cb6 100644 --- a/apps/studio/components/ui/AIAssistantPanel/elements/Tool.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/elements/Tool.tsx @@ -13,7 +13,7 @@ export function Tool({ className, label, icon, children }: ToolProps) { return (
Date: Fri, 21 Aug 2026 15:27:53 +0800 Subject: [PATCH 05/11] Flush notebook cache in valtio and react query whenever closing notebook tab (#49369) ## Context Opting to flush the notebook cache within the Valtio store (nootebook-store) and react query whenever we close the notebook tab in the explorer. Mainly to ensure that whenever we re-open the notebook again, the notebook content isn't stale and we refetch the notebook content from the API ## Summary by CodeRabbit - **Bug Fixes** - Closing a saved notebook tab now removes it from the session and clears its cached content. - Notebooks with unsaved changes are preserved when their tabs close. - **Tests** - Added coverage for saved and unsaved notebook tab cleanup behavior. --- .../ExplorerNotebookTabCoordinator.tsx | 39 +++++++++ .../ExplorerNotebookTabCoordinator.test.tsx | 83 +++++++++++++++++++ .../layouts/ExplorerLayout/ExplorerLayout.tsx | 4 + .../state/notebooks/notebook-session-state.ts | 9 -- 4 files changed, 126 insertions(+), 9 deletions(-) create mode 100644 apps/studio/components/interfaces/Explorer/ExplorerNotebookTabCoordinator.tsx create mode 100644 apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTabCoordinator.test.tsx delete mode 100644 apps/studio/state/notebooks/notebook-session-state.ts diff --git a/apps/studio/components/interfaces/Explorer/ExplorerNotebookTabCoordinator.tsx b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTabCoordinator.tsx new file mode 100644 index 0000000000000..9e1c723c4ac24 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTabCoordinator.tsx @@ -0,0 +1,39 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useParams } from 'common' +import { useContext, useEffect } from 'react' + +import { contentKeys } from '@/data/content/keys' +import { notebooksState } from '@/state/notebooks/notebooks-state' +import { TabsStateContext } from '@/state/tabs' + +/** + * Evicts a notebook's content from the valtio store and the React Query cache + * when its tab closes, so reopening it always refetches instead of showing + * whatever was last loaded. Only applies to notebooks with no unsaved edits — + * a dirty notebook is left in the store untouched, same as today, since there's + * no autosave or discard-confirmation flow for notebooks yet. + * + * [Joshen] We'll address discard confirmation separately + */ +export const ExplorerNotebookTabCoordinator = () => { + const { ref } = useParams() + const queryClient = useQueryClient() + const tabs = useContext(TabsStateContext) + + useEffect(() => { + return tabs.registerTabTypeHandler('notebook', { + onClose: (tab) => { + const notebookId = tab.metadata?.notebookId + if (!ref || !notebookId) return + + const stateNotebook = notebooksState.notebooks[notebookId] + if (stateNotebook?.status !== 'saved') return + + notebooksState.removeNotebook({ id: notebookId }) + queryClient.removeQueries({ queryKey: contentKeys.resource(ref, notebookId) }) + }, + }) + }, [ref, tabs, queryClient]) + + return null +} diff --git a/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTabCoordinator.test.tsx b/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTabCoordinator.test.tsx new file mode 100644 index 0000000000000..8ea127fcb3dae --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTabCoordinator.test.tsx @@ -0,0 +1,83 @@ +import { QueryClient } from '@tanstack/react-query' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { ExplorerNotebookTabCoordinator } from '../ExplorerNotebookTabCoordinator' +import { contentKeys } from '@/data/content/keys' +import { notebooksState } from '@/state/notebooks/notebooks-state' +import type { Notebook } from '@/state/notebooks/types' +import { createTabId, createTabsState, TabsStateContext } from '@/state/tabs' +import { customRender } from '@/tests/lib/custom-render' + +vi.mock('common', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useParams: () => ({ ref: 'default' }), + } +}) + +const NOTEBOOK_ID = 'notebook-coordinator-test' + +const seedNotebook = (status: 'new' | 'saved') => { + delete notebooksState.notebooks[NOTEBOOK_ID] + const notebook: Notebook = { + id: NOTEBOOK_ID, + type: 'notebook', + name: 'Test notebook', + visibility: 'project', + favorite: false, + owner_id: 1, + project_id: 1, + content: { schema_version: 1, cells: [] }, + } + if (status === 'new') notebooksState.addNotebook({ projectRef: 'default', notebook }) + else notebooksState.setNotebook({ projectRef: 'default', notebook }) +} + +const renderCoordinator = (queryClient: QueryClient) => { + const tabsState = createTabsState('default') + const tabId = createTabId('notebook', { id: NOTEBOOK_ID }) + tabsState.addTab({ id: tabId, type: 'notebook', metadata: { notebookId: NOTEBOOK_ID } }) + + customRender( + + + , + { queryClient } + ) + + return { tabsState, tabId } +} + +afterEach(() => { + delete notebooksState.notebooks[NOTEBOOK_ID] + notebooksState.needsSaving.clear() +}) + +describe('ExplorerNotebookTabCoordinator', () => { + it('flushes a saved notebook from the store and evicts its cache entry on close', () => { + seedNotebook('saved') + const queryClient = new QueryClient() + queryClient.setQueryData(contentKeys.resource('default', NOTEBOOK_ID), { id: NOTEBOOK_ID }) + + const { tabsState, tabId } = renderCoordinator(queryClient) + tabsState.closeTabs([tabId]) + + expect(notebooksState.notebooks[NOTEBOOK_ID]).toBeUndefined() + expect(queryClient.getQueryData(contentKeys.resource('default', NOTEBOOK_ID))).toBeUndefined() + }) + + it('leaves an unsaved notebook in the store on close', () => { + seedNotebook('new') + const queryClient = new QueryClient() + queryClient.setQueryData(contentKeys.resource('default', NOTEBOOK_ID), { id: NOTEBOOK_ID }) + + const { tabsState, tabId } = renderCoordinator(queryClient) + tabsState.closeTabs([tabId]) + + expect(notebooksState.notebooks[NOTEBOOK_ID]).toBeDefined() + expect(queryClient.getQueryData(contentKeys.resource('default', NOTEBOOK_ID))).toEqual({ + id: NOTEBOOK_ID, + }) + }) +}) diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx index 7e2f1920e3fbe..0a9690187ef21 100644 --- a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx @@ -16,6 +16,7 @@ import { type ExplorerResourceType } from './ExplorerLayout.constants' import { ExplorerNavChats } from './ExplorerNavChats' import { ExplorerNavHome } from './ExplorerNavHome' import { ExplorerNavNotebooks } from './ExplorerNavNotebooks' +import { ExplorerNotebookTabCoordinator } from '@/components/interfaces/Explorer/ExplorerNotebookTabCoordinator' import { ExplorerQueryTabCoordinator } from '@/components/interfaces/Explorer/ExplorerQueryTabCoordinator' import { useCreateChat, @@ -70,6 +71,9 @@ export const ExplorerLayout = ({ browserTitle, children, title }: ExplorerLayout } > + + +
Date: Fri, 21 Aug 2026 15:51:03 +0800 Subject: [PATCH 06/11] fix(studio): restrict geolocated default region to provider regions (#49141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #49131. For `AWS_NIMBUS` orgs, the new-project form's Region trigger could show a region that wasn't in the dropdown at all (e.g. "Southeast Asia (Singapore)" while the list only offered "East US (North Virginia)"). The geolocation-based default region (`useDefaultRegionQuery`) picked the nearest region from **all** AWS regions and seeded it into `dbRegion` unvalidated, ignoring the provider's restricted region list. **Changed:** - `getDefaultRegionOption` now computes the nearest region only over the provider's available regions (new `getDefaultRegionCandidateKeys` helper). The flag-based restricted pool (`defaultRegionRestrictedPool`) narrows within that set and is ignored if the intersection would be empty. - The form's default-region selection is extracted into `resolveDefaultDbRegion` (`ProjectCreation.utils.ts`): High Availability region first, then the recommended smart region, then the geolocated default — used only when the provider actually offers that region — falling back to the provider's static default. - `getAvailableRegions` takes an injectable `environment` param (same pattern as `getHighAvailabilityRegionCode`) so the prod-only Nimbus region list is unit-testable. **Added:** - Unit tests for `getDefaultRegionCandidateKeys` (provider clamping incl. Nimbus on prod, restricted-pool intersection, empty-intersection fallback), `getAvailableRegions` across environments, and `resolveDefaultDbRegion` (branch priority plus the fallback when the geolocated region isn't offered). ## To test - Emulate a Nimbus org locally by setting `"infra:cloud_providers": ["AWS_NIMBUS"]` in `apps/studio/hooks/custom-content/custom-content.json`, then open the new-project form: the Region trigger must show the same region the dropdown offers (locally that's only Southeast Asia (Singapore)). To reproduce the original mismatch path, stub `https://www.cloudflare.com/cdn-cgi/trace` to return `loc=US` — the trigger should still be clamped to the provider's region rather than showing a US region - Block or fail the Cloudflare trace request: the trigger should fall back to the provider's static default region, not sit blank or loading - Restore the normal provider list: the smart-region flow ("General regions" + "Specific regions" with Recommended badges) is unaffected — the geolocation request doesn't even fire on that path — and toggling High Availability still transitions the region list cleanly ## Summary by CodeRabbit ## Summary by CodeRabbit - **Bug Fixes** - Region suggestions now respect the selected cloud provider and deployment environment. - Project creation avoids unavailable geolocated regions and falls back to a supported provider default. - Restricted region pools now fall back reliably to available provider regions. - AWS Nimbus selection reflects the active environment while preserving high-availability and smart-region behavior. - **Tests** - Added coverage for provider-specific, environment-specific, restricted, and fallback region selection scenarios. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- .../ProjectCreation.utils.test.ts | 74 +++++++++++++++++++ .../ProjectCreation/ProjectCreation.utils.ts | 42 ++++++++++- .../ProjectCreation/ProjectCreationForm.tsx | 17 +++-- .../misc/get-default-region-query.test.ts | 41 ++++++++++ .../data/misc/get-default-region-query.ts | 33 +++++++-- 5 files changed, 191 insertions(+), 16 deletions(-) create mode 100644 apps/studio/data/misc/get-default-region-query.test.ts diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts index 4f06720b287e3..fd2b5c74c9635 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts @@ -1,3 +1,4 @@ +import { AWS_REGIONS } from 'shared-data' import { describe, expect, it } from 'vitest' import { @@ -7,9 +8,82 @@ import { } from './ProjectCreation.constants' import { filterHighAvailabilityRegions, + getAvailableRegions, getHighAvailabilityRegionCode, + resolveDefaultDbRegion, } from './ProjectCreation.utils' +describe('resolveDefaultDbRegion', () => { + const base = { + cloudProvider: 'AWS_NIMBUS', + isHighAvailabilityRestricted: false, + highAvailabilityRegionName: undefined, + isSmartRegionEnabled: false, + recommendedSmartRegion: undefined, + autoDefaultRegion: undefined, + fixedDefaultRegion: AWS_REGIONS.EAST_US.displayName, + environment: 'prod', + } as const + + it('prefers the high availability region when restricted, even while it is still loading', () => { + expect( + resolveDefaultDbRegion({ + ...base, + isHighAvailabilityRestricted: true, + highAvailabilityRegionName: AWS_REGIONS.EAST_US.displayName, + }) + ).toBe(AWS_REGIONS.EAST_US.displayName) + expect(resolveDefaultDbRegion({ ...base, isHighAvailabilityRestricted: true })).toBeUndefined() + }) + + it('uses the recommended smart region when smart regions are enabled', () => { + expect( + resolveDefaultDbRegion({ + ...base, + cloudProvider: 'AWS', + isSmartRegionEnabled: true, + recommendedSmartRegion: 'Americas', + autoDefaultRegion: AWS_REGIONS.SOUTHEAST_ASIA.displayName, + }) + ).toBe('Americas') + }) + + it('uses the geolocated region when the provider offers it', () => { + expect( + resolveDefaultDbRegion({ + ...base, + cloudProvider: 'AWS', + autoDefaultRegion: AWS_REGIONS.SOUTHEAST_ASIA.displayName, + }) + ).toBe(AWS_REGIONS.SOUTHEAST_ASIA.displayName) + }) + + it('falls back to the fixed default when the provider does not offer the geolocated region', () => { + expect( + resolveDefaultDbRegion({ ...base, autoDefaultRegion: AWS_REGIONS.SOUTHEAST_ASIA.displayName }) + ).toBe(AWS_REGIONS.EAST_US.displayName) + }) + + it('falls back to the fixed default when no geolocated region resolved', () => { + expect(resolveDefaultDbRegion(base)).toBe(AWS_REGIONS.EAST_US.displayName) + }) +}) + +describe('getAvailableRegions', () => { + it.each(['local', 'staging', 'prod'])('returns all AWS regions for AWS on %s', (environment) => { + expect(getAvailableRegions('AWS', environment)).toEqual(AWS_REGIONS) + expect(getAvailableRegions('AWS_K8S', environment)).toEqual(AWS_REGIONS) + }) + + it.each([ + ['local', { SOUTHEAST_ASIA: AWS_REGIONS.SOUTHEAST_ASIA }], + ['staging', { SOUTHEAST_ASIA: AWS_REGIONS.SOUTHEAST_ASIA }], + ['prod', { EAST_US: AWS_REGIONS.EAST_US }], + ])('returns the single AWS_NIMBUS region on %s', (environment, expectedRegions) => { + expect(getAvailableRegions('AWS_NIMBUS', environment)).toEqual(expectedRegions) + }) +}) + describe('High Availability project creation constraints', () => { it('pins the Alpha Postgres engine, release channel, and compute size', () => { expect(HIGH_AVAILABILITY_POSTGRES_ENGINE).toBe('17') diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts index a99780d99c7e1..f04d8bca8a5dd 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts @@ -8,13 +8,16 @@ export function smartRegionToExactRegion(smartOrExactRegion: string) { return SMART_REGION_TO_EXACT_REGION_MAP.get(smartOrExactRegion) ?? smartOrExactRegion } -export function getAvailableRegions(cloudProvider: CloudProvider): Region { +export function getAvailableRegions( + cloudProvider: CloudProvider, + environment = process.env.NEXT_PUBLIC_ENVIRONMENT +): Region { switch (cloudProvider) { case 'AWS': case 'AWS_K8S': return AWS_REGIONS case 'AWS_NIMBUS': - if (process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod') { + if (environment !== 'prod') { // Only allow Southeast Asia for Nimbus (local/staging) return { SOUTHEAST_ASIA: AWS_REGIONS.SOUTHEAST_ASIA, @@ -30,6 +33,41 @@ export function getAvailableRegions(cloudProvider: CloudProvider): Region { } } +type ResolveDefaultDbRegionArgs = { + cloudProvider: CloudProvider + isHighAvailabilityRestricted: boolean + highAvailabilityRegionName: string | undefined + isSmartRegionEnabled: boolean + recommendedSmartRegion: string | undefined + autoDefaultRegion: string | undefined + fixedDefaultRegion: string + environment?: string +} + +export function resolveDefaultDbRegion({ + cloudProvider, + isHighAvailabilityRestricted, + highAvailabilityRegionName, + isSmartRegionEnabled, + recommendedSmartRegion, + autoDefaultRegion, + fixedDefaultRegion, + environment = process.env.NEXT_PUBLIC_ENVIRONMENT, +}: ResolveDefaultDbRegionArgs): string | undefined { + if (isHighAvailabilityRestricted) return highAvailabilityRegionName + if (isSmartRegionEnabled) return recommendedSmartRegion + + // The geolocated default is only usable if the provider actually offers that region + // (e.g. AWS_NIMBUS is restricted to a single region) + const isAutoDefaultRegionAvailable = Object.entries( + getAvailableRegions(cloudProvider, environment) + ).some(([, region]) => region.displayName === autoDefaultRegion) + + return isAutoDefaultRegionAvailable && autoDefaultRegion !== undefined + ? autoDefaultRegion + : fixedDefaultRegion +} + /** * When launching new projects, they only get assigned a compute size once successfully launched, * this might assume wrong compute size, but only for projects being rapidly launched after one another on non-default compute sizes. diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx index 7b8b4639dc857..5f3af194804e1 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx @@ -33,6 +33,7 @@ import { getHighAvailabilityRegionCode, instanceLabel, monthlyInstancePrice, + resolveDefaultDbRegion, smartRegionToExactRegion, } from './ProjectCreation.utils' import { ProjectCreationFooter } from './ProjectCreationFooter' @@ -284,12 +285,16 @@ export const ProjectCreationForm = ({ const fixedDefaultRegion = PROVIDERS[selectedCloudProvider].default_region.displayName const regionError = smartRegionEnabled ? availableRegionsError : defaultRegionError - const defaultRegion = - highAvailability && highAvailabilityRegionCode !== undefined - ? highAvailabilityRegion?.name - : smartRegionEnabled - ? recommendedSmartRegion - : (autoDefaultRegion ?? fixedDefaultRegion) + const defaultRegion = resolveDefaultDbRegion({ + cloudProvider: selectedCloudProvider, + isHighAvailabilityRestricted: + highAvailability === true && highAvailabilityRegionCode !== undefined, + highAvailabilityRegionName: highAvailabilityRegion?.name, + isSmartRegionEnabled: smartRegionEnabled, + recommendedSmartRegion, + autoDefaultRegion, + fixedDefaultRegion, + }) const canCreateProject = isAdmin && !freePlanWithExceedingLimits && !hasOutstandingInvoices const canConfigureGitHubOnCreate = diff --git a/apps/studio/data/misc/get-default-region-query.test.ts b/apps/studio/data/misc/get-default-region-query.test.ts new file mode 100644 index 0000000000000..e5ff8d643bd62 --- /dev/null +++ b/apps/studio/data/misc/get-default-region-query.test.ts @@ -0,0 +1,41 @@ +import { AWS_REGIONS } from 'shared-data' +import { describe, expect, it } from 'vitest' + +import { getDefaultRegionCandidateKeys } from './get-default-region-query' + +describe('getDefaultRegionCandidateKeys', () => { + it('returns all AWS regions for the AWS provider', () => { + expect(getDefaultRegionCandidateKeys('AWS', undefined, 'prod')).toEqual( + Object.keys(AWS_REGIONS) + ) + }) + + it.each([ + ['prod', ['EAST_US']], + ['staging', ['SOUTHEAST_ASIA']], + ['local', ['SOUTHEAST_ASIA']], + ])('restricts AWS_NIMBUS to its only available region on %s', (environment, expectedKeys) => { + expect(getDefaultRegionCandidateKeys('AWS_NIMBUS', undefined, environment)).toEqual( + expectedKeys + ) + }) + + it('narrows the provider regions to the restricted pool', () => { + expect(getDefaultRegionCandidateKeys('AWS', ['EAST_US', 'SOUTHEAST_ASIA'], 'prod')).toEqual([ + 'EAST_US', + 'SOUTHEAST_ASIA', + ]) + }) + + it('never returns a region the provider does not offer, even if the restricted pool contains it', () => { + expect( + getDefaultRegionCandidateKeys('AWS_NIMBUS', ['EAST_US', 'SOUTHEAST_ASIA'], 'prod') + ).toEqual(['EAST_US']) + }) + + it('ignores a restricted pool that excludes every provider region', () => { + expect(getDefaultRegionCandidateKeys('AWS_NIMBUS', ['SOUTHEAST_ASIA'], 'prod')).toEqual([ + 'EAST_US', + ]) + }) +}) diff --git a/apps/studio/data/misc/get-default-region-query.ts b/apps/studio/data/misc/get-default-region-query.ts index d2c17c150601c..264cf5f847179 100644 --- a/apps/studio/data/misc/get-default-region-query.ts +++ b/apps/studio/data/misc/get-default-region-query.ts @@ -5,6 +5,7 @@ import { AWS_REGIONS } from 'shared-data' import { miscKeys } from './keys' import { COUNTRY_LAT_LON } from '@/components/interfaces/ProjectCreation/ProjectCreation.constants' +import { getAvailableRegions } from '@/components/interfaces/ProjectCreation/ProjectCreation.utils' import { AWS_REGIONS_COORDINATES } from '@/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants' import { fetchHandler } from '@/data/fetchers' import { getDistanceLatLonKM, tryParseJson } from '@/lib/helpers' @@ -16,6 +17,21 @@ export type DefaultRegionVariables = { useRestrictedPool?: boolean } +// The geolocation-based default may only ever pick a region the selected cloud provider +// actually offers (e.g. AWS_NIMBUS is restricted to a single region). The flag-based +// restricted pool narrows within that set, and is ignored if it would leave no candidates. +export function getDefaultRegionCandidateKeys( + cloudProvider: CloudProvider, + restrictedPool?: string[], + environment = process.env.NEXT_PUBLIC_ENVIRONMENT +) { + const providerRegionKeys = Object.keys(getAvailableRegions(cloudProvider, environment)) + const pooledRegionKeys = restrictedPool + ? providerRegionKeys.filter((key) => restrictedPool.includes(key)) + : providerRegionKeys + return pooledRegionKeys.length > 0 ? pooledRegionKeys : providerRegionKeys +} + export async function getDefaultRegionOption({ cloudProvider, restrictedPool, @@ -34,17 +50,18 @@ export async function getDefaultRegionOption({ if (locLatLon === undefined) return undefined - const locations = - useRestrictedPool && restrictedPool - ? Object.entries(AWS_REGIONS_COORDINATES) - .filter((x) => restrictedPool.includes(x[0])) - .reduce((o, val) => ({ ...o, [val[0]]: val[1] }), {}) - : AWS_REGIONS_COORDINATES + const candidateKeys = getDefaultRegionCandidateKeys( + cloudProvider, + useRestrictedPool ? restrictedPool : undefined + ) + const locations = Object.fromEntries( + Object.entries(AWS_REGIONS_COORDINATES).filter(([key]) => candidateKeys.includes(key)) + ) const distances = Object.keys(locations).map((reg) => { const region: { lat: number; lon: number } = { - lat: locations[reg as keyof typeof locations][1], - lon: locations[reg as keyof typeof locations][0], + lat: locations[reg][1], + lon: locations[reg][0], } return getDistanceLatLonKM(locLatLon.lat, locLatLon.lon, region.lat, region.lon) }) From 29e47821f5012a4c27293bc45048de8b0ab105f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Fri, 21 Aug 2026 09:08:28 +0100 Subject: [PATCH 07/11] fix(realtime): add pg changes pool to realtime settings (#49256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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? Feature — adds a new Realtime setting to configure the Postgres Changes connection pool size. ## What is the current behavior? The Realtime settings page only exposes the connection pool used for Realtime Authorization (`connection_pool`). The pool that Realtime uses for Postgres Changes is not surfaced anywhere in the dashboard, so projects that need to tune it have no self-serve way to do so — the only option is to contact support. ## What is the new behavior? The Realtime settings page now includes a **Postgres Changes connection pool size** field: - Reads `postgres_changes_pool` from the project's Realtime config, falling back to a default of `2` when no override is stored. - Validates input from `1` through `20` (`MAX_POSTGRES_CHANGES_POOL`), and submits the value as a number in the config `PATCH` payload. - Docs (`apps/docs/content/guides/realtime/settings.mdx`) are expanded with sizing guidance for both connection pools, plus limits, resource-usage notes, and the operational error codes to look for. Screenshot 2026-08-19 at 13 59 04 ## Additional context The named `RealtimeConfigResponse` / `UpdateRealtimeConfigBody` schemas in the generated `api-types` package do not carry `postgres_changes_pool` yet, so both the query and mutation types extend the generated schema locally — the same pattern already used elsewhere in `apps/studio/data/`. Once the platform OpenAPI spec ships the field and `api-types` is regenerated, those two local intersections can be dropped. Covered by component tests in `RealtimeSettings.test.tsx` for both the fetch and save paths. ## Summary by CodeRabbit * **New Features** * Added a Realtime setting to configure the Postgres Changes connection pool size. * Connection pools support 1–20 connections, with a default of 2. * Saving the setting now applies the configured value correctly. * **Documentation** * Expanded Realtime Settings guidance with configuration limits, resource usage, channel access, payload and presence limits, plan ceilings, spend-cap restrictions, and operational error codes. * Added guidance for sizing authorization and Postgres Changes connection pools. --------- Co-authored-by: Ivan Vasilov --- .../docs/content/guides/realtime/settings.mdx | 95 ++++++- .../Realtime/RealtimeSettings.test.tsx | 204 ++++++++++++++ .../interfaces/Realtime/RealtimeSettings.tsx | 40 +++ apps/studio/data/lint/lint-rules-query.ts | 2 +- .../data/realtime/realtime-config-mutation.ts | 8 +- .../data/realtime/realtime-config-query.ts | 7 +- packages/api-types/types/api-v1.d.ts | 12 + packages/api-types/types/api-v2.d.ts | 87 +++--- packages/api-types/types/platform.d.ts | 260 +++++++++++------- 9 files changed, 559 insertions(+), 156 deletions(-) create mode 100644 apps/studio/components/interfaces/Realtime/RealtimeSettings.test.tsx diff --git a/apps/docs/content/guides/realtime/settings.mdx b/apps/docs/content/guides/realtime/settings.mdx index 8084aa391d949..e05e5e36bfde2 100644 --- a/apps/docs/content/guides/realtime/settings.mdx +++ b/apps/docs/content/guides/realtime/settings.mdx @@ -23,13 +23,88 @@ width={4600} height={2600} /> -You can set the following settings using the Realtime Settings screen in your Dashboard: - -- Enable Realtime service: Determines if the Realtime service is enabled or disabled for your project. -- Channel Restrictions: You can toggle this settings to set Realtime to allow public channels or set it to use only private channels with [Realtime Authorization](/docs/guides/realtime/authorization). -- Database connection pool size: Determines the number of connections used for Realtime Authorization RLS checking - {/* supa-mdx-lint-disable-next-line Rule004ExcludeWords */} -- Max concurrent clients: Determines the maximum number of clients that can be connected -- Max events per second: Determines the maximum number of events per second that can be sent -- Max presence events per second: Determines the maximum number of presence events per second that can be sent -- Max payload size in KB: Determines the maximum number of payload size in KB that can be sent +You can set the following settings using the Realtime Settings screen in your Dashboard. For the ceilings your plan allows, see [Realtime Limits](/docs/guides/realtime/limits); the rate and payload limits are only editable while your organization's spend cap is disabled. For the errors below, see [Operational Error Codes](/docs/guides/realtime/error_codes). + +### Enable Realtime service + +**Type:** Toggle · **Options:** Enabled, Disabled · **Default:** Enabled + +Determines if the Realtime service is enabled or disabled for your project. + +- **Enabled**: normal operation. +- **Disabled**: connected clients are disconnected, new connections are rejected with `403` and `Realtime was disabled for this tenant`, joins receive `RealtimeDisabledForTenant`, and Broadcast REST requests are rejected with `403`. Realtime also releases the database connections and Postgres Changes replication slot it holds for your project, and reopens them on the first connection after you enable it again. + +### Allow public access to channels + +**Type:** Toggle · **Options:** Enabled, Disabled · **Default:** Enabled + +Determines whether Realtime allows public channels, or restricts your project to private channels with [Realtime Authorization](/docs/guides/realtime/authorization). + +- **Enabled**: no policy check runs, but anyone holding your project's anon key can subscribe to and broadcast on any public channel. +- **Disabled**: every join is checked against the Row Level Security policies on `realtime.messages`, so each join costs one authorization query. Clients that don't set `config.private` to `true` are rejected with `PrivateOnly`. With no policies, clients connect but receive no messages. + +### Database connection pool size + +**Type:** Number of connections · **Range:** 1 to your database's `max_connections` · **Default:** varies by compute size + +Determines the number of connections used for Realtime Authorization RLS checking. Results are cached per client, so the pool is used on each private channel join, each `access_token` refresh, and each private Broadcast REST request. + +- **Too low**: checks queue and time out. Clients receive `IncreaseConnectionPool`, broadcasts are dropped, and presence calls fail. Once timeouts in a 30-second window reach the pool size, later checks fail immediately without reaching the database. +- **Too high**: the pool competes with your application for your database's `max_connections`. If Realtime's total requirement doesn't fit, it refuses to start with `DatabaseLackOfConnections`. + +See [Database connections](/docs/guides/realtime/concepts#database-connections) for the defaults per compute size. + +### Postgres Changes connection pool size + +**Type:** Number of connections · **Range:** 1 to 20 · **Default:** 2 + +Determines the number of connections used to create [Postgres Changes](/docs/guides/realtime/postgres-changes) subscriptions when clients subscribe. It's only used while subscriptions are created; streaming the changes uses a separate connection. + +- **Too low**: subscription creation times out during bursts. Clients receive a `postgres_changes` system error with `Too many database timeouts` and retry after 5 to 10 seconds. +- **Too high**: it counts toward the same connection budget as every other Realtime pool. + +Raise this value if many clients subscribe at the same time, such as after a deploy or a mass reconnect. + +{/* supa-mdx-lint-disable-next-line Rule004ExcludeWords */} + +### Max concurrent clients + +**Type:** Number of clients · **Range:** 1 to your plan's [concurrent connections](/docs/guides/realtime/limits#limits-by-plan) limit · **Default:** your plan's limit + +Determines the maximum number of clients that can be connected. A client is one WebSocket connection, no matter how many channels it joins. + +- **Too low**: new connections are rejected with `429` and `Too many connected users`. Existing clients are unaffected. +- **Too high**: each connection consumes memory on the Realtime nodes, so this setting acts as a capacity and cost control. + +### Max events per second + +**Type:** Number of events per second · **Range:** 1 to your plan's [messages per second](/docs/guides/realtime/limits#limits-by-plan) limit · **Default:** your plan's limit + +Determines the maximum number of events per second that can be sent, measured as a rolling average over the previous minute. An event is a message sent by a client or delivered to one, so one broadcast to 100 subscribers counts as 100 events. + +- **Too low**: channels that exceed the average are closed with `Too many messages per second`, which `supabase-js` recovers from by rejoining. Broadcast REST requests are rejected with `429`; those responses carry `x-rate-limit` and `x-rate-limit-remaining` headers you can use to slow down first. +- **Too high**: Realtime stops throttling broadcast fan-out, which removes the protection against a runaway loop or a mass reconnect, and raises the ceiling on your Realtime spend. + +### Max presence events per second + +**Type:** Number of events per second · **Range:** 1 to your plan's [presence messages per second](/docs/guides/realtime/limits#limits-by-plan) limit · **Default:** your plan's limit + +Determines the maximum number of presence events per second that can be sent, using the same rolling average as [Max events per second](#max-events-per-second) but checked before the event is sent rather than after delivery. + +- **Too low**: presence tracking and syncing fail and the channel is closed with `Too many presence messages per second`. +- **Too high**: rapid `track` and `untrack` cycles can generate presence storms, because each change is broadcast to every client on the channel and also counts toward [Max events per second](#max-events-per-second). + + + +A separate per-client limit also applies to presence, independent of this project-wide setting. A client that exceeds it is closed with `Client presence rate limit exceeded`. See [Realtime Limits](/docs/guides/realtime/limits) for the value on your plan. + + + +### Max payload size in KB + +**Type:** Size in KB · **Range:** 1 to your plan's [broadcast payload size](/docs/guides/realtime/limits#limits-by-plan) limit · **Default:** your plan's limit + +Determines the maximum payload size in KB that can be sent. + +- **Too low**: oversized broadcasts are dropped, and the sender only learns about it if it set `ack_broadcast` to `true`. Broadcast REST requests are rejected with `422`, and an oversized presence `track` closes the channel with `Track message size exceeded`. +- **Too high**: large messages increase memory and bandwidth usage on every subscriber, since each message is delivered to all clients on the channel. Postgres Changes payloads have a [separate limit](/docs/guides/realtime/limits#postgres-changes-payload-limit). diff --git a/apps/studio/components/interfaces/Realtime/RealtimeSettings.test.tsx b/apps/studio/components/interfaces/Realtime/RealtimeSettings.test.tsx new file mode 100644 index 0000000000000..09af13e35954c --- /dev/null +++ b/apps/studio/components/interfaces/Realtime/RealtimeSettings.test.tsx @@ -0,0 +1,204 @@ +import { fireEvent, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { components } from 'api-types' +import { mockAnimationsApi } from 'jsdom-testing-mocks' +import { HttpResponse } from 'msw' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { z } from 'zod' + +import { RealtimeSettings } from './RealtimeSettings' +import type { Entitlement, FeatureKey } from '@/data/entitlements/entitlements-query' +import type { RealtimeConfigurationData } from '@/data/realtime/realtime-config-query' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' + +mockAnimationsApi() + +const { + mockUseAsyncCheckPermissions, + mockUseMaxConnectionsQuery, + mockUseSelectedOrganizationQuery, + mockUseSelectedProjectQuery, + mockUseDatabasePoliciesQuery, +} = vi.hoisted(() => ({ + mockUseAsyncCheckPermissions: vi.fn(), + mockUseMaxConnectionsQuery: vi.fn(), + mockUseSelectedOrganizationQuery: vi.fn(), + mockUseSelectedProjectQuery: vi.fn(), + mockUseDatabasePoliciesQuery: vi.fn(), +})) + +vi.mock('@/hooks/misc/useCheckPermissions', () => ({ + useAsyncCheckPermissions: mockUseAsyncCheckPermissions, +})) + +vi.mock('@/hooks/misc/useSelectedProject', () => ({ + useSelectedProjectQuery: mockUseSelectedProjectQuery, +})) + +vi.mock('@/hooks/misc/useSelectedOrganization', () => ({ + useSelectedOrganizationQuery: mockUseSelectedOrganizationQuery, +})) + +vi.mock('@/data/database/max-connections-query', () => ({ + useMaxConnectionsQuery: mockUseMaxConnectionsQuery, +})) + +vi.mock('@/data/database-policies/database-policies-query', () => ({ + useDatabasePoliciesQuery: mockUseDatabasePoliciesQuery, +})) + +vi.mock('@/lib/constants', async (importOriginal) => { + const actual = await importOriginal>() + return { ...actual, IS_PLATFORM: true } +}) + +const REALTIME_CONFIG = { + connection_pool: 2, + postgres_changes_pool: 2, + max_bytes_per_second: 100000, + max_channels_per_client: 100, + max_concurrent_users: 200, + max_events_per_second: 100, + max_joins_per_second: 100, + max_payload_size_in_kb: 100, + max_presence_events_per_second: 100, + presence_enabled: true, + private_only: false, + suspend: false, +} as const satisfies RealtimeConfigurationData + +const REALTIME_ENTITLEMENTS: Entitlement[] = ( + [ + ['realtime.max_concurrent_users', 50_000], + ['realtime.max_events_per_second', 50_000], + ['realtime.max_presence_events_per_second', 5_000], + ['realtime.max_payload_size_in_kb', 3_000], + ] satisfies [FeatureKey, number][] +).map(([key, value]) => ({ + config: { enabled: true, unit: '', unlimited: false, value }, + feature: { key, type: 'numeric' }, + hasAccess: true, + type: 'numeric', +})) + +describe('RealtimeSettings', () => { + beforeEach(() => { + vi.clearAllMocks() + + mockUseAsyncCheckPermissions.mockReturnValue({ can: true, isSuccess: true }) + mockUseSelectedProjectQuery.mockReturnValue({ + data: { ref: 'default', connectionString: 'postgresql://example' }, + }) + mockUseSelectedOrganizationQuery.mockReturnValue({ + data: { slug: 'default', plan: { id: 'pro' }, usage_billing_enabled: true }, + isSuccess: true, + }) + mockUseMaxConnectionsQuery.mockReturnValue({ data: { maxConnections: 20 } }) + mockUseDatabasePoliciesQuery.mockReturnValue({ data: [], isSuccess: true }) + + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref/config/realtime', + response: () => HttpResponse.json(REALTIME_CONFIG), + }) + + addAPIMock({ + method: 'get', + path: '/platform/organizations/:slug/entitlements', + response: { entitlements: REALTIME_ENTITLEMENTS }, + }) + }) + + test('renders the fetched postgres changes pool value', async () => { + customRender() + + expect(await screen.findByLabelText('Postgres Changes connection pool size')).toHaveValue(2) + }) + + test('falls back to the default postgres changes pool value when omitted', async () => { + const { postgres_changes_pool, ...configWithoutPool } = REALTIME_CONFIG + + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref/config/realtime', + response: () => + HttpResponse.json( + configWithoutPool as any + ), + }) + + customRender() + + expect(await screen.findByLabelText('Postgres Changes connection pool size')).toHaveValue(2) + }) + + test('submits the postgres changes pool value when saving', async () => { + const updateBodySchema = z.object({ postgres_changes_pool: z.number() }) + + const requests: z.infer[] = [] + addAPIMock({ + method: 'patch', + path: '/platform/projects/:ref/config/realtime', + response: async ({ request }) => { + requests.push(updateBodySchema.parse(await request.json())) + return new HttpResponse(null, { status: 204 }) + }, + }) + + customRender() + + const postgresChangesPoolInput = await screen.findByLabelText( + 'Postgres Changes connection pool size' + ) + await userEvent.clear(postgresChangesPoolInput) + await userEvent.type(postgresChangesPoolInput, '5') + + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })) + + const dialog = await screen.findByRole('dialog') + fireEvent.click(within(dialog).getByRole('button', { name: 'Save changes' })) + + await waitFor(() => expect(requests).toHaveLength(1)) + expect(requests[0]).toMatchObject({ postgres_changes_pool: 5 }) + }) + + test.each([ + { value: '1', accepted: true }, + { value: '20', accepted: true }, + { value: '0', accepted: false }, + { value: '21', accepted: false }, + ])('$accepted for postgres changes pool value of $value', async ({ value, accepted }) => { + const requests: unknown[] = [] + addAPIMock({ + method: 'patch', + path: '/platform/projects/:ref/config/realtime', + response: async ({ request }) => { + requests.push(await request.json()) + return new HttpResponse(null, { status: 204 }) + }, + }) + + customRender() + + const postgresChangesPoolInput = await screen.findByLabelText( + 'Postgres Changes connection pool size' + ) + await userEvent.clear(postgresChangesPoolInput) + await userEvent.type(postgresChangesPoolInput, value) + + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })) + + if (accepted) { + const dialog = await screen.findByRole('dialog') + fireEvent.click(within(dialog).getByRole('button', { name: 'Save changes' })) + + await waitFor(() => expect(requests).toHaveLength(1)) + expect(requests[0]).toMatchObject({ postgres_changes_pool: Number(value) }) + } else { + await waitFor(() => expect(postgresChangesPoolInput).toHaveAttribute('aria-invalid', 'true')) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(requests).toHaveLength(0) + } + }) +}) diff --git a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx index 13963a9f4a1f0..fa1d2643c5dc1 100644 --- a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx +++ b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx @@ -50,6 +50,8 @@ const REALTIME_SOFT_LIMITS = { max_payload_size_in_kb: 3_000, } +const MAX_POSTGRES_CHANGES_POOL = 20 + export const RealtimeSettings = () => { const { ref: projectRef } = useParams() const { data: project } = useSelectedProjectQuery() @@ -141,6 +143,7 @@ export const RealtimeSettings = () => { .min(1) .max(maxConn?.maxConnections ?? 100) .optional(), + postgres_changes_pool: z.coerce.number().min(1).max(MAX_POSTGRES_CHANGES_POOL).optional(), max_concurrent_users: z.coerce .number() .min(1) @@ -186,6 +189,7 @@ export const RealtimeSettings = () => { .number() .min(1) .max(maxConn?.maxConnections ?? 100), + postgres_changes_pool: z.coerce.number().min(1).max(MAX_POSTGRES_CHANGES_POOL), max_concurrent_users: z.coerce .number() .min(1) @@ -226,6 +230,8 @@ export const RealtimeSettings = () => { const configValues = data ?? REALTIME_DEFAULT_CONFIG const sharedFormValues = { connection_pool: configValues.connection_pool ?? REALTIME_DEFAULT_CONFIG.connection_pool, + postgres_changes_pool: + configValues.postgres_changes_pool ?? REALTIME_DEFAULT_CONFIG.postgres_changes_pool, max_concurrent_users: configValues.max_concurrent_users ?? REALTIME_DEFAULT_CONFIG.max_concurrent_users, max_events_per_second: @@ -278,6 +284,11 @@ export const RealtimeSettings = () => { connection_pool: Number( values.connection_pool ?? data?.connection_pool ?? REALTIME_DEFAULT_CONFIG.connection_pool ), + postgres_changes_pool: Number( + values.postgres_changes_pool ?? + data?.postgres_changes_pool ?? + REALTIME_DEFAULT_CONFIG.postgres_changes_pool + ), max_concurrent_users: Number( values.max_concurrent_users ?? data?.max_concurrent_users ?? @@ -468,6 +479,35 @@ export const RealtimeSettings = () => { )} /> + + ( + + + + + + connections + + + + + )} + /> + export interface operations { - postV1WebhooksEvents: { + 'v1-webhooks-events-post': { parameters: { query?: never header?: never @@ -2709,7 +2706,7 @@ export interface operations { } } } - allV2OrganizationsBySlugWebhooks: { + 'v2-organizations-slug-webhooks-deliveries-id-get': { parameters: { query?: never header?: never @@ -3156,7 +3153,7 @@ export interface operations { } } } - allV2OrganizationsBySlugWebhooks: { + 'v2-organizations-slug-webhooks-deliveries-id-retry-post': { parameters: { query?: never header?: never @@ -3499,7 +3496,7 @@ export interface operations { } } } - allV2OrganizationsBySlugWebhooks: { + 'v2-organizations-slug-webhooks-endpoints-get': { parameters: { query?: { /** @description Up to how many records to return. */ @@ -3908,7 +3905,7 @@ export interface operations { } } } - allV2OrganizationsBySlugWebhooks: { + 'v2-organizations-slug-webhooks-endpoints-post': { parameters: { query?: never header?: never @@ -4362,7 +4359,7 @@ export interface operations { } } } - allV2OrganizationsBySlugWebhooks: { + 'v2-organizations-slug-webhooks-endpoints-delete': { parameters: { query?: never header?: never @@ -4742,7 +4739,7 @@ export interface operations { } } } - allV2OrganizationsBySlugWebhooks: { + 'v2-organizations-slug-webhooks-endpoints-id-get': { parameters: { query?: never header?: never @@ -5161,7 +5158,7 @@ export interface operations { } } } - allV2OrganizationsBySlugWebhooks: { + 'v2-organizations-slug-webhooks-endpoints-id-delete': { parameters: { query?: never header?: never @@ -5580,7 +5577,7 @@ export interface operations { } } } - allV2OrganizationsBySlugWebhooks: { + 'v2-organizations-slug-webhooks-endpoints-id-patch': { parameters: { query?: never header?: never @@ -6070,7 +6067,7 @@ export interface operations { } } } - allV2OrganizationsBySlugWebhooks: { + 'v2-organizations-slug-webhooks-endpoints-id-deliveries-get': { parameters: { query?: { /** @description Cursor in cursor-based pagination to return up to `page[size]` records after (exclusive) the entry specified by this query param. */ @@ -6482,7 +6479,7 @@ export interface operations { } } } - allV2OrganizationsBySlugWebhooks: { + 'v2-organizations-slug-webhooks-endpoints-id-test-post': { parameters: { query?: never header?: never @@ -7584,7 +7581,7 @@ export interface operations { } } } - allV2ProjectsByRefWebhooks: { + 'v2-projects-ref-webhooks-deliveries-id-get': { parameters: { query?: never header?: never @@ -8031,7 +8028,7 @@ export interface operations { } } } - allV2ProjectsByRefWebhooks: { + 'v2-projects-ref-webhooks-deliveries-id-retry-post': { parameters: { query?: never header?: never @@ -8374,7 +8371,7 @@ export interface operations { } } } - allV2ProjectsByRefWebhooks: { + 'v2-projects-ref-webhooks-endpoints-get': { parameters: { query?: { /** @description Up to how many records to return. */ @@ -8783,7 +8780,7 @@ export interface operations { } } } - allV2ProjectsByRefWebhooks: { + 'v2-projects-ref-webhooks-endpoints-post': { parameters: { query?: never header?: never @@ -9237,7 +9234,7 @@ export interface operations { } } } - allV2ProjectsByRefWebhooks: { + 'v2-projects-ref-webhooks-endpoints-delete': { parameters: { query?: never header?: never @@ -9617,7 +9614,7 @@ export interface operations { } } } - allV2ProjectsByRefWebhooks: { + 'v2-projects-ref-webhooks-endpoints-id-get': { parameters: { query?: never header?: never @@ -10036,7 +10033,7 @@ export interface operations { } } } - allV2ProjectsByRefWebhooks: { + 'v2-projects-ref-webhooks-endpoints-id-delete': { parameters: { query?: never header?: never @@ -10455,7 +10452,7 @@ export interface operations { } } } - allV2ProjectsByRefWebhooks: { + 'v2-projects-ref-webhooks-endpoints-id-patch': { parameters: { query?: never header?: never @@ -10945,7 +10942,7 @@ export interface operations { } } } - allV2ProjectsByRefWebhooks: { + 'v2-projects-ref-webhooks-endpoints-id-deliveries-get': { parameters: { query?: { /** @description Cursor in cursor-based pagination to return up to `page[size]` records after (exclusive) the entry specified by this query param. */ @@ -11357,7 +11354,7 @@ export interface operations { } } } - allV2ProjectsByRefWebhooks: { + 'v2-projects-ref-webhooks-endpoints-id-test-post': { parameters: { query?: never header?: never diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index 2cc15ae84dc8c..ed2eb431fbb53 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -5001,6 +5001,26 @@ export interface paths { patch?: never trace?: never } + '/platform/warehouse/{ref}/setup': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + /** + * Set up Warehouse + * @description Ensure the project Warehouse pipeline exists, add the requested schemas and tables to its publication, and start syncing. Schema targets include the currently eligible tables in that schema. Warehouse FDW installation is opt-in. + */ + post: operations['WarehouseController_setup'] + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/platform/warehouse/{ref}/setup-status': { parameters: { query?: never @@ -5034,11 +5054,7 @@ export interface paths { */ get: operations['WarehouseController_getTables'] put?: never - /** - * Copy a table to Warehouse - * @description Ensure the project Warehouse pipeline exists, add the table to its publication, and start syncing. Warehouse FDW installation is opt-in. - */ - post: operations['WarehouseController_linkTable'] + post?: never delete?: never options?: never head?: never @@ -5695,8 +5711,8 @@ export interface components { note?: string }[] } - CreateNotificationExceptionsResponse: { - exceptions: { + CreateNotificationExceptionsResponse_Output: { + exceptions: ({ /** Format: uuid */ assigned_to: string | null /** Format: uuid */ @@ -5709,11 +5725,13 @@ export interface components { lint_category: string | null lint_metadata?: { [key: string]: unknown - } + } | null lint_name: string | null note: string | null project_ref: string - }[] + } & { + [key: string]: unknown + })[] } CreateOAuthAppBody: { icon?: string @@ -8688,6 +8706,7 @@ export interface components { | 'project_restore_after_expiry' | 'assistant.advance_model' | 'integrations.github_connections' + | 'integrations.github_push_webhooks_limit' | 'dedicated_pooler' | 'observability.dashboard_advanced_metrics' | 'api.members.invitations' @@ -8736,8 +8755,8 @@ export interface components { name: string }[] } - ListNotificationExceptionsResponse: { - exceptions: { + ListNotificationExceptionsResponse_Output: { + exceptions: ({ /** Format: uuid */ assigned_to: string | null /** Format: uuid */ @@ -8750,11 +8769,13 @@ export interface components { lint_category: string | null lint_metadata?: { [key: string]: unknown - } + } | null lint_name: string | null note: string | null project_ref: string - }[] + } & { + [key: string]: unknown + })[] } ListOAuthAppClientSecretsResponse: { client_secrets: { @@ -10249,6 +10270,8 @@ export interface components { max_payload_size_in_kb: number | null /** @description Sets maximum number of presence events per second rate limit */ max_presence_events_per_second: number | null + /** @description Sets connection pool size used to create Postgres Changes subscriptions */ + postgres_changes_pool: number | null /** @description Whether to enable presence */ presence_enabled: boolean /** @description Whether to only allow private channels */ @@ -11768,7 +11791,7 @@ export interface components { project_ref?: string user_id: string } - TemporaryApiKeyResponse: { + TemporaryApiKeyResponse_Output: { api_key: string } TransferProjectBody: { @@ -12618,6 +12641,8 @@ export interface components { max_payload_size_in_kb?: number /** @description Sets maximum number of presence events per second rate limit */ max_presence_events_per_second?: number + /** @description Sets connection pool size used to create Postgres Changes subscriptions */ + postgres_changes_pool?: number /** @description Whether to enable presence */ presence_enabled?: boolean /** @description Whether to only allow private channels */ @@ -14471,61 +14496,85 @@ export interface components { /** @description Whether external catalog access is enabled */ enabled: boolean } - WarehouseLinkedTable: { - /** - * @description Warehouse-facing table name - * @example warehouse.orders - */ - copy_name: string - /** - * @description Replication lag in milliseconds, when available - * @example 12000 - */ - lag_ms?: number - /** - * Format: date-time - * @description Last sync timestamp, when available - * @example 2026-06-23T17:48:00Z - */ - last_synced_at?: string - /** - * @description Postgres table name - * @example orders - */ - name: string - /** - * @description Postgres schema name - * @example public - */ - schema: string - /** - * @description Warehouse copy sync state derived from replication status - * @example live - * @enum {string} - */ - state: 'syncing' | 'live' | 'error' - /** - * @description Warehouse table size in bytes, when available - * @example 197912092672 - */ - warehouse_size_bytes?: number - } - WarehouseLinkTableBody: { + WarehouseSetupBody: { /** * @description Whether to configure and install the Warehouse FDW in the project database. Defaults to false. * @example false */ install_fdw?: boolean + /** @description Schemas and individual tables to copy. Schema targets expand to the eligible tables present when the request is processed. */ + targets: ( + | { + /** + * @description Postgres schema whose currently eligible tables should be copied + * @example public + */ + schema: string + /** @enum {string} */ + type: 'schema' + } + | { + /** + * @description Postgres table name + * @example orders + */ + name: string + /** + * @description Postgres schema name + * @example public + */ + schema: string + /** @enum {string} */ + type: 'table' + } + )[] + } + WarehouseSetupResponse: { /** - * @description Postgres table name - * @example orders - */ - name: string - /** - * @description Postgres schema name - * @example public + * @description Warehouse replication pipeline id + * @example 101 */ - schema: string + pipeline_id: number + /** @description Tables with Warehouse copies */ + tables: { + /** + * @description DuckLake schema-qualified table name + * @example public.orders + */ + copy_name: string + /** + * @description Replication lag in milliseconds, when available + * @example 12000 + */ + lag_ms?: number + /** + * Format: date-time + * @description Last sync timestamp, when available + * @example 2026-06-23T17:48:00Z + */ + last_synced_at?: string + /** + * @description Postgres table name + * @example orders + */ + name: string + /** + * @description Postgres schema name + * @example public + */ + schema: string + /** + * @description Warehouse copy sync state derived from replication status + * @example live + * @enum {string} + */ + state: 'syncing' | 'live' | 'error' + /** + * @description Warehouse table size in bytes, when available + * @example 197912092672 + */ + warehouse_size_bytes?: number + }[] } WarehouseSetupStatusResponse: { /** @description Project database FDW setup markers used to derive the Warehouse FDW phase */ @@ -14595,8 +14644,8 @@ export interface components { /** @description Warehouse linked tables and replication-derived sync state */ tables: { /** - * @description Warehouse-facing table name - * @example warehouse.orders + * @description DuckLake schema-qualified table name + * @example public.orders */ copy_name: string /** @@ -14668,8 +14717,8 @@ export interface components { /** @description Tables with Warehouse copies */ tables: { /** - * @description Warehouse-facing table name - * @example warehouse.orders + * @description DuckLake schema-qualified table name + * @example public.orders */ copy_name: string /** @@ -22642,6 +22691,7 @@ export interface operations { | 'project_restore_after_expiry' | 'assistant.advance_model' | 'integrations.github_connections' + | 'integrations.github_push_webhooks_limit' | 'dedicated_pooler' | 'observability.dashboard_advanced_metrics' | 'api.members.invitations' @@ -24462,6 +24512,13 @@ export interface operations { 'text/plain': string } } + /** @description Project must be active and healthy, or metrics are not available for this project */ + 400: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Unauthorized */ 401: { headers: { @@ -24513,7 +24570,7 @@ export interface operations { [name: string]: unknown } content: { - 'application/json': components['schemas']['TemporaryApiKeyResponse'] + 'application/json': components['schemas']['TemporaryApiKeyResponse_Output'] } } /** @description Unauthorized */ @@ -26765,7 +26822,7 @@ export interface operations { [name: string]: unknown } content: { - 'application/json': components['schemas']['ListNotificationExceptionsResponse'] + 'application/json': components['schemas']['ListNotificationExceptionsResponse_Output'] } } /** @description Unauthorized */ @@ -26819,7 +26876,7 @@ export interface operations { [name: string]: unknown } content: { - 'application/json': components['schemas']['CreateNotificationExceptionsResponse'] + 'application/json': components['schemas']['CreateNotificationExceptionsResponse_Output'] } } /** @description Unauthorized */ @@ -32574,7 +32631,7 @@ export interface operations { } } } - WarehouseController_getSetupStatus: { + WarehouseController_setup: { parameters: { query?: never header?: never @@ -32584,17 +32641,28 @@ export interface operations { } cookie?: never } - requestBody?: never + requestBody: { + content: { + 'application/json': components['schemas']['WarehouseSetupBody'] + } + } responses: { - /** @description Warehouse setup status. */ - 200: { + /** @description Warehouse setup accepted. */ + 202: { headers: { [name: string]: unknown } content: { - 'application/json': components['schemas']['WarehouseSetupStatusResponse'] + 'application/json': components['schemas']['WarehouseSetupResponse'] } } + /** @description A requested table or schema is not eligible for Warehouse replication. */ + 400: { + headers: { + [name: string]: unknown + } + content?: never + } /** @description Unauthorized */ 401: { headers: { @@ -32602,6 +32670,15 @@ export interface operations { } content?: never } + /** @description This feature requires the Pro, Team, or Enterprise organization plan. */ + 402: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['PlanGateErrorBody'] + } + } /** @description Forbidden action */ 403: { headers: { @@ -32616,7 +32693,7 @@ export interface operations { } content?: never } - /** @description Unexpected error while retrieving Warehouse setup status. */ + /** @description Unexpected error while setting up Warehouse. */ 500: { headers: { [name: string]: unknown @@ -32625,7 +32702,7 @@ export interface operations { } } } - WarehouseController_getTables: { + WarehouseController_getSetupStatus: { parameters: { query?: never header?: never @@ -32637,13 +32714,13 @@ export interface operations { } requestBody?: never responses: { - /** @description Warehouse linked tables. */ + /** @description Warehouse setup status. */ 200: { headers: { [name: string]: unknown } content: { - 'application/json': components['schemas']['WarehouseTablesResponse'] + 'application/json': components['schemas']['WarehouseSetupStatusResponse'] } } /** @description Unauthorized */ @@ -32667,7 +32744,7 @@ export interface operations { } content?: never } - /** @description Unexpected error while listing Warehouse tables. */ + /** @description Unexpected error while retrieving Warehouse setup status. */ 500: { headers: { [name: string]: unknown @@ -32676,7 +32753,7 @@ export interface operations { } } } - WarehouseController_linkTable: { + WarehouseController_getTables: { parameters: { query?: never header?: never @@ -32686,19 +32763,15 @@ export interface operations { } cookie?: never } - requestBody: { - content: { - 'application/json': components['schemas']['WarehouseLinkTableBody'] - } - } + requestBody?: never responses: { - /** @description Warehouse table link accepted. */ - 202: { + /** @description Warehouse linked tables. */ + 200: { headers: { [name: string]: unknown } content: { - 'application/json': components['schemas']['WarehouseLinkedTable'] + 'application/json': components['schemas']['WarehouseTablesResponse'] } } /** @description Unauthorized */ @@ -32708,15 +32781,6 @@ export interface operations { } content?: never } - /** @description This feature requires the Pro, Team, or Enterprise organization plan. */ - 402: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['PlanGateErrorBody'] - } - } /** @description Forbidden action */ 403: { headers: { @@ -32731,7 +32795,7 @@ export interface operations { } content?: never } - /** @description Unexpected error while linking Warehouse table. */ + /** @description Unexpected error while listing Warehouse tables. */ 500: { headers: { [name: string]: unknown From 12a8e31fa6e13af640b17ab86dbb8d8defd4aa0f Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:32:22 +0200 Subject: [PATCH 08/11] chore(studio): render Sign in with ChatGPT unconditionally (#49375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Requested by **Ivan Vasilov** · [Slack thread](https://supabase.slack.com/archives/C0161K73J1J/p1787296019236949?thread_ts=1787296019.236949&cid=C0161K73J1J)_ ## 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? Chore / feature-flag cleanup. ## What is the current behavior? The "Sign in with ChatGPT" button on `/sign-in` and `/sign-up` sits behind three gates in `useEnabledIdentityProviders`: 1. the static `dashboard_auth:sign_in_with_chatgpt` feature flag, AND 2. either the `ShowSignInWithChatGptButton` ConfigCat flag, OR 3. the `SIGN_IN_CHATGPT_ENABLED` (`siwc-enabled`) localStorage opt-in, flipped by a shareable `?siwc-enabled=1` link via `useSiwcQueryParamOptIn`. The ConfigCat flag resolves client-side, so on a fresh load the button is absent for the first render and appears once the flag comes back. That pushes the rest of the sign-in options down and produces a visible layout shift on the sign-in page. ## What is the new behavior? ChatGPT is gated only by its static `dashboard_auth:sign_in_with_chatgpt` feature flag, which is resolved synchronously from `enabled-features.json`. The button renders on the first paint, with no async re-layout. Removed: - the `useFlag('ShowSignInWithChatGptButton')` call and the `chatgptLocalStorageEnabled || chatGptConfigCatFlagEnabled` branch in `apps/studio/hooks/misc/useEnabledIdentityProviders.ts` - `LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED` and its `LOCAL_STORAGE_KEYS_ALLOWLIST` entry in `packages/common/constants/local-storage.ts` - `apps/studio/hooks/misc/useSiwcQueryParamOptIn.ts` and its callers in `pages/sign-in.tsx` / `pages/sign-up.tsx` — its only job was writing that localStorage flag - the tests that covered the two removed rollout gates The static `dashboard_auth:sign_in_with_chatgpt` kill switch is untouched. ## Additional context The `ShowSignInWithChatGptButton` ConfigCat flag is reported as 100% enabled (per Joshen Lim in the linked thread). The repo contains no default value, allowlist, or env gate for it — the live value lives only in ConfigCat, so that number is not verifiable from here. Once this merges the flag is unreferenced and should be **archived in ConfigCat by a human**; nothing in ConfigCat was changed as part of this PR. Verification notes: `packages/common` typechecks clean (`tsc --noEmit`) and all touched files pass the repo's Prettier config. Studio's `typecheck`, `lint`, and `vitest` could not be run here — `pnpm install` fails in this environment because `npm.jsr.io` (needed for studio's `@std/path` dependency) is not reachable through the network allowlist, so `apps/studio/node_modules` was never installed. CI should be treated as the first real run of those checks. --- _Generated by [Claude Code](https://claude.ai/code/session_01M21SPvwf6FSthomX4Lj3ZC)_ Co-authored-by: Claude --- .../useEnabledIdentityProviders.test.ts | 93 +------------------ .../__tests__/useSiwcQueryParamOptIn.test.ts | 87 ----------------- .../hooks/misc/useEnabledIdentityProviders.ts | 23 +---- .../hooks/misc/useSiwcQueryParamOptIn.ts | 27 ------ apps/studio/pages/sign-in.tsx | 3 - apps/studio/pages/sign-up.tsx | 3 - apps/studio/tests/pages/sign-in.test.tsx | 70 -------------- apps/studio/tests/pages/sign-up.test.tsx | 46 --------- packages/common/constants/local-storage.ts | 2 - 9 files changed, 7 insertions(+), 347 deletions(-) delete mode 100644 apps/studio/hooks/misc/__tests__/useSiwcQueryParamOptIn.test.ts delete mode 100644 apps/studio/hooks/misc/useSiwcQueryParamOptIn.ts delete mode 100644 apps/studio/tests/pages/sign-in.test.tsx delete mode 100644 apps/studio/tests/pages/sign-up.test.tsx diff --git a/apps/studio/hooks/misc/__tests__/useEnabledIdentityProviders.test.ts b/apps/studio/hooks/misc/__tests__/useEnabledIdentityProviders.test.ts index 8c42bd61283cb..705ffd20a9375 100644 --- a/apps/studio/hooks/misc/__tests__/useEnabledIdentityProviders.test.ts +++ b/apps/studio/hooks/misc/__tests__/useEnabledIdentityProviders.test.ts @@ -1,5 +1,5 @@ import { renderHook } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { useEnabledIdentityProviders } from '../useEnabledIdentityProviders' import { @@ -8,22 +8,11 @@ import { } from '@/lib/external-identity-providers' const mockIsFeatureEnabled = vi.hoisted(() => vi.fn()) -const mockUseLocalStorageQuery = vi.hoisted(() => vi.fn()) -const mockUseFlag = vi.hoisted(() => vi.fn()) vi.mock('../useIsFeatureEnabled', () => ({ useIsFeatureEnabled: mockIsFeatureEnabled, })) -vi.mock('../useLocalStorage', () => ({ - useLocalStorageQuery: mockUseLocalStorageQuery, -})) - -vi.mock('common', async (importOriginal) => ({ - ...(await importOriginal()), - useFlag: mockUseFlag, -})) - function mockFeatures({ github = false, chatgpt = true }: { github?: boolean; chatgpt?: boolean }) { mockIsFeatureEnabled.mockReturnValue({ dashboardAuthSignInWithGithub: github, @@ -32,14 +21,8 @@ function mockFeatures({ github = false, chatgpt = true }: { github?: boolean; ch } describe('useEnabledIdentityProviders', () => { - beforeEach(() => { - mockUseFlag.mockReset() - }) - it('returns every provider when all flags are enabled', () => { mockFeatures({ github: true, chatgpt: true }) - mockUseLocalStorageQuery.mockReturnValue([true]) - mockUseFlag.mockReturnValue(true) const { result } = renderHook(() => useEnabledIdentityProviders()) @@ -48,88 +31,22 @@ describe('useEnabledIdentityProviders', () => { it('returns no providers when all flags are disabled', () => { mockFeatures({ github: false, chatgpt: false }) - mockUseLocalStorageQuery.mockReturnValue([false]) - mockUseFlag.mockReturnValue(false) const { result } = renderHook(() => useEnabledIdentityProviders()) expect(result.current).toEqual([]) }) - it('includes ChatGPT when localStorage is true and configcat is true', () => { - mockFeatures({ chatgpt: true }) - mockUseLocalStorageQuery.mockReturnValue([true]) - mockUseFlag.mockReturnValue(true) - - const { result } = renderHook(() => useEnabledIdentityProviders()) - - expect(result.current).toEqual([CHATGPT_IDENTITY_PROVIDER]) - }) - - it('includes ChatGPT when localStorage is true and configcat is false', () => { - mockFeatures({ chatgpt: true }) - mockUseLocalStorageQuery.mockReturnValue([true]) - mockUseFlag.mockReturnValue(false) - - const { result } = renderHook(() => useEnabledIdentityProviders()) - - expect(result.current).toEqual([CHATGPT_IDENTITY_PROVIDER]) - }) - - it('includes ChatGPT when localStorage is false and configcat is true', () => { + it('includes ChatGPT when its feature flag is enabled', () => { mockFeatures({ chatgpt: true }) - mockUseLocalStorageQuery.mockReturnValue([false]) - mockUseFlag.mockReturnValue(true) const { result } = renderHook(() => useEnabledIdentityProviders()) expect(result.current).toEqual([CHATGPT_IDENTITY_PROVIDER]) }) - it('excludes ChatGPT when localStorage is false and configcat is false', () => { - mockFeatures({ chatgpt: true }) - mockUseLocalStorageQuery.mockReturnValue([false]) - mockUseFlag.mockReturnValue(false) - - const { result } = renderHook(() => useEnabledIdentityProviders()) - - expect(result.current).toEqual([]) - }) - - it('excludes ChatGPT when its feature flag is disabled but configcat is true', () => { - mockFeatures({ chatgpt: false }) - mockUseLocalStorageQuery.mockReturnValue([false]) - mockUseFlag.mockReturnValue(true) - - const { result } = renderHook(() => useEnabledIdentityProviders()) - - expect(result.current).toEqual([]) - }) - - it('excludes ChatGPT when its feature flag is disabled but localStorage is opted in', () => { - mockFeatures({ chatgpt: false }) - mockUseLocalStorageQuery.mockReturnValue([true]) - mockUseFlag.mockReturnValue(false) - - const { result } = renderHook(() => useEnabledIdentityProviders()) - - expect(result.current).toEqual([]) - }) - - it('excludes ChatGPT when its feature flag is disabled and both rollout gates are on', () => { + it('excludes ChatGPT when its feature flag is disabled', () => { mockFeatures({ github: true, chatgpt: false }) - mockUseLocalStorageQuery.mockReturnValue([true]) - mockUseFlag.mockReturnValue(true) - - const { result } = renderHook(() => useEnabledIdentityProviders()) - - expect(result.current).toEqual([GITHUB_IDENTITY_PROVIDER]) - }) - - it('includes GitHub when its feature flag is enabled', () => { - mockFeatures({ github: true, chatgpt: true }) - mockUseLocalStorageQuery.mockReturnValue([false]) - mockUseFlag.mockReturnValue(false) const { result } = renderHook(() => useEnabledIdentityProviders()) @@ -138,11 +55,9 @@ describe('useEnabledIdentityProviders', () => { it('excludes GitHub when its feature flag is disabled', () => { mockFeatures({ github: false, chatgpt: true }) - mockUseLocalStorageQuery.mockReturnValue([false]) - mockUseFlag.mockReturnValue(false) const { result } = renderHook(() => useEnabledIdentityProviders()) - expect(result.current).toEqual([]) + expect(result.current).toEqual([CHATGPT_IDENTITY_PROVIDER]) }) }) diff --git a/apps/studio/hooks/misc/__tests__/useSiwcQueryParamOptIn.test.ts b/apps/studio/hooks/misc/__tests__/useSiwcQueryParamOptIn.test.ts deleted file mode 100644 index 71f2acc58d081..0000000000000 --- a/apps/studio/hooks/misc/__tests__/useSiwcQueryParamOptIn.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { renderHook } from '@testing-library/react' -import mockRouter from 'next-router-mock' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { useSiwcQueryParamOptIn } from '../useSiwcQueryParamOptIn' - -vi.mock('next/router', () => import('next-router-mock')) - -// tests/vitestSetup.ts globally mocks `common`'s useParams to always return `{ ref: 'default' }`, -// which would make this hook's `siwcEnabled` lookup always undefined. Restore the real -// implementation here so useParams reflects the mocked router's query params. -vi.mock('common', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual } -}) - -const mockSetValue = vi.hoisted(() => vi.fn()) -const mockUseLocalStorageQuery = vi.hoisted(() => vi.fn()) - -vi.mock('../useLocalStorage', () => ({ - useLocalStorageQuery: mockUseLocalStorageQuery, -})) - -describe('useSiwcQueryParamOptIn', () => { - beforeEach(() => { - mockRouter.setCurrentUrl('/sign-in') - mockSetValue.mockClear() - mockUseLocalStorageQuery.mockReturnValue([false, mockSetValue]) - }) - - it('enables the flag when siwc-enabled=1 is present', () => { - mockRouter.setCurrentUrl('/sign-in?siwc-enabled=1') - - renderHook(() => useSiwcQueryParamOptIn()) - - expect(mockSetValue).toHaveBeenCalledWith(true) - }) - - it('does nothing when the param is absent', () => { - renderHook(() => useSiwcQueryParamOptIn()) - - expect(mockSetValue).not.toHaveBeenCalled() - }) - - it('does nothing for a non-"1" value', () => { - mockRouter.setCurrentUrl('/sign-in?siwc-enabled=true') - - renderHook(() => useSiwcQueryParamOptIn()) - - expect(mockSetValue).not.toHaveBeenCalled() - }) - - it('does nothing when siwc-enabled=0', () => { - mockRouter.setCurrentUrl('/sign-in?siwc-enabled=0') - - renderHook(() => useSiwcQueryParamOptIn()) - - expect(mockSetValue).not.toHaveBeenCalled() - }) - - it('only considers the first value when the param is repeated (array value)', () => { - // useParams (from 'common') flattens repeated query params to their first occurrence, so - // only the first "0" here is seen by the hook, and it does nothing. - mockRouter.setCurrentUrl('/sign-in?siwc-enabled=0&siwc-enabled=1') - - renderHook(() => useSiwcQueryParamOptIn()) - - expect(mockSetValue).not.toHaveBeenCalled() - }) - - it('still calls the setter when the flag is already true (idempotent no-op is the setter’s job)', () => { - mockUseLocalStorageQuery.mockReturnValue([true, mockSetValue]) - mockRouter.setCurrentUrl('/sign-in?siwc-enabled=1') - - renderHook(() => useSiwcQueryParamOptIn()) - - expect(mockSetValue).toHaveBeenCalledWith(true) - }) - - it('works the same way on the sign-up URL', () => { - mockRouter.setCurrentUrl('/sign-up?siwc-enabled=1') - - renderHook(() => useSiwcQueryParamOptIn()) - - expect(mockSetValue).toHaveBeenCalledWith(true) - }) -}) diff --git a/apps/studio/hooks/misc/useEnabledIdentityProviders.ts b/apps/studio/hooks/misc/useEnabledIdentityProviders.ts index 681bedce42bb9..161cf8b3d8060 100644 --- a/apps/studio/hooks/misc/useEnabledIdentityProviders.ts +++ b/apps/studio/hooks/misc/useEnabledIdentityProviders.ts @@ -1,8 +1,6 @@ -import { LOCAL_STORAGE_KEYS, useFlag } from 'common' import { useMemo } from 'react' import { useIsFeatureEnabled } from './useIsFeatureEnabled' -import { useLocalStorageQuery } from './useLocalStorage' import { CHATGPT_IDENTITY_PROVIDER, GITHUB_IDENTITY_PROVIDER, @@ -13,37 +11,22 @@ import { * Returns the statically-declared identity providers whose feature flag is currently enabled. * To add a provider: declare its config in `lib/external-identity-providers.ts`, add a * `dashboard_auth:sign_in_with_*` flag, and gate it here. - * - * ChatGPT carries an extra rollout gate on top of its `dashboard_auth:sign_in_with_chatgpt` flag: - * the feature flag must be enabled AND either the `ShowSignInWithChatGptButton` ConfigCat flag or a - * manual, localStorage-only opt-in switch (`LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED`, flippable - * via the `?siwc-enabled=1` query param — see `useSiwcQueryParamOptIn`) must be on. The feature flag - * is the static kill switch; the OR'd pair is the progressive rollout mechanism. */ export function useEnabledIdentityProviders(): ExternalIdentityProviderConfig[] { const { dashboardAuthSignInWithGithub: githubEnabled, - dashboardAuthSignInWithChatgpt: chatgptFeatureEnabled, + dashboardAuthSignInWithChatgpt: chatgptEnabled, } = useIsFeatureEnabled([ 'dashboard_auth:sign_in_with_github', 'dashboard_auth:sign_in_with_chatgpt', ]) - const [chatgptLocalStorageEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED, - false - ) - const chatGptConfigCatFlagEnabled = useFlag('ShowSignInWithChatGptButton') - - const isChatGptEnabled = - chatgptFeatureEnabled && (chatgptLocalStorageEnabled || chatGptConfigCatFlagEnabled) - return useMemo( () => [ githubEnabled && GITHUB_IDENTITY_PROVIDER, - isChatGptEnabled && CHATGPT_IDENTITY_PROVIDER, + chatgptEnabled && CHATGPT_IDENTITY_PROVIDER, ].filter((p): p is ExternalIdentityProviderConfig => Boolean(p)), - [githubEnabled, isChatGptEnabled] + [githubEnabled, chatgptEnabled] ) } diff --git a/apps/studio/hooks/misc/useSiwcQueryParamOptIn.ts b/apps/studio/hooks/misc/useSiwcQueryParamOptIn.ts deleted file mode 100644 index 400c7a71b03e9..0000000000000 --- a/apps/studio/hooks/misc/useSiwcQueryParamOptIn.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { LOCAL_STORAGE_KEYS, useParams } from 'common' -import { useEffect } from 'react' - -import { useLocalStorageQuery } from './useLocalStorage' - -/** - * Lets a shareable link (e.g. `/sign-in?siwc-enabled=1`) flip on the manual ChatGPT sign-in - * rollout switch (`LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED`, read by - * `useEnabledIdentityProviders`) for this browser, instead of requiring a devtools localStorage - * edit. Only ever meaningful on `/sign-in` and `/sign-up`, where this param would be linked to. - * - * Only the exact string `'1'` opts in; the flag is never cleared based on the param's absence, so - * it persists once set. - */ -export function useSiwcQueryParamOptIn() { - const { siwcEnabled } = useParams() - const [, setChatgptLocalStorageEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED, - false - ) - - useEffect(() => { - if (siwcEnabled === '1') { - setChatgptLocalStorageEnabled(true) - } - }, [siwcEnabled, setChatgptLocalStorageEnabled]) -} diff --git a/apps/studio/pages/sign-in.tsx b/apps/studio/pages/sign-in.tsx index 24335a2af7850..9cf0b1c6ea513 100644 --- a/apps/studio/pages/sign-in.tsx +++ b/apps/studio/pages/sign-in.tsx @@ -14,14 +14,11 @@ import { useCustomContent } from '@/hooks/custom-content/useCustomContent' import { useEnabledIdentityProviders } from '@/hooks/misc/useEnabledIdentityProviders' import { useInboundBranding } from '@/hooks/misc/useInboundBranding' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' -import { useSiwcQueryParamOptIn } from '@/hooks/misc/useSiwcQueryParamOptIn' import { IS_PLATFORM } from '@/lib/constants' import type { ExternalIdentityProviderConfig } from '@/lib/external-identity-providers' import type { NextPageWithLayout } from '@/types' const SignInPage: NextPageWithLayout = () => { - useSiwcQueryParamOptIn() - const router = useRouter() const [showOtherOptions, setShowOtherOptions] = useState(false) diff --git a/apps/studio/pages/sign-up.tsx b/apps/studio/pages/sign-up.tsx index 417f1dc4bfc4b..382a78a36acb6 100644 --- a/apps/studio/pages/sign-up.tsx +++ b/apps/studio/pages/sign-up.tsx @@ -9,13 +9,10 @@ import { UnknownInterface } from '@/components/ui/UnknownInterface' import { useEnabledIdentityProviders } from '@/hooks/misc/useEnabledIdentityProviders' import { useInboundBranding } from '@/hooks/misc/useInboundBranding' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' -import { useSiwcQueryParamOptIn } from '@/hooks/misc/useSiwcQueryParamOptIn' import type { ExternalIdentityProviderConfig } from '@/lib/external-identity-providers' import type { NextPageWithLayout } from '@/types' const SignUpPage: NextPageWithLayout = () => { - useSiwcQueryParamOptIn() - const [showOtherOptions, setShowOtherOptions] = useState(false) const { dashboardAuthSignUp: signUpEnabled } = useIsFeatureEnabled(['dashboard_auth:sign_up']) diff --git a/apps/studio/tests/pages/sign-in.test.tsx b/apps/studio/tests/pages/sign-in.test.tsx deleted file mode 100644 index e0e2b6933e4ce..0000000000000 --- a/apps/studio/tests/pages/sign-in.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { render } from '@testing-library/react' -import type { ReactNode } from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import SignInPage from '@/pages/sign-in' - -const { mockUseSiwcQueryParamOptIn, mockUseIsFeatureEnabled } = vi.hoisted(() => ({ - mockUseSiwcQueryParamOptIn: vi.fn(), - mockUseIsFeatureEnabled: vi.fn(), -})) - -vi.mock('next/router', () => ({ - useRouter: () => ({ query: {}, replace: vi.fn() }), -})) - -vi.mock('@/hooks/misc/useSiwcQueryParamOptIn', () => ({ - useSiwcQueryParamOptIn: mockUseSiwcQueryParamOptIn, -})) - -vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({ - useIsFeatureEnabled: mockUseIsFeatureEnabled, -})) - -vi.mock('@/hooks/custom-content/useCustomContent', () => ({ - useCustomContent: () => ({ - dashboardAuthCustomProvider: undefined, - dashboardAuthCustomProviders: undefined, - }), -})) - -vi.mock('@/hooks/misc/useEnabledIdentityProviders', () => ({ - useEnabledIdentityProviders: () => [], -})) - -vi.mock('@/hooks/misc/useInboundBranding', () => ({ - useInboundBranding: () => ({ focusProvider: undefined }), -})) - -vi.mock('@/components/interfaces/SignIn/LastSignInWrapper', () => ({ - LastSignInWrapper: ({ children }: { children: ReactNode }) =>
{children}
, -})) - -vi.mock('@/components/interfaces/SignIn/SignInForm', () => ({ - SignInForm: () =>
SignInForm
, -})) - -vi.mock('@/components/interfaces/SignIn/SignInWithCustom', () => ({ - SignInWithCustom: () =>
SignInWithCustom
, -})) - -vi.mock('@/components/interfaces/SignIn/SignInWithExternalProvider', () => ({ - SignInWithExternalProvider: () =>
SignInWithExternalProvider
, -})) - -describe('/sign-in', () => { - beforeEach(() => { - mockUseSiwcQueryParamOptIn.mockClear() - mockUseIsFeatureEnabled.mockReturnValue({ - dashboardAuthSignInWithSso: false, - dashboardAuthSignInWithEmail: false, - dashboardAuthSignUp: false, - }) - }) - - it('calls useSiwcQueryParamOptIn so a shareable ?siwc-enabled=1 link can opt this browser in', () => { - render() - - expect(mockUseSiwcQueryParamOptIn).toHaveBeenCalled() - }) -}) diff --git a/apps/studio/tests/pages/sign-up.test.tsx b/apps/studio/tests/pages/sign-up.test.tsx deleted file mode 100644 index 37b54070a365a..0000000000000 --- a/apps/studio/tests/pages/sign-up.test.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { render } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import SignUpPage from '@/pages/sign-up' - -const { mockUseSiwcQueryParamOptIn, mockUseIsFeatureEnabled } = vi.hoisted(() => ({ - mockUseSiwcQueryParamOptIn: vi.fn(), - mockUseIsFeatureEnabled: vi.fn(), -})) - -vi.mock('@/hooks/misc/useSiwcQueryParamOptIn', () => ({ - useSiwcQueryParamOptIn: mockUseSiwcQueryParamOptIn, -})) - -vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({ - useIsFeatureEnabled: mockUseIsFeatureEnabled, -})) - -vi.mock('@/hooks/misc/useEnabledIdentityProviders', () => ({ - useEnabledIdentityProviders: () => [], -})) - -vi.mock('@/hooks/misc/useInboundBranding', () => ({ - useInboundBranding: () => ({ focusProvider: undefined }), -})) - -vi.mock('@/components/interfaces/SignIn/SignInWithExternalProvider', () => ({ - SignInWithExternalProvider: () =>
SignInWithExternalProvider
, -})) - -vi.mock('@/components/interfaces/SignIn/SignUpForm', () => ({ - SignUpForm: () =>
SignUpForm
, -})) - -describe('/sign-up', () => { - beforeEach(() => { - mockUseSiwcQueryParamOptIn.mockClear() - mockUseIsFeatureEnabled.mockReturnValue({ dashboardAuthSignUp: true }) - }) - - it('calls useSiwcQueryParamOptIn so a shareable ?siwc-enabled=1 link can opt this browser in', () => { - render() - - expect(mockUseSiwcQueryParamOptIn).toHaveBeenCalled() - }) -}) diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 8a8ce0312eb9c..744eb75dd2dc0 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -30,7 +30,6 @@ export const LOCAL_STORAGE_KEYS = { UI_PREVIEW_DATABASE_CONNECTIONS: 'preview-database-connections', AI_ASSISTANT_MCP_OPT_IN: 'ai-assistant-mcp-opt-in', - SIGN_IN_CHATGPT_ENABLED: 'siwc-enabled', DASHBOARD_HISTORY: (ref: string) => `dashboard-history-${ref}`, STORAGE_PREFERENCE: (ref: string) => `storage-explorer-${ref}`, @@ -173,7 +172,6 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [ LOCAL_STORAGE_KEYS.HIDE_PROMO_TOAST, LOCAL_STORAGE_KEYS.BLOG_VIEW, LOCAL_STORAGE_KEYS.AI_ASSISTANT_MCP_OPT_IN, - LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED, LOCAL_STORAGE_KEYS.LINTER_SHOW_FOOTER, LOCAL_STORAGE_KEYS.SIDEBAR_BEHAVIOR, LOCAL_STORAGE_KEYS.UI_TIMEZONE, From 502e0f9b09ad9b126c1be7a12282c4dd4cd13800 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 21 Aug 2026 17:47:57 +0800 Subject: [PATCH 09/11] Add isReadOnly flag into QueryEditor component (#49378) ## Context `QueryEditor` component is being used in the Assistant Chat currently and needs to be read only in this context specifically image ## Summary by CodeRabbit * **Enhancements** * Added read-only support for query editors, allowing query content to be viewed without making changes. * Assistant-generated queries are now displayed in a non-editable mode to prevent accidental modifications. * Read-only editors also prevent applying suggested SQL changes, helping preserve the original query while it is being reviewed. --- .../components/interfaces/Explorer/QueryEditor/index.tsx | 5 ++++- .../components/ui/AIAssistantPanel/AssistantQueryCell.tsx | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx index 8285eb878f02c..87b9579e860cf 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx @@ -101,6 +101,7 @@ export type QueryEditorHandle = { type QueryEditorProps = { id: string + isReadOnly?: boolean variant: 'embedded' | 'viewport' title: string query: ExplorerQueryModel @@ -131,6 +132,7 @@ type QueryEditorProps = { export const QueryEditor = forwardRef(function QueryEditor( { id, + isReadOnly = false, variant, title, query, @@ -261,7 +263,7 @@ export const QueryEditor = forwardRef(funct } const acceptSqlProposal = () => { - if (!pendingProposal) return + if (isReadOnly || !pendingProposal) return if (sql === pendingProposal.original) { onSqlChange(pendingProposal.modified) onSqlCommit?.(pendingProposal.modified) @@ -381,6 +383,7 @@ export const QueryEditor = forwardRef(funct Date: Fri, 21 Aug 2026 20:12:00 +1000 Subject: [PATCH 10/11] feat(studio): preserve assistant tool previews after completion (#49352) image ## 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? Assistant execution feedback improvement. ## Stack context Builds on #49351. ## What is the current behavior? When an Assistant query, notebook, Edge Function deployment, or log query completes or fails, the preview can be replaced by a terse text result. ## What is the new behavior? - Retains the original query, log-query, notebook, and Edge Function preview after the tool resolves. - Replaces confirmation actions with a success, error, or skipped footer state. - Keeps the Open notebook action available after a successful notebook creation or update. ## To test 1. Ask the Assistant to run a valid SQL query, approve it, and confirm the query cell remains visible with a Query executed footer. 2. Trigger a failed SQL or log query and confirm the original preview remains visible with an error footer and error result. 3. Ask the Assistant to create or update a notebook, approve it, and confirm the preview remains visible with a completed footer and Open notebook action. 4. Skip any approval and confirm the preview remains visible with a skipped footer instead of being replaced by plain text. ## Summary by CodeRabbit * **New Features** * Assistant actions now show clear success, error, or denied-status messages. * Completed actions retain relevant previews and provide follow-up actions, such as opening a created notebook. * SQL, log-query, Edge Function, and notebook errors appear within their respective result views. * Status updates are announced more clearly as actions progress and complete. * **Bug Fixes** * Preserved submitted tool details when execution fails or original input is unavailable. * Improved handling of failed and denied operations across assistant workflows. --- .../AIAssistantPanel/AssistantQueryCell.tsx | 25 ++++- .../ui/AIAssistantPanel/Confirm.test.tsx | 51 ++++++++++ .../ui/AIAssistantPanel/Confirm.tsx | 63 ++++++++++--- .../ui/AIAssistantPanel/Confirm.utils.test.ts | 44 ++++++++- .../ui/AIAssistantPanel/Confirm.utils.ts | 27 ++++-- .../AIAssistantPanel/EdgeFunctionRenderer.tsx | 6 ++ .../ui/AIAssistantPanel/Message.Parts.tsx | 41 +++------ .../MessagePartQueryLogs.test.tsx | 52 +++++++++++ .../AIAssistantPanel/MessagePartQueryLogs.tsx | 28 +++--- .../NotebookProposalRenderer.test.tsx | 87 +++++++++++++++++- .../NotebookProposalRenderer.tsx | 92 ++++++++++--------- 11 files changed, 405 insertions(+), 111 deletions(-) create mode 100644 apps/studio/components/ui/AIAssistantPanel/Confirm.test.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/MessagePartQueryLogs.test.tsx diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx index d78769f65ebc3..8612987d991c0 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx @@ -14,7 +14,10 @@ import { Confirm } from './Confirm' import { type ConfirmFooterApprovalState } from './Confirm.utils' import { QueryEditor } from '@/components/interfaces/Explorer/QueryEditor' import { type QueryDisplay, type QueryResult } from '@/components/interfaces/Explorer/types' -import { type QuerySourceBinding } from '@/data/query-sources/query-source-registry' +import { + type QuerySourceBinding, + type QuerySourceTag, +} from '@/data/query-sources/query-source-registry' import { useTrack } from '@/lib/telemetry/track' import { useLocalRoleImpersonationState } from '@/state/role-impersonation-state' @@ -36,6 +39,22 @@ interface AssistantQueryCellProps { const DEFAULT_SOURCE: QuerySourceBinding = { _tag: 'database' } +const QUERY_OUTCOME_MESSAGES: Record< + QuerySourceTag, + { success: string; error: string; denied: string } +> = { + database: { + success: 'Query executed', + error: 'Failed to execute SQL', + denied: 'Skipped query', + }, + logs: { + success: 'Query executed', + error: 'Failed to query logs', + denied: 'Skipped query', + }, +} + /** Assistant adapter around the shared QueryEditor. Local state only — nothing is persisted. */ export const AssistantQueryCell = ({ id, @@ -123,6 +142,7 @@ export const AssistantQueryCell = ({ } const isConfirming = confirmState !== undefined + const outcomeMessages = QUERY_OUTCOME_MESSAGES[source._tag] return ( diff --git a/apps/studio/components/ui/AIAssistantPanel/Confirm.test.tsx b/apps/studio/components/ui/AIAssistantPanel/Confirm.test.tsx new file mode 100644 index 0000000000000..4cec084cc32e4 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/Confirm.test.tsx @@ -0,0 +1,51 @@ +import { screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { Confirm } from './Confirm' +import { customRender as render } from '@/tests/lib/custom-render' + +describe('Confirm', () => { + it('keeps its content visible and replaces actions with a success status', () => { + render( + +
Query preview
+
+ ) + + expect(screen.getByText('Query preview')).toBeInTheDocument() + expect(screen.getByText('Query executed')).toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('keeps its content visible and replaces actions with an error status', () => { + render( + +
Query preview
+
+ ) + + expect(screen.getByText('Query preview')).toBeInTheDocument() + expect(screen.getByText('Failed to execute SQL')).toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('announces outcome updates in the existing status region', () => { + const { rerender } = render( + +
Query preview
+
+ ) + const status = screen.getByRole('status') + + expect(status).toHaveTextContent('Run query') + + rerender( + +
Query preview
+
+ ) + + expect(screen.getByRole('status')).toBe(status) + expect(status).toHaveTextContent('Query executed') + }) +}) diff --git a/apps/studio/components/ui/AIAssistantPanel/Confirm.tsx b/apps/studio/components/ui/AIAssistantPanel/Confirm.tsx index a3a7d26c975ec..bdd6d4e283d88 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Confirm.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Confirm.tsx @@ -1,7 +1,8 @@ -import { type PropsWithChildren } from 'react' +import { Check, X } from 'lucide-react' +import { type PropsWithChildren, type ReactNode } from 'react' import { Button, cn } from 'ui' -import { getConfirmFooterBar } from './Confirm.utils' +import { getConfirmFooterBar, type ConfirmFooterApprovalState } from './Confirm.utils' interface ConfirmFooterProps { message: string @@ -10,6 +11,9 @@ interface ConfirmFooterProps { confirmLabelLoading?: string isLoading?: boolean isDisabled?: boolean + outcome?: 'success' | 'error' | 'denied' + showActions?: boolean + action?: ReactNode /** Omit the confirm button so only Skip remains (unparseable / unapplyable previews). */ denyOnly?: boolean /** Escape hatch for consumers that attach the bar directly under their own frame. */ @@ -26,6 +30,9 @@ export const ConfirmFooter = ({ confirmLabelLoading = 'Working...', isLoading = false, isDisabled = false, + outcome, + showActions = true, + action, denyOnly = false, className, onCancel, @@ -41,17 +48,28 @@ export const ConfirmFooter = ({ className )} > -
{message}
-
- - {!denyOnly && ( - - )} +
+ {outcome === 'success' && } + {outcome === 'error' && } + {message}
+ {(showActions || action) && ( +
+ {showActions && ( + <> + + {!denyOnly && ( + + )} + + )} + {action} +
+ )}
) } @@ -61,11 +79,15 @@ interface ConfirmProps { * Result of `getManualToolApprovalConfirmState`. Interactive buttons only for * `approval-requested`; `approval-responded` is the post-approve loading morph. */ - state?: string + state?: ConfirmFooterApprovalState message: string cancelLabel?: string confirmLabel?: string confirmLabelLoading?: string + successMessage?: string + errorMessage?: string + deniedMessage?: string + footerAction?: ReactNode extraLoading?: boolean isLoading?: boolean /** @@ -93,6 +115,10 @@ export const Confirm = ({ cancelLabel = 'Skip', confirmLabel = 'Confirm', confirmLabelLoading = 'Working...', + successMessage, + errorMessage, + deniedMessage, + footerAction, extraLoading = false, isLoading = false, fill = false, @@ -104,6 +130,12 @@ export const Confirm = ({ const bar = getConfirmFooterBar(state) const showLoading = bar.isLoading || extraLoading || isLoading const isApprovalRequested = state === 'approval-requested' + const outcomeMessages = { + success: successMessage, + error: errorMessage, + denied: deniedMessage, + } + const footerMessage = bar.outcome ? (outcomeMessages[bar.outcome] ?? message) : message return (
{bar.show && ( { expect(getConfirmFooterBar('approval-responded')).toEqual({ show: true, isLoading: true }) }) - it('hides the bar for every other tool state', () => { - expect(getConfirmFooterBar('input-available')).toEqual({ show: false, isLoading: false }) - expect(getConfirmFooterBar('output-available')).toEqual({ show: false, isLoading: false }) - expect(getConfirmFooterBar('output-denied')).toEqual({ show: false, isLoading: false }) + it('shows a terminal outcome after a completed approval', () => { + expect(getConfirmFooterBar('success')).toEqual({ + show: true, + isLoading: false, + outcome: 'success', + }) + expect(getConfirmFooterBar('error')).toEqual({ + show: true, + isLoading: false, + outcome: 'error', + }) + expect(getConfirmFooterBar('denied')).toEqual({ + show: true, + isLoading: false, + outcome: 'denied', + }) }) }) @@ -47,6 +59,27 @@ describe('getManualToolApprovalConfirmState', () => { ).toBe('approval-responded') }) + it('keeps a terminal footer after a manual tool completes', () => { + expect( + getManualToolApprovalConfirmState({ + state: 'output-available', + approval: { id: 'approval-1', approved: true }, + }) + ).toBe('success') + expect( + getManualToolApprovalConfirmState({ + state: 'output-error', + approval: { id: 'approval-1', approved: true }, + }) + ).toBe('error') + expect( + getManualToolApprovalConfirmState({ + state: 'output-denied', + approval: { id: 'approval-1', approved: false }, + }) + ).toBe('denied') + }) + it('hides the footer for automatic approvals', () => { expect( getManualToolApprovalConfirmState({ @@ -71,9 +104,10 @@ describe('getManualToolApprovalConfirmState', () => { ).toBeUndefined() }) - it('ignores non-approval tool states', () => { + it('ignores terminal states that were not manually approved', () => { expect(getManualToolApprovalConfirmState({ state: 'input-available' })).toBeUndefined() expect(getManualToolApprovalConfirmState({ state: 'output-available' })).toBeUndefined() + expect(getManualToolApprovalConfirmState({ state: 'output-error' })).toBeUndefined() expect(getManualToolApprovalConfirmState({ state: 'output-denied' })).toBeUndefined() }) }) diff --git a/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.ts b/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.ts index 9ce728c929417..d6061f4620e5b 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.ts +++ b/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.ts @@ -1,4 +1,9 @@ -export type ConfirmFooterApprovalState = 'approval-requested' | 'approval-responded' +export type ConfirmFooterApprovalState = + | 'approval-requested' + | 'approval-responded' + | 'success' + | 'error' + | 'denied' /** Sent with Skip so the model sees a user choice, not the SDK default "Tool execution denied." */ export const USER_SKIPPED_TOOL_REASON = 'The user skipped this action.' @@ -11,20 +16,27 @@ export type ToolApprovalFields = { } /** - * Whether the confirm bar should render, and whether it is in the post-approve loading - * morph. Driven by the AI SDK tool approval state; any other state hides the bar. + * Whether the confirm bar should render, and whether it is loading or in a terminal state. + * Driven by the AI SDK tool approval state; any other state hides the bar. */ -export function getConfirmFooterBar(state?: string): { show: boolean; isLoading: boolean } { +export function getConfirmFooterBar(state?: ConfirmFooterApprovalState): { + show: boolean + isLoading: boolean + outcome?: 'success' | 'error' | 'denied' +} { if (state === 'approval-requested') return { show: true, isLoading: false } if (state === 'approval-responded') return { show: true, isLoading: true } + if (state === 'success' || state === 'error' || state === 'denied') { + return { show: true, isLoading: false, outcome: state } + } return { show: false, isLoading: false } } /** * Maps a tool part onto the confirm footer. Follows the AI SDK `useChat` rule: * interactive Approve/Deny only for `approval-requested` when `!approval.isAutomatic`. - * `approval-responded` keeps a loading morph after a manual approve; denials and - * automatic decisions hide the bar. + * `approval-responded` keeps a loading morph after a manual approve. Completed manual + * approvals stay visible as a terminal outcome; automatic decisions never get a footer. * * @see https://ai-sdk.dev/docs/agents/tool-approvals */ @@ -38,6 +50,9 @@ export function getManualToolApprovalConfirmState({ if (approval?.isAutomatic) return undefined if (state === 'approval-requested') return 'approval-requested' if (state === 'approval-responded' && approval?.approved !== false) return 'approval-responded' + if (state === 'output-available' && approval?.approved === true) return 'success' + if (state === 'output-error' && approval?.approved === true) return 'error' + if (state === 'output-denied' && approval?.approved === false) return 'denied' return undefined } diff --git a/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.tsx index d22e46c9cd537..5b866559f45dd 100644 --- a/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.tsx @@ -16,6 +16,7 @@ interface EdgeFunctionRendererProps { onDeny?: () => void isDeploying?: boolean initialIsDeployed?: boolean + errorText?: string confirmState?: ConfirmFooterApprovalState } @@ -27,6 +28,7 @@ export const EdgeFunctionRenderer = ({ onDeny, isDeploying = false, initialIsDeployed, + errorText, confirmState, }: EdgeFunctionRendererProps) => { const { ref } = useParams() @@ -85,6 +87,9 @@ export const EdgeFunctionRenderer = ({ cancelLabel="Skip" confirmLabel="Deploy" confirmLabelLoading="Deploying..." + successMessage="Edge Function deployed" + errorMessage="Failed to deploy Edge Function" + deniedMessage="Skipped Edge Function deployment" isLoading={isDeploying} onCancel={onDeny} onConfirm={handleDeploy} @@ -97,6 +102,7 @@ export const EdgeFunctionRenderer = ({ disabled={isConfirming} isDeploying={isDeploying} isDeployed={initialIsDeployed} + errorText={errorText} functionUrl={functionUrl} deploymentDetailsUrl={deploymentDetailsUrl} downloadCommand={downloadCommand} diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx index b3b4fe25584d0..821f1d7cbfad3 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx @@ -110,24 +110,17 @@ function ToolDisplayExecuteSqlLoading({ label = 'Writing SQL...' }: { label?: st ) } -function ToolDisplayExecuteSqlFailure() { - return
Failed to execute SQL.
-} - function MessagePartExecuteSql({ toolPart }: { toolPart: ToolUIPart }) { const { id } = useMessageInfoContext() const { addToolApprovalResponse } = useMessageActionsContext() - const { toolCallId, state, input, output } = toolPart + const { toolCallId, state, input: submittedInput, output } = toolPart + const input = state === 'output-error' ? (submittedInput ?? toolPart.rawInput) : submittedInput if (state === 'input-streaming') { return } - if (state === 'output-error') { - return - } - const { data: chart, success } = parseExecuteSqlChartResult(input) if (!success) return null @@ -136,7 +129,8 @@ function MessagePartExecuteSql({ toolPart }: { toolPart: ToolUIPart }) { state === 'approval-requested' || state === 'approval-responded' || state === 'output-denied' || - state === 'output-available' + state === 'output-available' || + state === 'output-error' ) { const { confirmState, onApprove, onDeny } = getManualToolApprovalHandlers({ state, @@ -150,7 +144,11 @@ function MessagePartExecuteSql({ toolPart }: { toolPart: ToolUIPart }) { id={`${id}-${toolCallId}`} sql={chart.sql} title={chart.label} - initialResult={toAssistantQueryResult(output)} + initialResult={ + state === 'output-error' + ? { rows: [], error: { message: toolPart.errorText ?? 'Failed to execute SQL' } } + : toAssistantQueryResult(output) + } view={chart.view} xAxis={chart.xAxis} yAxis={chart.yAxis} @@ -171,10 +169,12 @@ const TOOL_DEPLOY_EDGE_FUNCTION_STATES_WITH_INPUT = new Set([ 'approval-responded', 'output-denied', 'output-available', + 'output-error', ]) function MessagePartDeployEdgeFunction({ toolPart }: { toolPart: ToolUIPart }) { - const { state, input, output } = toolPart + const { state, input: submittedInput, output } = toolPart + const input = state === 'output-error' ? (submittedInput ?? toolPart.rawInput) : submittedInput const { addToolApprovalResponse } = useMessageActionsContext() if (state === 'input-streaming') { @@ -186,10 +186,6 @@ function MessagePartDeployEdgeFunction({ toolPart }: { toolPart: ToolUIPart }) { ) } - if (state === 'output-error') { - return

Failed to deploy Edge Function.

- } - if (!TOOL_DEPLOY_EDGE_FUNCTION_STATES_WITH_INPUT.has(state)) return null const parsedInput = deployEdgeFunctionInputSchema.safeParse(input) @@ -213,6 +209,7 @@ function MessagePartDeployEdgeFunction({ toolPart }: { toolPart: ToolUIPart }) { confirmState={confirmState} isDeploying={confirmState === 'approval-responded'} initialIsDeployed={isInitiallyDeployed} + errorText={state === 'output-error' ? toolPart.errorText : undefined} onApprove={onApprove} onDeny={onDeny} /> @@ -224,11 +221,6 @@ const NOTEBOOK_DRAFTING_LABEL: Record = { update: 'Drafting notebook update...', } -const NOTEBOOK_FAILED_LABEL: Record = { - create: 'Failed to create notebook.', - update: 'Failed to update notebook.', -} - function MessagePartNotebookProposal({ toolPart, mode, @@ -236,7 +228,8 @@ function MessagePartNotebookProposal({ toolPart: ToolUIPart mode: NotebookProposalMode }) { - const { state, input, output } = toolPart + const { state, input: submittedInput, output } = toolPart + const input = state === 'output-error' ? (submittedInput ?? toolPart.rawInput) : submittedInput const { addToolApprovalResponse } = useMessageActionsContext() if (state === 'input-streaming') { @@ -248,10 +241,6 @@ function MessagePartNotebookProposal({ ) } - if (state === 'output-error') { - return

{NOTEBOOK_FAILED_LABEL[mode]}

- } - const { confirmState, onApprove, onDeny } = getManualToolApprovalHandlers({ state, approval: toolPart.approval, diff --git a/apps/studio/components/ui/AIAssistantPanel/MessagePartQueryLogs.test.tsx b/apps/studio/components/ui/AIAssistantPanel/MessagePartQueryLogs.test.tsx new file mode 100644 index 0000000000000..9eac304587a61 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/MessagePartQueryLogs.test.tsx @@ -0,0 +1,52 @@ +import { screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { MessageProvider } from './Message.Context' +import { MessagePartQueryLogs } from './MessagePartQueryLogs' +import { customRender as render } from '@/tests/lib/custom-render' + +vi.mock('./AssistantQueryCell', () => ({ + AssistantQueryCell: ({ + sql, + initialResult, + }: { + sql: string + initialResult?: { error?: Error } + }) => ( +
+ {sql} + {initialResult?.error?.message} +
+ ), +})) + +type QueryLogsToolPart = Parameters[0]['toolPart'] + +describe('MessagePartQueryLogs', () => { + it('renders an output error using raw input when submitted input is unavailable', () => { + const toolPart = { + toolCallId: 'query-logs-1', + state: 'output-error', + input: undefined, + rawInput: { sql: 'select count(*) from edge_logs' }, + errorText: 'Log query timed out', + } as QueryLogsToolPart + + render( + + + + ) + + expect(screen.getByText('select count(*) from edge_logs')).toBeInTheDocument() + expect(screen.getByText('Log query timed out')).toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/ui/AIAssistantPanel/MessagePartQueryLogs.tsx b/apps/studio/components/ui/AIAssistantPanel/MessagePartQueryLogs.tsx index 9c3976f79d439..006dfbcc9d234 100644 --- a/apps/studio/components/ui/AIAssistantPanel/MessagePartQueryLogs.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/MessagePartQueryLogs.tsx @@ -10,15 +10,14 @@ import { toQueryLogsResult, } from './MessagePartQueryLogs.utils' -type QueryLogsToolPart = Pick - -function QueryLogsFailure() { - return
Failed to query logs.
-} +type QueryLogsToolPart = Pick< + ToolUIPart, + 'toolCallId' | 'state' | 'input' | 'output' | 'errorText' +> & { rawInput?: unknown } export function MessagePartQueryLogs({ toolPart }: { toolPart: QueryLogsToolPart }) { const { id } = useMessageInfoContext() - const { toolCallId, state, input, output } = toolPart + const { toolCallId, state, input: submittedInput, rawInput, output } = toolPart if (state === 'input-streaming' || state === 'input-available') { return ( @@ -29,14 +28,16 @@ export function MessagePartQueryLogs({ toolPart }: { toolPart: QueryLogsToolPart ) } - if (state === 'output-error') return - if (state !== 'output-available') return null + if (state !== 'output-available' && state !== 'output-error') return null - const parsedInput = parseQueryLogsInput(input) - const result = toQueryLogsResult(output) - if (!parsedInput.success || !result) { - return - } + const parsedInput = parseQueryLogsInput(submittedInput ?? rawInput) + if (!parsedInput.success) return null + + const result = + state === 'output-error' + ? { rows: [], error: { message: toolPart.errorText ?? 'Failed to query logs' } } + : toQueryLogsResult(output) + if (!result) return null return (
@@ -52,6 +53,7 @@ export function MessagePartQueryLogs({ toolPart }: { toolPart: QueryLogsToolPart ), }} initialResult={result} + confirmState={state === 'output-error' ? 'error' : undefined} />
) diff --git a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx index df86f4795e275..e7e08152e71af 100644 --- a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx @@ -151,18 +151,97 @@ describe('NotebookProposalRenderer', () => { expect(onApprove).not.toHaveBeenCalled() }) - it('renders an Open notebook link once output is available', () => { + it('keeps the create preview and marks it successful once output is available', () => { render( + ) + + expect(screen.getByRole('toolbar', { name: 'Notebook toolbar' })).toBeInTheDocument() + expect(screen.getByText('Signup funnel')).toBeInTheDocument() + expect(screen.getByText('Notebook created')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Create' })).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Open notebook' })).toHaveAttribute( + 'href', + `/project/default/explorer/notebook/${NOTEBOOK_ID}` + ) + }) + + it('shows the notebook action after an automatic create succeeds', () => { + render( + + ) + + expect(screen.getByRole('link', { name: 'Open notebook' })).toHaveAttribute( + 'href', + `/project/default/explorer/notebook/${NOTEBOOK_ID}` + ) + }) + + it('shows the notebook action after an automatic update succeeds', async () => { + mockContentItem(mockNotebookRow()) + + render( + ) - const link = screen.getByRole('link', { name: 'Open notebook' }) - expect(link).toHaveAttribute('href', `/project/default/explorer/notebook/${NOTEBOOK_ID}`) + expect(await screen.findByRole('link', { name: 'Open notebook' })).toHaveAttribute( + 'href', + `/project/default/explorer/notebook/${NOTEBOOK_ID}` + ) + }) + + it('keeps the create preview and marks it failed when the tool errors', () => { + render( + + ) + + expect(screen.getByRole('toolbar', { name: 'Notebook toolbar' })).toBeInTheDocument() + expect(screen.getByText('New notebook')).toBeInTheDocument() + expect(screen.getByText('Failed to create notebook')).toBeInTheDocument() }) it('keeps the preview in the message after skip', () => { diff --git a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx index e59c5d6aa0681..2e136d3315cd7 100644 --- a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import { Loader2 } from 'lucide-react' import Link from 'next/link' -import { type PropsWithChildren } from 'react' +import { type PropsWithChildren, type ReactNode } from 'react' import { Button } from 'ui' import { Admonition } from 'ui-patterns/Admonition' import { CodeBlock } from 'ui-patterns/CodeBlock' @@ -25,14 +25,13 @@ import { toWireNotebook } from '@/data/content/notebooks/notebook-schema' export type NotebookProposalMode = 'create' | 'update' -// input-streaming and output-error are handled by the caller before this component is -// rendered — see MessagePartNotebookProposal in Message.Parts.tsx. export type NotebookProposalState = | 'input-available' | 'approval-requested' | 'approval-responded' | 'output-denied' | 'output-available' + | 'output-error' export interface NotebookProposalRendererProps { mode: NotebookProposalMode @@ -45,7 +44,12 @@ export interface NotebookProposalRendererProps { onDeny?: () => void } -type NotebookProposalStepProps = Omit +type NotebookProposalStepProps = Omit< + NotebookProposalRendererProps, + 'mode' | 'output' | 'state' +> & { + footerAction?: ReactNode +} const MODE_COPY = { create: { @@ -69,48 +73,42 @@ const MODE_COPY = { * same way `AssistantQueryCell` wraps `QueryEditor`. */ export const NotebookProposalRenderer = (props: NotebookProposalRendererProps) => { - const { mode, state, output } = props - - if (state === 'output-available') { - return - } - - const { input, confirmState, onApprove, onDeny } = props - return mode === 'create' ? ( - - ) : ( - - ) -} - -function NotebookOutputSummary({ mode, output }: { mode: NotebookProposalMode; output: unknown }) { const { ref } = useParams() + const { mode, state, input, output, confirmState, onApprove, onDeny } = props const parsedOutput = notebookToolOutputSchema.safeParse(output) - const label = MODE_COPY[mode].outputLabel + const footerAction = + state === 'output-available' && parsedOutput.success && ref ? ( + + ) : undefined + + const proposal = + mode === 'create' ? ( + + ) : ( + + ) return ( -
- - {parsedOutput.success ? `${label}: ${parsedOutput.data.name}` : label} - - {parsedOutput.success && ref && ( - - )} -
+ <> + {proposal} + {confirmState === undefined && footerAction} + ) } @@ -122,6 +120,7 @@ interface NotebookConfirmProps { confirmLabelLoading?: string extraLoading?: boolean denyOnly?: boolean + footerAction?: ReactNode onApprove?: () => void onDeny?: () => void } @@ -135,6 +134,7 @@ function NotebookConfirm({ confirmLabelLoading, extraLoading, denyOnly, + footerAction, onApprove, onDeny, children, @@ -149,6 +149,10 @@ function NotebookConfirm({ cancelLabel="Skip" confirmLabel={confirmLabel ?? copy.confirmLabel} confirmLabelLoading={confirmLabelLoading ?? copy.confirmLabelLoading} + successMessage={copy.outputLabel} + errorMessage={`Failed to ${mode} notebook`} + deniedMessage={`Skipped notebook ${mode === 'create' ? 'creation' : 'update'}`} + footerAction={footerAction} extraLoading={extraLoading} denyOnly={denyOnly} onCancel={onDeny} @@ -188,6 +192,7 @@ function NotebookParseFailure({ function CreateNotebookProposal({ input, confirmState, + footerAction, onApprove, onDeny, }: NotebookProposalStepProps) { @@ -216,6 +221,7 @@ function CreateNotebookProposal({ @@ -227,6 +233,7 @@ function CreateNotebookProposal({ function UpdateNotebookProposal({ input, confirmState, + footerAction, onApprove, onDeny, }: NotebookProposalStepProps) { @@ -308,6 +315,7 @@ function UpdateNotebookProposal({ Date: Fri, 21 Aug 2026 11:12:37 +0100 Subject: [PATCH 11/11] feat(studio): add permission presets to scoped pat creation form (#49381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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? This adds a quick presets selector to scoped pat permissions. No access, read-only and full access. ## Summary by CodeRabbit * **New Features** * Added permission presets for scoped access tokens: No access, Read-only, and Full access. * Added a selector to quickly configure permissions across resources. * Displays “Custom” when individual permissions differ from a preset. * Shows warnings and guidance for high-risk full-access permissions. * Automatically uses read-only access for resources that do not support write permissions. * **Tests** * Added coverage for preset selection, application, warnings, ordering, and custom configurations. --- .../AccessTokens/AccessToken.presets.test.ts | 117 ++++++++++++++++++ .../AccessTokens/AccessToken.presets.ts | 111 +++++++++++++++++ .../Scoped/Form/NewScopedTokenForm.tsx | 8 ++ .../Scoped/Form/PermissionPresetSelect.tsx | 73 +++++++++++ .../Scoped/Form/PermissionsAccordion.tsx | 57 ++++++--- .../Scoped/NewScopedTokenSheet.test.tsx | 55 ++++++++ 6 files changed, 402 insertions(+), 19 deletions(-) create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/AccessToken.presets.test.ts create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/AccessToken.presets.ts create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionPresetSelect.tsx diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.presets.test.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.presets.test.ts new file mode 100644 index 0000000000000..c54138e3c0eea --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.presets.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from 'vitest' + +import { + getCatalogEntry, + PERMISSION_CATALOG, + PERMISSION_CATALOG_BY_CATEGORY, + type PermissionSelection, +} from './AccessToken.permissions' +import { + applyPreset, + getActivePresetId, + getFullAccessDescription, + getPreset, + PERMISSION_PRESETS, +} from './AccessToken.presets' + +const NONE = getPreset('none')! +const READ = getPreset('read')! +const FULL = getPreset('full')! + +describe('PERMISSION_PRESETS', () => { + test('offers no access, read-only and full access in that order', () => { + expect(PERMISSION_PRESETS.map((preset) => preset.id)).toEqual(['none', 'read', 'full']) + }) + + test('resolves every catalog entry to a mode its row can render', () => { + for (const entry of PERMISSION_CATALOG) { + expect(NONE.resolve(entry)).toBe('none') + expect(READ.resolve(entry)).toBe('read') + expect(FULL.resolve(entry)).toBe(entry.writable ? 'readwrite' : 'read') + } + }) + + test('caps full access at read for resources with no write scopes', () => { + const readOnlyEntries = PERMISSION_CATALOG.filter((entry) => !entry.writable) + expect(readOnlyEntries.length).toBeGreaterThan(0) + for (const entry of readOnlyEntries) { + expect(FULL.resolve(entry)).toBe('read') + } + }) + + test('only marks full access as risky, and only it carries a description', () => { + expect( + PERMISSION_PRESETS.filter((preset) => preset.isRisky).map((preset) => preset.id) + ).toEqual(['full']) + expect( + PERMISSION_PRESETS.filter((preset) => preset.description !== undefined).map( + (preset) => preset.id + ) + ).toEqual(['full']) + }) +}) + +describe('getFullAccessDescription', () => { + test('names high-risk resources that exist in the catalog', () => { + const description = getFullAccessDescription() + expect(description).toBe( + 'Grants the highest access each resource offers, including write access to your database, API keys, and organization members.' + ) + for (const key of ['project:database', 'project:api_gateway_keys', 'organization:members']) { + expect(getCatalogEntry(key)).toBeDefined() + expect(getCatalogEntry(key)!.risk).toBe('high') + // The copy claims write access on these specifically, so they have to be writable + expect(getCatalogEntry(key)!.writable).toBe(true) + } + }) +}) + +describe('applyPreset', () => { + test('sets every catalog entry by default', () => { + const selection = applyPreset(READ, {}) + expect(Object.keys(selection)).toHaveLength(PERMISSION_CATALOG.length) + expect(Object.values(selection).every((mode) => mode === 'read')).toBe(true) + }) + + test('overwrites existing manual choices', () => { + const selection = applyPreset(NONE, { 'project:database': 'readwrite' }) + expect(selection['project:database']).toBe('none') + }) + + test('leaves entries outside the given subset untouched', () => { + const database = PERMISSION_CATALOG_BY_CATEGORY.find((category) => category.key === 'database')! + const before: PermissionSelection = { 'project:advisors': 'read' } + const selection = applyPreset(FULL, before, database.entries) + + expect(selection['project:advisors']).toBe('read') + expect(selection['project:database']).toBe('readwrite') + expect(Object.keys(selection)).toHaveLength(database.entries.length + 1) + }) +}) + +describe('getActivePresetId', () => { + test('reads an empty selection as no access', () => { + expect(getActivePresetId({})).toBe('none') + }) + + test('identifies a selection produced by each preset', () => { + for (const preset of PERMISSION_PRESETS) { + expect(getActivePresetId(applyPreset(preset, {}))).toBe(preset.id) + } + }) + + test('returns null once a single row diverges', () => { + const selection = applyPreset(READ, {}) + selection['project:storage'] = 'readwrite' + expect(getActivePresetId(selection)).toBeNull() + }) + + test('ignores rows outside the given subset', () => { + const database = PERMISSION_CATALOG_BY_CATEGORY.find((category) => category.key === 'database')! + const selection = applyPreset(READ, {}, database.entries) + selection['project:storage'] = 'readwrite' + + expect(getActivePresetId(selection)).toBeNull() + expect(getActivePresetId(selection, database.entries)).toBe('read') + }) +}) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.presets.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.presets.ts new file mode 100644 index 0000000000000..12c4550a20eb5 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.presets.ts @@ -0,0 +1,111 @@ +import { + getCatalogEntry, + PERMISSION_CATALOG, + type PermissionCatalogEntry, + type PermissionMode, + type PermissionSelection, +} from './AccessToken.permissions' + +/** + * Bulk presets for the permission list. Setting 42 rows one at a time is the main reason people + * abandon the scoped token flow, so a preset applies a mode to every row at once and the user + * fine-tunes from there. + * + * A preset is data, not JSX: `resolve` maps a single catalog entry to a mode, and every helper + * below takes an `entries` subset (defaulting to the whole catalog) so the same presets can later + * back per-category "set all" controls, or move server-side. + */ + +export type PermissionPresetId = 'none' | 'read' | 'full' + +export interface PermissionPreset { + id: PermissionPresetId + label: string + /** Optional subtext, shown beneath the label in the menu. */ + description?: string + /** Surfaces the description as an inline warning once applied. */ + isRisky?: boolean + /** Announced in a live region once the preset is applied. */ + announcement: string + resolve: (entry: PermissionCatalogEntry) => PermissionMode +} + +/** + * High-risk resources named in the Full access warning. Keyed by catalog key so the copy can only + * name scopes that actually exist — AccessToken.presets.test.ts asserts every key resolves. + */ +const FULL_ACCESS_HIGH_RISK: { key: string; noun: string }[] = [ + { key: 'project:database', noun: 'your database' }, + { key: 'project:api_gateway_keys', noun: 'API keys' }, + { key: 'organization:members', noun: 'organization members' }, +] + +export const getFullAccessDescription = (): string => { + const nouns = FULL_ACCESS_HIGH_RISK.filter(({ key }) => getCatalogEntry(key) !== undefined).map( + ({ noun }) => noun + ) + if (nouns.length === 0) return 'Grants the highest access each resource offers.' + const listed = + nouns.length === 1 + ? nouns[0] + : `${nouns.slice(0, -1).join(', ')}, and ${nouns[nouns.length - 1]}` + return `Grants the highest access each resource offers, including write access to ${listed}.` +} + +export const PERMISSION_PRESETS: PermissionPreset[] = [ + { + id: 'none', + label: 'No access', + announcement: 'All permissions set to none', + resolve: () => 'none', + }, + { + id: 'read', + label: 'Read-only', + announcement: 'All permissions set to read', + resolve: () => 'read', + }, + { + id: 'full', + label: 'Full access', + description: getFullAccessDescription(), + isRisky: true, + announcement: 'All permissions set to read-write', + // Five resources expose no write scopes, so read is their highest level — resolving them to + // 'readwrite' would store a mode their Select can't render. + resolve: (entry) => (entry.writable ? 'readwrite' : 'read'), + }, +] + +export const getPreset = (id: PermissionPresetId): PermissionPreset | undefined => + PERMISSION_PRESETS.find((preset) => preset.id === id) + +/** Applies a preset over `entries`, leaving any selection outside that subset untouched. */ +export const applyPreset = ( + preset: PermissionPreset, + selection: PermissionSelection, + entries: PermissionCatalogEntry[] = PERMISSION_CATALOG +): PermissionSelection => { + const next = { ...selection } + for (const entry of entries) { + next[entry.key] = preset.resolve(entry) + } + return next +} + +/** + * The preset `selection` currently matches across `entries`, or null when it matches none of them + * — the "Custom" state, which is displayed but never selectable. + */ +export const getActivePresetId = ( + selection: PermissionSelection, + entries: PermissionCatalogEntry[] = PERMISSION_CATALOG +): PermissionPresetId | null => getActivePreset(selection, entries)?.id ?? null + +export const getActivePreset = ( + selection: PermissionSelection, + entries: PermissionCatalogEntry[] = PERMISSION_CATALOG +): PermissionPreset | undefined => + PERMISSION_PRESETS.find((preset) => + entries.every((entry) => (selection[entry.key] ?? 'none') === preset.resolve(entry)) + ) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx index 7c18d08c332b0..eed6f0cae90e8 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx @@ -19,6 +19,7 @@ import { Admonition } from 'ui-patterns/Admonition' import { CLASSIC_TOKEN_WARNING } from '../../AccessToken.constants' import { countConfigured, PermissionMode } from '../../AccessToken.permissions' +import { applyPreset, type PermissionPreset } from '../../AccessToken.presets' import { useTokenAccessEvaluation } from '../../hooks/useTokenAccessEvaluation' import { DEFAULT_EXPIRY, TokenFormSchema, TokenFormValues } from './NewScopedTokenForm.utils' import { NewScopedTokenFormReview } from './NewScopedTokenFormReview' @@ -122,6 +123,12 @@ export const NewScopedTokenForm = ({ if (mode !== 'none') setShowMissingPermissionsWarning(false) } + const handleApplyPreset = (preset: PermissionPreset) => { + const next = applyPreset(preset, selection) + form.setValue('permissions', next) + if (countConfigured(next) > 0) setShowMissingPermissionsWarning(false) + } + return ( <> {/* Radix wraps viewport children in an inline-styled display:table div that grows to fit @@ -170,6 +177,7 @@ export const NewScopedTokenForm = ({ {showMissingPermissionsWarning && ( diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionPresetSelect.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionPresetSelect.tsx new file mode 100644 index 0000000000000..49368f524a0d5 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionPresetSelect.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react' +import { Select, SelectContent, SelectItem, SelectTrigger } from 'ui' + +import type { PermissionSelection } from '../../AccessToken.permissions' +import { + getActivePreset, + PERMISSION_PRESETS, + type PermissionPreset, +} from '../../AccessToken.presets' + +interface PermissionPresetSelectProps { + selection: PermissionSelection + onApplyPreset: (preset: PermissionPreset) => void +} + +export const PermissionPresetSelect = ({ + selection, + onApplyPreset, +}: PermissionPresetSelectProps) => { + const [announcement, setAnnouncement] = useState('') + const activePreset = getActivePreset(selection) + + const handleSelectPreset = (id: string) => { + const preset = PERMISSION_PRESETS.find((candidate) => candidate.id === id) + if (preset === undefined) return + onApplyPreset(preset) + setAnnouncement(preset.announcement) + } + + return ( + // A div root so FormLayout's flex-row-reverse column stretches the trigger to its full width, + // lining it up with the resource selects in the section above. +
+ {/* An empty value leaves every option unchecked, which is how "Custom" reads: a state the + selection can land in, never one you can pick. */} + + {/* The menu unmounts on select, so the announcement lives outside it to survive the close. */} + + {announcement} + +
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx index 23112f36d8763..b96ba33e95ebc 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx @@ -1,5 +1,7 @@ import { useState } from 'react' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, cn } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' +import { FormLayout } from 'ui-patterns/form/Layout/FormLayout' import { countConfiguredInCategory, @@ -7,7 +9,9 @@ import { type PermissionMode, type PermissionSelection, } from '../../AccessToken.permissions' +import { getActivePreset, type PermissionPreset } from '../../AccessToken.presets' import type { TokenAccessEvaluation } from '../../AccessToken.roles' +import { PermissionPresetSelect } from './PermissionPresetSelect' import { PermissionRow } from './PermissionRow' import { InlineLink } from '@/components/ui/InlineLink' import { DOCS_URL } from '@/lib/constants' @@ -15,36 +19,51 @@ import { DOCS_URL } from '@/lib/constants' interface PermissionsAccordionProps { selection: PermissionSelection onChange: (key: string, mode: PermissionMode) => void + onApplyPreset: (preset: PermissionPreset) => void access?: TokenAccessEvaluation } export const PermissionsAccordion = ({ selection, onChange, + onApplyPreset, access, }: PermissionsAccordionProps) => { const [openCategories, setOpenCategories] = useState([]) + const activePreset = getActivePreset(selection) + // Derived, so editing any row back off the preset clears the warning with it. + const riskyPreset = activePreset?.isRisky === true ? activePreset : undefined return ( -
-
-

Permissions

-

- Grant the minimum access this token needs. Everything defaults to None. Permissions follow - your role in the organizations and projects you're a member of — see{' '} - - access control - {' '} - for how roles work. -

-
- - + + Grant the minimum access this token needs. Everything defaults to None. Permissions + follow your role in the organizations and projects you're a member of — see{' '} + + access control + {' '} + for how roles work. +

+ } > + +
+ + {riskyPreset !== undefined && ( + + )} + + {PERMISSION_CATALOG_BY_CATEGORY.map((category, index) => { const configuredCount = countConfiguredInCategory(selection, category.key) return ( @@ -88,6 +107,6 @@ export const PermissionsAccordion = ({ ) })} -
+ ) } diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx index 550295bac60e2..9811078e177ba 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx @@ -256,6 +256,61 @@ describe('NewScopedTokenSheet', () => { await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) }, 10_000) + // Permission preset tests + const FULL_ACCESS_WARNING = + 'Grants the highest access each resource offers, including write access to your database, API keys, and organization members.' + + const getPresetTrigger = async () => screen.findByRole('combobox', { name: 'Permission preset' }) + + const openPresetMenu = async () => { + fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' })) + await screen.findByRole('dialog') + fireEvent.click(await getPresetTrigger()) + } + + test('applies a preset to every permission row', async () => { + renderSheet() + await openPresetMenu() + fireEvent.click(await screen.findByRole('option', { name: 'Read-only' })) + + expect((await getPresetTrigger()).textContent).toContain('Read-only') + // The bulk change is announced rather than left to the user to notice + expect((await screen.findByRole('status')).textContent).toBe('All permissions set to read') + + await expandPermissionCategory('Project') + const select = await screen.findByLabelText('Project Settings', { exact: false }) + expect(select.textContent).toBe('Read') + }) + + test('warns about full access in the menu and inline once applied', async () => { + renderSheet() + await openPresetMenu() + + const fullAccess = await screen.findByRole('option', { name: /^Full access/ }) + const describedBy = fullAccess.getAttribute('aria-describedby') + expect(describedBy).not.toBeNull() + expect(document.getElementById(describedBy!)?.textContent).toBe(FULL_ACCESS_WARNING) + + fireEvent.click(fullAccess) + // The warning survives the menu closing + await screen.findByText(FULL_ACCESS_WARNING, { selector: '[role="alert"] *' }) + expect((await getPresetTrigger()).textContent).toContain('Full access') + }) + + test('falls back to Custom when a row diverges from the preset', async () => { + renderSheet() + await openPresetMenu() + fireEvent.click(await screen.findByRole('option', { name: /^Full access/ })) + await screen.findByText(FULL_ACCESS_WARNING, { selector: '[role="alert"] *' }) + + await expandPermissionCategory('Project') + fireEvent.click(await screen.findByLabelText('Project Settings', { exact: false })) + fireEvent.click(await screen.findByRole('option', { name: 'None' })) + + await waitFor(async () => expect((await getPresetTrigger()).textContent).toContain('Custom')) + expect(screen.queryByText(FULL_ACCESS_WARNING, { selector: '[role="alert"] *' })).toBeNull() + }) + test('opens the experimental API dialog from the dropdown', async () => { renderSheet() await user.click(await screen.findByRole('button', { name: 'Choose token scope' }))