diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 33ffc26b371d0..9830ed0274abf 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -69,6 +69,54 @@ reviews: for both runtimes until the final cleanup pass (tracked in FE-3106). Keep this a reminder to verify, not a hard blocker: if no mirror is required, say so briefly rather than forcing a change. + - path: '{apps,packages}/**/*.{tsx,jsx,css,mdx}' + instructions: | + When reviewing UI changes, flag these accessibility gaps. Comments are + advisory. One comment per gap. Skip test files (*.test.*, *.spec.*) and + generated files. Skip Radix/shadcn primitives imported from ui for all + checks below. Do not flag issues axe-core already catches mechanically, + such as a missing alt attribute, an empty button or link name, or + invalid ARIA. + - State changes: if sighted users can see a status change (toast, + loading/empty swap, copy confirmation, async result) and nothing + announces it, suggest aria-live="polite" or role="status". Reserve + role="alert" for urgent errors or warnings. Skip if a live region, + Radix Toast, or Sonner is already there, or if the change is + decoration only. If a live region is created in the same conditional + as its message, flag that: the region must already exist in the DOM, + then receive the update, or screen readers often announce nothing. + - Mouse interaction: flag pointer-only handlers on a non-interactive + element (div, span, or similar) with no keyboard equivalent. The + listed handlers are illustrative: onClick, onMouseEnter, + onDoubleClick, onContextMenu, onPointerDown, onPointerUp, + onTouchStart, onTouchEnd, and equivalents. Also flag hover-only UI + (content revealed with onMouseEnter or CSS :hover) that has no focus + or keyboard path. + - Animation: flag animate-*, keyframes, or JS motion with no + reduced-motion treatment. Prefer Tailwind motion-reduce: / + motion-safe:, or matchMedia('(prefers-reduced-motion: reduce)'). + packages/config/css/utilities.css only zeroes out .shimmer under + reduced motion, not all animation. + - Alt text: flag generic values such as Image, Icon, Photo, Picture, or + the filename. Flag alt that starts with "image of" or "picture of". + If adjacent visible text already names the image (blog thumbnail next + to its title, icon next to its label), flag it as redundant and + recommend alt="" plus aria-hidden on the image. For a decorative SVG + next to visible text, recommend aria-hidden on the SVG. If alt is + longer than about two sentences, suggest moving the extra into a + caption, adjacent text, or aria-describedby. Do not treat a character + count as a hard fail. + - Focus visibility: flag outline-none, outline-hidden, outline: none, + outline: 0, or equivalent :focus resets that are not paired with a + focus-visible ring or outline, or with the focus-ring or focus-inset + utility. + - Color-only state: flag status, validation, or selection that is + conveyed only by color. Suggest a text label, icon, or sr-only text + in addition. + - Link purpose: flag an accessible name that is only "click here", + "read more", or "learn more" when it does not describe the + destination. Skip if aria-label or wrapping context already names + where the link goes. # Applies our internal engineering skills (.claude/skills/) as CodeRabbit review # guidelines. The skills are the single source of truth — they are consumed diff --git a/apps/docs/data/content-listings/ai-tools.data.ts b/apps/docs/data/content-listings/ai-tools.data.ts index 6b4bf347d78d5..4b555dc87bc7f 100644 --- a/apps/docs/data/content-listings/ai-tools.data.ts +++ b/apps/docs/data/content-listings/ai-tools.data.ts @@ -15,12 +15,14 @@ const ICON_ASSETS: Record = { 'claude-code': { icon: '/docs/img/icons/agent-claude-icon', hasLightIcon: false }, codex: { icon: '/docs/img/icons/agent-openai-icon', hasLightIcon: true }, cursor: { icon: '/docs/img/icons/agent-cursor-icon', hasLightIcon: true }, + grok: { icon: '/docs/img/icons/agent-grok-icon', hasLightIcon: true }, 'gemini-cli': { icon: '/docs/img/icons/agent-gemini-cli-icon', hasLightIcon: false }, 'github-copilot': { icon: '/docs/img/icons/agent-copilot-icon', hasLightIcon: true }, kimi: { icon: '/docs/img/icons/agent-kimi-icon', hasLightIcon: true }, vscode: { icon: '/docs/img/icons/agent-vscode-icon', hasLightIcon: false }, antigravity: { icon: '/docs/img/icons/agent-antigravity-icon', hasLightIcon: false }, windsurf: { icon: '/docs/img/icons/agent-devin-icon', hasLightIcon: true }, + warp: { icon: '/docs/img/icons/agent-warp-icon', hasLightIcon: true }, goose: { icon: '/docs/img/icons/agent-goose-icon', hasLightIcon: true }, factory: { icon: '/docs/img/icons/agent-factory-icon', hasLightIcon: true }, opencode: { icon: '/docs/img/icons/agent-opencode-icon', hasLightIcon: true }, diff --git a/apps/docs/features/ui/AgentPluginsPanel.data.ts b/apps/docs/features/ui/AgentPluginsPanel.data.ts index 7678b94d615f2..91902ae641271 100644 --- a/apps/docs/features/ui/AgentPluginsPanel.data.ts +++ b/apps/docs/features/ui/AgentPluginsPanel.data.ts @@ -3,6 +3,7 @@ import type { McpClient } from 'ui-patterns/McpUrlBuilder' export interface PluginClient extends McpClient { repoUrl?: string docsUrl?: string + docsLinkText?: string } export const PLUGIN_CLIENTS: PluginClient[] = [ @@ -25,6 +26,7 @@ export const PLUGIN_CLIENTS: PluginClient[] = [ key: 'cursor', label: 'Cursor', icon: 'cursor', + hasDistinctDarkIcon: true, repoUrl: 'https://github.com/supabase-community/cursor-plugin', docsUrl: 'https://cursor.com/docs/plugins', }, @@ -44,6 +46,15 @@ export const PLUGIN_CLIENTS: PluginClient[] = [ docsUrl: 'https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/plugins-finding-installing', }, + { + key: 'grok', + label: 'Grok', + icon: 'grok', + hasDistinctDarkIcon: true, + repoUrl: 'https://github.com/supabase-community/supabase-plugin', + docsUrl: 'https://docs.x.ai/build/features/skills-plugins-marketplaces#plugins', + docsLinkText: 'View Grok plugins docs', + }, { key: 'kimi', label: 'Kimi Code', diff --git a/apps/docs/features/ui/AgentPluginsPanel.tsx b/apps/docs/features/ui/AgentPluginsPanel.tsx index c5ebcf3e43713..7df6f067d13a6 100644 --- a/apps/docs/features/ui/AgentPluginsPanel.tsx +++ b/apps/docs/features/ui/AgentPluginsPanel.tsx @@ -150,6 +150,26 @@ function PluginInstructions({ client }: { client: PluginClient }) { ) } + if (client.key === 'grok') { + return ( +
+

+ Install the Supabase plugin by running the following command in your terminal. +

+ +

+ Browse and install plugins in a session: run grok, then /plugins{' '} + or /marketplace. +

+
+ ) + } + if (client.key === 'vscode') { return (
@@ -240,7 +260,7 @@ export function AgentPluginsPanel() { rel="noopener noreferrer" className="text-brand-link hover:underline inline-flex items-center" > - View {selectedClient.label} extensions docs + {selectedClient.docsLinkText ?? `View ${selectedClient.label} extensions docs`}
diff --git a/apps/docs/features/ui/McpConfigPanel.tsx b/apps/docs/features/ui/McpConfigPanel.tsx index f7bece0a60d4d..9a8ae8cdf8b50 100644 --- a/apps/docs/features/ui/McpConfigPanel.tsx +++ b/apps/docs/features/ui/McpConfigPanel.tsx @@ -26,6 +26,7 @@ import { import { Admonition } from 'ui-patterns/Admonition' import { createMcpCopyHandler, + MCP_HOSTED_AUTH_NOTE, McpConfigPanel as McpConfigPanelBase, type McpClient, } from 'ui-patterns/McpUrlBuilder' @@ -336,17 +337,8 @@ export function McpConfigPanel() { /> {isPlatform && ( - -

- { - "Some MCP clients will automatically prompt you to login during setup, while others may require manual authentication steps. Either authentication method will open a browser window where you can login to your Supabase account and grant organization access to the MCP client. In the future, we'll offer more fine grain control over these permissions." - } -

-

- { - 'Previously Supabase MCP required you to generate a personal access token (PAT), but this is no longer required.' - } -

+ +

{MCP_HOSTED_AUTH_NOTE.body}

)} diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 4366f56b139a2..39096065e680b 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -90,6 +90,7 @@ Dimitrios Liappis Div Arora Divit D Divya Sharma +Donna Alexandra Douglas Hunley Eduardo Gurgel Eleftheria Trivyzaki diff --git a/apps/docs/public/img/icons/agent-grok-icon-light.svg b/apps/docs/public/img/icons/agent-grok-icon-light.svg new file mode 100644 index 0000000000000..8ac6121911a2c --- /dev/null +++ b/apps/docs/public/img/icons/agent-grok-icon-light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/docs/public/img/icons/agent-grok-icon.svg b/apps/docs/public/img/icons/agent-grok-icon.svg new file mode 100644 index 0000000000000..1b71ff1ab24f9 --- /dev/null +++ b/apps/docs/public/img/icons/agent-grok-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/docs/public/img/icons/agent-warp-icon-light.svg b/apps/docs/public/img/icons/agent-warp-icon-light.svg new file mode 100644 index 0000000000000..ea1212cbc6420 --- /dev/null +++ b/apps/docs/public/img/icons/agent-warp-icon-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/docs/public/img/icons/agent-warp-icon.svg b/apps/docs/public/img/icons/agent-warp-icon.svg new file mode 100644 index 0000000000000..9799154c284ad --- /dev/null +++ b/apps/docs/public/img/icons/agent-warp-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.tsx b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.tsx index 62b8754e1e1ad..5d79e016bef5a 100644 --- a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.tsx +++ b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.tsx @@ -1,9 +1,11 @@ +import { NotebookPen } from 'lucide-react' import { useState } from 'react' -import { Button } from 'ui' +import { Button, cn } from 'ui' import { formatNotebookDiffSummary, getEntryKey, + isEntryExpandedByDefault, summarizeNotebookDiff, } from './NotebookPreview.utils' import { NotebookPreviewCell } from './NotebookPreviewCell' @@ -12,34 +14,92 @@ import type { NotebookCellDiffEntry } from '@/data/content/notebooks/notebook-op export interface NotebookPreviewProps { entries: NotebookCellDiffEntry[] mode: 'create' | 'update' + /** The notebook's name, shown in the card header. Falls back to a generic label. */ + title?: string + className?: string } const VISIBLE_ENTRY_LIMIT = 5 +const FALLBACK_TITLE = { + create: 'New notebook', + update: 'Notebook changes', +} as const + /** * Read-only preview of a proposed notebook create/update, rendered from a pre-computed diff. * Pure presentational component: no data fetching, no approval or notebook-editor state — see * `deriveNotebookDiff` for how `entries` is produced. */ -export const NotebookPreview = ({ entries, mode }: NotebookPreviewProps) => { - const [isExpanded, setIsExpanded] = useState(false) +export const NotebookPreview = ({ entries, mode, title, className }: NotebookPreviewProps) => { + const [isShowingAllEntries, setIsShowingAllEntries] = useState(false) + const [expandedOverrides, setExpandedOverrides] = useState>({}) const summary = summarizeNotebookDiff(entries, mode) - const visibleEntries = isExpanded ? entries : entries.slice(0, VISIBLE_ENTRY_LIMIT) + const visibleEntries = isShowingAllEntries ? entries : entries.slice(0, VISIBLE_ENTRY_LIMIT) const hiddenCount = entries.length - visibleEntries.length + const isExpanded = (entry: NotebookCellDiffEntry) => + expandedOverrides[getEntryKey(entry)] ?? isEntryExpandedByDefault(entry) + + const hasEntries = entries.length > 0 + const areAllExpanded = visibleEntries.every(isExpanded) + + const toggleAll = () => + setExpandedOverrides( + Object.fromEntries(entries.map((entry) => [getEntryKey(entry), !areAllExpanded])) + ) + return ( -
-

- {formatNotebookDiffSummary(summary)} -

-
- {visibleEntries.map((entry) => ( - - ))} +
+
+ + + {title ?? FALLBACK_TITLE[mode]} + + + {formatNotebookDiffSummary(summary)} + + {hasEntries && ( + + )} +
+
+ {visibleEntries.map((entry) => { + const key = getEntryKey(entry) + return ( + + setExpandedOverrides((prev) => ({ ...prev, [key]: open })) + } + /> + ) + })}
{hiddenCount > 0 && ( - )} diff --git a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.ts b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.ts index 4dd3087ffc8e0..3d40260a6e87b 100644 --- a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.ts +++ b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.ts @@ -17,6 +17,15 @@ export function getEntryKey(entry: NotebookCellDiffEntry): string { } } +/** + * Whether a diff entry starts expanded. Only the entries the user has to actually read to + * decide — the ones whose content the assistant is proposing — open on their own; unchanged, + * moved, and removed cells stay as single rows until asked for. + */ +export function isEntryExpandedByDefault(entry: NotebookCellDiffEntry): boolean { + return entry._tag === 'added' || entry._tag === 'replaced' +} + /** Human label for a collapsed/badge row. */ export function getCellLabel(cell: CellWire | AgentCell): string { switch (cell._tag) { diff --git a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreviewCell.tsx b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreviewCell.tsx index ddac9ae9e1cb6..67153618e7b15 100644 --- a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreviewCell.tsx +++ b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreviewCell.tsx @@ -1,6 +1,14 @@ -import { useState, type ReactNode } from 'react' -import { Badge, Button, cn } from 'ui' -import { CodeBlock, type CodeBlockLang } from 'ui-patterns/CodeBlock' +import { ChevronRight } from 'lucide-react' +import { + cn, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, +} from 'ui' +import { CodeBlock } from 'ui-patterns/CodeBlock' import { getCellCodeBlockLanguage, @@ -15,77 +23,100 @@ import type { AgentCell, CellWire } from '@/data/content/notebooks/notebook-sche export interface NotebookPreviewCellProps { entry: NotebookCellDiffEntry + isExpanded: boolean + onExpandedChange: (isExpanded: boolean) => void } -/** Renders a single diff entry, dispatching on its tag. */ -export const NotebookPreviewCell = ({ entry }: NotebookPreviewCellProps) => { - switch (entry._tag) { - case 'unchanged': - return - case 'removed': - return ( - - ) - case 'moved': - return ( - - ) - case 'added': - return - case 'replaced': - return - } -} +/** + * A single-character gutter marker per change type The glyph is decorative — + * `changeLabel` carries the same information into the row's accessible name and its tooltip. + */ +const CHANGE_MARKERS = { + added: { glyph: '+', changeLabel: 'Added', className: 'text-brand-600' }, + removed: { glyph: '−', changeLabel: 'Removed', className: 'text-destructive' }, + replaced: { glyph: '~', changeLabel: 'Replaced', className: 'text-warning-600' }, + moved: { glyph: '↕', changeLabel: 'Moved', className: 'text-foreground-lighter' }, + unchanged: { glyph: '', changeLabel: 'Unchanged', className: '' }, +} as const + +type ChangeMarker = (typeof CHANGE_MARKERS)[keyof typeof CHANGE_MARKERS] -interface CollapsedRowProps { - label: string - strikethrough?: boolean - badge?: { variant: 'destructive' | 'secondary'; label: string } +/** The cell a row is labelled by — for a replacement, the proposed cell rather than the current one. */ +function getEntryCell(entry: NotebookCellDiffEntry): CellWire | AgentCell { + return entry._tag === 'replaced' ? entry.after : entry.cell } -/** A single muted row with no content — used for unchanged, removed, and moved entries. */ -const CollapsedRow = ({ label, strikethrough, badge }: CollapsedRowProps) => ( -
- {badge && {badge.label}} - - {label} - -
-) +/** One row of the diff card: a header line that collapses to a single row, plus its content. */ +export const NotebookPreviewCell = ({ + entry, + isExpanded, + onExpandedChange, +}: NotebookPreviewCellProps) => { + const marker = CHANGE_MARKERS[entry._tag] + const isRemoved = entry._tag === 'removed' + const label = getCellLabel(getEntryCell(entry)) -interface ContentCellProps { - badge: { variant: 'success' | 'warning'; label: string } - label: string - children: ReactNode + return ( + + + + + + {label} + + + +
+ +
+
+
+ ) } -/** Shared frame for entries that show full cell content — added and replaced cells. */ -const ContentCell = ({ badge, label, children }: ContentCellProps) => ( -
-
- {badge.label} - {label} -
- {children} -
-) +/** + * The gutter glyph, with a tooltip naming the change type it stands for. The glyph stays + * `aria-hidden` — the row's `aria-label` already carries the same word — so the tooltip is a + * pointer affordance rather than a second announcement. + */ +const ChangeGlyph = ({ marker }: { marker: ChangeMarker }) => { + const className = cn('w-3 shrink-0 text-center font-mono text-xs leading-none', marker.className) -const AddedCell = ({ cell }: { cell: AgentCell }) => ( - - - - -) + // An unchanged cell has no glyph, so there is nothing to explain — render the spacer alone. + if (!marker.glyph) return + + return ( + + + + {marker.glyph} + + + {marker.changeLabel} + + ) +} + +const CellBody = ({ entry }: { entry: NotebookCellDiffEntry }) => + entry._tag === 'replaced' ? ( + + ) : ( + <> + + + + ) /** * A `replace_cell` can change only the source parameters (`database_identifier`, @@ -93,12 +124,12 @@ const AddedCell = ({ cell }: { cell: AgentCell }) => ( * change at all, so the metadata is compared independently and rendered as its own * before → after line whenever it differs. */ -const ReplacedCell = ({ before, after }: { before: CellWire; after: AgentCell }) => { +const ReplacedCellBody = ({ before, after }: { before: CellWire; after: AgentCell }) => { const beforeMetadata = getCellMetadataLine(before) const afterMetadata = getCellMetadataLine(after) return ( - + <> )} - + ) } -/** A plain-text metadata line for a query cell — never rendered as a link or attribute. */ -const MetadataLine = ({ text }: { text: string | null }) => - text ?

{text}

: null - -interface ExpandableCodeBlockProps { - language: CodeBlockLang - value: string -} - /** - * `CodeBlock` clipped to a fixed height with a "Show more/less" toggle. `CodeBlock`'s - * wrapper already scrolls (`overflow-auto`), so clipping just changes what's visible. + * The cell's source, always rendered as literal text via `CodeBlock` — agent-authored markdown + * must never be interpreted into real DOM nodes. */ -const ExpandableCodeBlock = ({ language, value }: ExpandableCodeBlockProps) => { - const [isExpanded, setIsExpanded] = useState(false) +const CellSource = ({ cell }: { cell: CellWire | AgentCell }) => ( + +) - return ( -
- - -
- ) -} +/** A plain-text metadata line for a query cell — never rendered as a link or attribute. */ +const MetadataLine = ({ text }: { text: string | null }) => + text ?

{text}

: null diff --git a/apps/studio/components/interfaces/ProjectCreation/ComputeSizeSelector.tsx b/apps/studio/components/interfaces/ProjectCreation/ComputeSizeSelector.tsx index 49ab883d35f35..393d407d52de3 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ComputeSizeSelector.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/ComputeSizeSelector.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import { UseFormReturn } from 'react-hook-form' import { CloudProvider } from 'shared-data' import { @@ -8,11 +9,12 @@ import { SelectItem, SelectTrigger, SelectValue, + useWatch, } from 'ui' import { ComputeBadge } from 'ui-patterns/ComputeBadge' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { sizes } from './ProjectCreation.constants' +import { HIGH_AVAILABILITY_INSTANCE_SIZE, sizes } from './ProjectCreation.constants' import { CreateProjectForm } from './ProjectCreation.schema' import { InlineLink } from '@/components/ui/InlineLink' import Panel from '@/components/ui/Panel' @@ -24,6 +26,19 @@ interface ComputeSizeSelectorProps { } export const ComputeSizeSelector = ({ form }: ComputeSizeSelectorProps) => { + const cloudProvider = useWatch({ control: form.control, name: 'cloudProvider' }) as CloudProvider + const highAvailability = useWatch({ control: form.control, name: 'highAvailability' }) + + const sizeOptions = useMemo( + () => + highAvailability + ? [HIGH_AVAILABILITY_INSTANCE_SIZE] + : sizes.filter((option) => + instanceSizeSpecs[option].cloud_providers.includes(cloudProvider) + ), + [highAvailability, cloudProvider] + ) + return ( { layout="horizontal" label="Compute size" description={ - <> -

- The size for your dedicated database. You can change this later. Learn more about{' '} - - compute add-ons - {' '} - and{' '} - - compute billing - - . -

- +

+ The size for your dedicated database. You can change this later. Learn more about{' '} + + compute add-ons + {' '} + and{' '} + + compute billing + + . +

} > - value !== '' && field.onChange(value)} + > { - {sizes - .filter((option) => - instanceSizeSpecs[option].cloud_providers.includes( - form.getValues('cloudProvider') as CloudProvider - ) - ) - .map((option) => { - return ( - -
-
- -
+ {sizeOptions.map((option) => { + return ( + +
+
+ +
-
- - {instanceSizeSpecs[option].ram} RAM /{' '} - {instanceSizeSpecs[option].cpu} CPU - -

- ${instanceSizeSpecs[option].priceHourly}/hour (~$ - {instanceSizeSpecs[option].priceMonthly}/month) -

-
+
+ + {instanceSizeSpecs[option].ram} RAM / {instanceSizeSpecs[option].cpu}{' '} + CPU + +

+ ${instanceSizeSpecs[option].priceHourly}/hour (~$ + {instanceSizeSpecs[option].priceMonthly}/month) +

- - ) - })} - -
- Larger instance sizes available after creation -
-
+
+
+ ) + })} + {!highAvailability && ( + +
+ Larger instance sizes available after creation +
+
+ )} diff --git a/apps/studio/components/interfaces/ProjectCreation/HighAvailabilityInput.tsx b/apps/studio/components/interfaces/ProjectCreation/HighAvailabilityInput.tsx index 93b8e7ad0f810..9b619fe1ee83a 100644 --- a/apps/studio/components/interfaces/ProjectCreation/HighAvailabilityInput.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/HighAvailabilityInput.tsx @@ -4,6 +4,7 @@ import { type CloudProvider } from 'shared-data' import { Badge, FormControl, FormField, Switch, useWatch } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { HIGH_AVAILABILITY_INSTANCE_SIZE } from './ProjectCreation.constants' import { CreateProjectForm } from './ProjectCreation.schema' import Panel from '@/components/ui/Panel' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' @@ -28,10 +29,12 @@ export const HighAvailabilityInput = ({ cloudProvider: CloudProvider | undefined postgresVersionSelection: string | undefined dbRegion: string | null + instanceSize: string | null }>({ cloudProvider: undefined, postgresVersionSelection: undefined, dbRegion: null, + instanceSize: null, }) const handleHighAvailabilityChange = (checked: boolean) => { @@ -55,6 +58,12 @@ export const HighAvailabilityInput = ({ beforeHighAvailability.current.dbRegion = currentRegion ?? null setValue('dbRegion', highAvailabilityRegionName) } + + const currentInstanceSize = getValues('instanceSize') + if (currentInstanceSize !== HIGH_AVAILABILITY_INSTANCE_SIZE) { + beforeHighAvailability.current.instanceSize = currentInstanceSize ?? null + setValue('instanceSize', HIGH_AVAILABILITY_INSTANCE_SIZE) + } } else { if (beforeHighAvailability.current.cloudProvider !== undefined) { setValue('cloudProvider', beforeHighAvailability.current.cloudProvider) @@ -73,6 +82,11 @@ export const HighAvailabilityInput = ({ setValue('dbRegion', beforeHighAvailability.current.dbRegion) beforeHighAvailability.current.dbRegion = null } + + if (beforeHighAvailability.current.instanceSize !== null) { + setValue('instanceSize', beforeHighAvailability.current.instanceSize) + beforeHighAvailability.current.instanceSize = null + } } } diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.constants.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.constants.ts index fb6dcd1d258a7..6eeb886ce0f31 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.constants.ts +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.constants.ts @@ -6,6 +6,7 @@ import type { export const HIGH_AVAILABILITY_POSTGRES_ENGINE = '17' satisfies PostgresEngine export const HIGH_AVAILABILITY_RELEASE_CHANNEL = 'ga' satisfies ReleaseChannel +export const HIGH_AVAILABILITY_INSTANCE_SIZE: DesiredInstanceSize = 'large' // [Joshen] Obtained from https://gist.github.com/tadast/8827699 export const COUNTRY_LAT_LON = { diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts index ec08c1218ef8a..4f06720b287e3 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { + HIGH_AVAILABILITY_INSTANCE_SIZE, HIGH_AVAILABILITY_POSTGRES_ENGINE, HIGH_AVAILABILITY_RELEASE_CHANNEL, } from './ProjectCreation.constants' @@ -10,9 +11,10 @@ import { } from './ProjectCreation.utils' describe('High Availability project creation constraints', () => { - it('pins the Alpha Postgres engine and release channel', () => { + it('pins the Alpha Postgres engine, release channel, and compute size', () => { expect(HIGH_AVAILABILITY_POSTGRES_ENGINE).toBe('17') expect(HIGH_AVAILABILITY_RELEASE_CHANNEL).toBe('ga') + expect(HIGH_AVAILABILITY_INSTANCE_SIZE).toBe('large') }) it.each([ diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx index 6ef98e5749da5..7b8b4639dc857 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx @@ -371,7 +371,14 @@ export const ProjectCreationForm = ({ values.instanceSize && !sizesWithNoCostConfirmationRequired.includes(values.instanceSize as DesiredInstanceSize) - if (additionalMonthlySpend > 0 && (hasOAuthApps || launchingLargerInstance)) { + // High availability projects are free during Alpha, so the forced large compute + // doesn't incur the usual compute costs. + const requiresCostConfirmation = + !values.highAvailability && + additionalMonthlySpend > 0 && + (hasOAuthApps || launchingLargerInstance) + + if (requiresCostConfirmation) { track('project_creation_simple_version_confirm_modal_opened', { instanceSize: values.instanceSize, }) diff --git a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx index 79915d4e6f11c..4dbd2b49003ff 100644 --- a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx @@ -231,7 +231,11 @@ export const RegionSelector = ({ } > - value !== '' && field.onChange(value)} + disabled={isLoading} + > { - +
-
diff --git a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx index b644d8aeafb67..18a38634a89a4 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 { Button } from 'ui' +import { Button, cn } from 'ui' import { Admonition } from 'ui-patterns/Admonition' import { CodeBlock } from 'ui-patterns/CodeBlock' @@ -118,6 +118,16 @@ interface NotebookConfirmFooterProps { onDeny?: () => void } +/** + * `ConfirmFooter` is built to sit flush under the block it confirms (`border-t-0 rounded-b-lg`), + * so that block has to square off its own bottom corners and the two must not be gapped apart. + */ +const GLUED_TO_FOOTER = 'rounded-b-none' + +function hasConfirmFooter(state: NotebookProposalState) { + return state === 'approval-requested' || state === 'approval-responded' +} + /** The footer morphs (label + disabled) across approval-requested/approval-responded and is absent otherwise. */ function NotebookConfirmFooter({ mode, @@ -129,7 +139,7 @@ function NotebookConfirmFooter({ onApprove, onDeny, }: NotebookConfirmFooterProps) { - if (state !== 'approval-requested' && state !== 'approval-responded') return null + if (!hasConfirmFooter(state)) return null const copy = MODE_COPY[mode] const isApprovalRequested = state === 'approval-requested' @@ -155,7 +165,7 @@ function NotebookParseFailure({ onDeny, }: Pick) { return ( -
+
- +
+
) @@ -243,7 +258,7 @@ function UpdateNotebookProposal({ state, input, onApprove, onDeny }: NotebookPro if (isError || !notebook) { return ( -
+
{(state === 'approval-requested' || state === 'approval-responded') && (