From 268c29cd9dca32ebfcbc5c93c84801cfdd48619b Mon Sep 17 00:00:00 2001
From: Andrew Valleteau
Date: Fri, 24 Oct 2025 18:25:16 +0200
Subject: [PATCH 1/7] feat(studio): give action shortcut for persistent
branches deletion (#39786)
* feat(studio): give action shortcut for persistent branches deletion
* chore: refactor dedup trigger
* chore: no-biome
---
.../interfaces/BranchManagement/Overview.tsx | 38 +++++++++++++++----
1 file changed, 30 insertions(+), 8 deletions(-)
diff --git a/apps/studio/components/interfaces/BranchManagement/Overview.tsx b/apps/studio/components/interfaces/BranchManagement/Overview.tsx
index 666cf7445c62e..4f9f6f047b976 100644
--- a/apps/studio/components/interfaces/BranchManagement/Overview.tsx
+++ b/apps/studio/components/interfaces/BranchManagement/Overview.tsx
@@ -180,9 +180,14 @@ const PreviewBranchActions = ({
const { data } = useBranchQuery({ projectRef, branchRef })
const isBranchActiveHealthy = data?.status === 'ACTIVE_HEALTHY'
+ const isPersistentBranch = branch.persistent
const [showConfirmResetModal, setShowConfirmResetModal] = useState(false)
const [showBranchModeSwitch, setShowBranchModeSwitch] = useState(false)
+ const [
+ showPersistentBranchDeleteConfirmationModal,
+ setShowPersistentBranchDeleteConfirmationModal,
+ ] = useState(false)
const [showEditBranchModal, setShowEditBranchModal] = useState(false)
const { mutate: resetBranch, isLoading: isResetting } = useBranchResetMutation({
@@ -210,6 +215,15 @@ const PreviewBranchActions = ({
updateBranch({ branchRef, projectRef, persistent: !branch.persistent })
}
+ const onDeleteBranch = (e: Event | React.MouseEvent) => {
+ if (isPersistentBranch) {
+ setShowPersistentBranchDeleteConfirmationModal(true)
+ } else {
+ e.stopPropagation()
+ onSelectDeleteBranch()
+ }
+ }
+
return (
<>
@@ -307,14 +321,8 @@ const PreviewBranchActions = ({
{
- e.stopPropagation()
- onSelectDeleteBranch()
- }}
- onClick={(e) => {
- e.stopPropagation()
- onSelectDeleteBranch()
- }}
+ onSelect={onDeleteBranch}
+ onClick={onDeleteBranch}
tooltip={{
content: {
side: 'left',
@@ -373,6 +381,20 @@ const PreviewBranchActions = ({
+ setShowPersistentBranchDeleteConfirmationModal(false)}
+ onConfirm={onTogglePersistent}
+ >
+
+ You must switch the branch "{branch.name}" to preview before deleting it.
+
+
+
Date: Fri, 24 Oct 2025 10:48:59 -0600
Subject: [PATCH 2/7] Fix: infinite re-render on table when computing foreign
keys (#39850)
fix infinite re-render on table editor
---
.../SidePanelEditor/TableEditor/TableEditor.tsx | 14 +++++++++-----
1 file changed, 9 insertions(+), 5 deletions(-)
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx
index c5e7247aa4abe..37c72cf86a72e 100644
--- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx
+++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx
@@ -1,6 +1,6 @@
import type { PostgresTable } from '@supabase/postgres-meta'
import { isEmpty, isUndefined, noop } from 'lodash'
-import { useEffect, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { DocsButton } from 'components/ui/DocsButton'
@@ -141,14 +141,18 @@ export const TableEditor = ({
(constraint) => constraint.type === CONSTRAINT_TYPE.PRIMARY_KEY_CONSTRAINT
)
- const { data: foreignKeyMeta, isSuccess: isSuccessForeignKeyMeta } =
+ const { data: foreignKeyMeta = [], isSuccess: isSuccessForeignKeyMeta } =
useForeignKeyConstraintsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
schema: table?.schema,
})
- const foreignKeys = (foreignKeyMeta ?? []).filter(
- (fk) => fk.source_schema === table?.schema && fk.source_table === table?.name
+ const foreignKeys = useMemo(
+ () =>
+ foreignKeyMeta.filter(
+ (fk) => fk.source_schema === table?.schema && fk.source_table === table?.name
+ ),
+ [foreignKeyMeta, table]
)
const onUpdateField = (changes: Partial) => {
@@ -245,7 +249,7 @@ export const TableEditor = ({
} else {
const tableFields = generateTableFieldFromPostgresTable(
table,
- foreignKeyMeta || [],
+ foreignKeyMeta,
isDuplicating,
isRealtimeEnabled
)
From 0a99c0eb049d5c06af7c81277dc121e7c45cfba5 Mon Sep 17 00:00:00 2001
From: Ali Waseem
Date: Fri, 24 Oct 2025 11:19:25 -0600
Subject: [PATCH 3/7] Fix: Removed default array value in case memo is broken
(#39851)
Removed default array value in case memo is broken
---
.../SidePanelEditor/TableEditor/TableEditor.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx
index 37c72cf86a72e..41290142bad4e 100644
--- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx
+++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx
@@ -141,7 +141,7 @@ export const TableEditor = ({
(constraint) => constraint.type === CONSTRAINT_TYPE.PRIMARY_KEY_CONSTRAINT
)
- const { data: foreignKeyMeta = [], isSuccess: isSuccessForeignKeyMeta } =
+ const { data: foreignKeyMeta, isSuccess: isSuccessForeignKeyMeta } =
useForeignKeyConstraintsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
@@ -149,7 +149,7 @@ export const TableEditor = ({
})
const foreignKeys = useMemo(
() =>
- foreignKeyMeta.filter(
+ (foreignKeyMeta ?? []).filter(
(fk) => fk.source_schema === table?.schema && fk.source_table === table?.name
),
[foreignKeyMeta, table]
@@ -249,7 +249,7 @@ export const TableEditor = ({
} else {
const tableFields = generateTableFieldFromPostgresTable(
table,
- foreignKeyMeta,
+ foreignKeyMeta ?? [],
isDuplicating,
isRealtimeEnabled
)
From 3eb85075666c949661aff4747d84193bec0777cd Mon Sep 17 00:00:00 2001
From: Ali Waseem
Date: Fri, 24 Oct 2025 11:31:30 -0600
Subject: [PATCH 4/7] Feature: Abililty to duplicate triggers from the
dashboard (#39828)
* updated to support on close and duplicate
* updated formatting
* added confirmation panel for triggers
* updated form to select the correct table for defaults
* updated to support trigger intial tables selected
* updated to mark select field as dirty
* Update apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx
Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>
* Update apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx
Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>
* updated to remove undefined error
---------
Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>
---
.../Database/Triggers/TriggerSheet.tsx | 75 ++++++++++++++-----
.../Triggers/TriggersList/TriggerList.tsx | 15 +++-
.../Triggers/TriggersList/TriggersList.tsx | 3 +
.../pages/project/[ref]/database/triggers.tsx | 50 ++++++++++---
4 files changed, 112 insertions(+), 31 deletions(-)
diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx
index f7214e1d0fe2b..6cc885670ae55 100644
--- a/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx
+++ b/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx
@@ -40,6 +40,7 @@ import {
TRIGGER_ORIENTATIONS,
TRIGGER_TYPES,
} from './Triggers.constants'
+import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
const formId = 'create-trigger'
@@ -75,20 +76,27 @@ const defaultValues: z.infer = {
interface TriggerSheetProps {
selectedTrigger?: PostgresTrigger
+ isDuplicatingTrigger?: boolean
open: boolean
- setOpen: (val: boolean) => void
+ onClose: () => void
}
-export const TriggerSheet = ({ selectedTrigger, open, setOpen }: TriggerSheetProps) => {
+export const TriggerSheet = ({
+ selectedTrigger,
+ isDuplicatingTrigger,
+ open,
+ onClose,
+}: TriggerSheetProps) => {
const { data: project } = useSelectedProjectQuery()
+ const [isClosingPanel, setIsClosingPanel] = useState(false)
const [showFunctionSelector, setShowFunctionSelector] = useState(false)
const { mutate: createDatabaseTrigger, isLoading: isCreating } = useDatabaseTriggerCreateMutation(
{
- onSuccess: (res) => {
- toast.success(`Successfully created trigger ${res.name}`)
- setOpen(false)
+ onSuccess: () => {
+ toast.success(`Successfully created trigger`)
+ onClose()
},
onError: (error) => {
toast.error(`Failed to create trigger: ${error.message}`)
@@ -97,9 +105,9 @@ export const TriggerSheet = ({ selectedTrigger, open, setOpen }: TriggerSheetPro
)
const { mutate: updateDatabaseTrigger, isLoading: isUpdating } = useDatabaseTriggerUpdateMutation(
{
- onSuccess: (res) => {
- toast.success(`Successfully updated trigger ${res.name}`)
- setOpen(false)
+ onSuccess: () => {
+ toast.success(`Successfully updated trigger`)
+ onClose()
},
onError: (error) => {
toast.error(`Failed to update trigger: ${error.message}`)
@@ -117,7 +125,7 @@ export const TriggerSheet = ({ selectedTrigger, open, setOpen }: TriggerSheetPro
const tables = data
.sort((a, b) => a.schema.localeCompare(b.schema))
.filter((a) => !protectedSchemas.find((s) => s.name === a.schema))
- const isEditing = !!selectedTrigger
+ const isEditing = !isDuplicatingTrigger && !!selectedTrigger
const form = useForm>({
mode: 'onSubmit',
@@ -127,6 +135,10 @@ export const TriggerSheet = ({ selectedTrigger, open, setOpen }: TriggerSheetPro
})
const { function_name, function_schema } = form.watch()
+ function isClosingSidePanel() {
+ form.formState.isDirty ? setIsClosingPanel(true) : onClose()
+ }
+
const onSubmit: SubmitHandler> = async (values) => {
if (!project) return console.error('Project is required')
const { tableId, ...payload } = values
@@ -151,7 +163,16 @@ export const TriggerSheet = ({ selectedTrigger, open, setOpen }: TriggerSheetPro
if (open && isSuccess) {
form.clearErrors()
- if (isEditing) {
+ if (isDuplicatingTrigger && selectedTrigger) {
+ const initalSelectedTable = tables.find((t) => t.name === selectedTrigger.table)
+
+ form.reset({
+ ...selectedTrigger,
+ tableId: initalSelectedTable?.id.toString(),
+ table: initalSelectedTable?.name,
+ schema: initalSelectedTable?.schema,
+ })
+ } else if (isEditing) {
form.reset(selectedTrigger)
} else if (tables.length > 0) {
form.reset({
@@ -167,13 +188,15 @@ export const TriggerSheet = ({ selectedTrigger, open, setOpen }: TriggerSheetPro
return (
<>
-
+
- {isEditing
- ? `Edit database trigger: ${selectedTrigger.name}`
- : 'Create a new database trigger'}
+ {isDuplicatingTrigger
+ ? 'Duplicate trigger'
+ : isEditing
+ ? `Edit database trigger: ${selectedTrigger.name}`
+ : 'Create a new database trigger'}
@@ -250,10 +273,12 @@ export const TriggerSheet = ({ selectedTrigger, open, setOpen }: TriggerSheetPro
{
+ // mark table ID as dirty to trigger validation
+ field.onChange(val)
const table = tables.find((x) => x.id.toString() === val)
if (table) {
- form.setValue('table', table.name)
- form.setValue('schema', table.schema)
+ form.setValue('table', table.name, { shouldDirty: true })
+ form.setValue('schema', table.schema, { shouldDirty: true })
}
}}
>
@@ -446,7 +471,7 @@ export const TriggerSheet = ({ selectedTrigger, open, setOpen }: TriggerSheetPro
type="default"
htmlType="reset"
disabled={isCreating || isUpdating}
- onClick={() => setOpen(false)}
+ onClick={onClose}
>
Cancel
@@ -454,6 +479,22 @@ export const TriggerSheet = ({ selectedTrigger, open, setOpen }: TriggerSheetPro
{isEditing ? 'Save' : 'Create'} trigger
+
+ setIsClosingPanel(false)}
+ onConfirm={() => {
+ setIsClosingPanel(false)
+ onClose()
+ }}
+ >
+
+ There are unsaved changes. Are you sure you want to close the panel? Your changes will
+ be lost.
+
+
diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx
index 9e0862014fea8..2690209c90437 100644
--- a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx
+++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx
@@ -1,6 +1,6 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { includes, sortBy } from 'lodash'
-import { Check, Edit, Edit2, MoreVertical, Trash, X } from 'lucide-react'
+import { Check, Copy, Edit, Edit2, MoreVertical, Trash, X } from 'lucide-react'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useDatabaseTriggersQuery } from 'data/database-triggers/database-triggers-query'
@@ -13,6 +13,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
+ DropdownMenuSeparator,
DropdownMenuTrigger,
TableCell,
TableRow,
@@ -21,13 +22,15 @@ import {
TooltipTrigger,
} from 'ui'
import { generateTriggerCreateSQL } from './TriggerList.utils'
+import { PostgresTrigger } from '@supabase/postgres-meta'
interface TriggerListProps {
schema: string
filterString: string
isLocked: boolean
- editTrigger: (trigger: any) => void
- deleteTrigger: (trigger: any) => void
+ editTrigger: (trigger: PostgresTrigger) => void
+ duplicateTrigger: (trigger: PostgresTrigger) => void
+ deleteTrigger: (trigger: PostgresTrigger) => void
}
const TriggerList = ({
@@ -35,6 +38,7 @@ const TriggerList = ({
filterString,
isLocked,
editTrigger,
+ duplicateTrigger,
deleteTrigger,
}: TriggerListProps) => {
const { data: project } = useSelectedProjectQuery()
@@ -197,6 +201,11 @@ const TriggerList = ({
Edit with Assistant
+ duplicateTrigger(x)}>
+
+ Duplicate trigger
+
+
deleteTrigger(x)}>
Delete trigger
diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx
index 30b651ba93887..1f5d10fe750a9 100644
--- a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx
+++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx
@@ -37,12 +37,14 @@ import Link from 'next/link'
interface TriggersListProps {
createTrigger: () => void
editTrigger: (trigger: PostgresTrigger) => void
+ duplicateTrigger: (trigger: PostgresTrigger) => void
deleteTrigger: (trigger: PostgresTrigger) => void
}
const TriggersList = ({
createTrigger = noop,
editTrigger = noop,
+ duplicateTrigger = noop,
deleteTrigger = noop,
}: TriggersListProps) => {
const { data: project } = useSelectedProjectQuery()
@@ -232,6 +234,7 @@ const TriggersList = ({
filterString={filterString}
isLocked={isSchemaLocked}
editTrigger={editTrigger}
+ duplicateTrigger={duplicateTrigger}
deleteTrigger={deleteTrigger}
/>
diff --git a/apps/studio/pages/project/[ref]/database/triggers.tsx b/apps/studio/pages/project/[ref]/database/triggers.tsx
index f08ad5a0f32ea..6830818d9d9e8 100644
--- a/apps/studio/pages/project/[ref]/database/triggers.tsx
+++ b/apps/studio/pages/project/[ref]/database/triggers.tsx
@@ -21,6 +21,8 @@ const TriggersPage: NextPageWithLayout = () => {
const isInlineEditorEnabled = useIsInlineEditorEnabled()
const [selectedTrigger, setSelectedTrigger] = useState()
+ const [isDuplicatingTrigger, setIsDuplicatingTrigger] = useState(false)
+
const [showCreateTriggerForm, setShowCreateTriggerForm] = useState(false)
const [showDeleteTriggerForm, setShowDeleteTriggerForm] = useState(false)
@@ -53,11 +55,34 @@ const TriggersPage: NextPageWithLayout = () => {
}
}
+ const duplicateTrigger = (trigger: PostgresTrigger) => {
+ setIsDuplicatingTrigger(true)
+
+ const dupTrigger = {
+ ...trigger,
+ name: `${trigger.name}_duplicate`,
+ }
+
+ if (isInlineEditorEnabled) {
+ setSelectedTriggerForEditor(dupTrigger)
+ setEditorPanelOpen(true)
+ } else {
+ setSelectedTrigger(dupTrigger)
+ setShowCreateTriggerForm(true)
+ }
+ }
+
const deleteTrigger = (trigger: PostgresTrigger) => {
setSelectedTrigger(trigger)
setShowDeleteTriggerForm(true)
}
+ const resetEditorPanel = () => {
+ setIsDuplicatingTrigger(false)
+ setEditorPanelOpen(false)
+ setSelectedTriggerForEditor(undefined)
+ }
+
if (isPermissionsLoaded && !canReadTriggers) {
return
}
@@ -75,6 +100,7 @@ const TriggersPage: NextPageWithLayout = () => {
@@ -83,7 +109,11 @@ const TriggersPage: NextPageWithLayout = () => {
{
+ setIsDuplicatingTrigger(false)
+ setShowCreateTriggerForm(false)
+ }}
+ isDuplicatingTrigger={isDuplicatingTrigger}
/>
{
{
- setEditorPanelOpen(false)
- setSelectedTriggerForEditor(undefined)
- }}
- onClose={() => {
- setEditorPanelOpen(false)
- setSelectedTriggerForEditor(undefined)
- }}
+ onRunSuccess={resetEditorPanel}
+ onClose={resetEditorPanel}
initialValue={
selectedTriggerForEditor
? generateTriggerCreateSQL(selectedTriggerForEditor)
@@ -111,12 +135,16 @@ execute function function_name();`
}
label={
selectedTriggerForEditor
- ? `Edit trigger "${selectedTriggerForEditor.name}"`
+ ? isDuplicatingTrigger
+ ? `Duplicate trigger "${selectedTriggerForEditor.name}"`
+ : `Edit trigger "${selectedTriggerForEditor.name}"`
: 'Create new database trigger'
}
initialPrompt={
selectedTriggerForEditor
- ? `Update the database trigger "${selectedTriggerForEditor.name}" to...`
+ ? isDuplicatingTrigger
+ ? `Duplicate the database trigger "${selectedTriggerForEditor.name}" to...`
+ : `Update the database trigger "${selectedTriggerForEditor.name}" to...`
: 'Create a new database trigger that...'
}
/>
From 5576b37a649908fd3797efc5bc03a7003bd0e4f1 Mon Sep 17 00:00:00 2001
From: Ben Isenstein
Date: Fri, 24 Oct 2025 12:08:00 -0600
Subject: [PATCH 5/7] Fix #39827 (#39829)
fix: storage ui failing to authenticate uploads #39827
Co-authored-by: Ali Waseem
---
apps/studio/state/storage-explorer.tsx | 3 +++
1 file changed, 3 insertions(+)
diff --git a/apps/studio/state/storage-explorer.tsx b/apps/studio/state/storage-explorer.tsx
index fbeb9a547bee8..751080cd58c23 100644
--- a/apps/studio/state/storage-explorer.tsx
+++ b/apps/studio/state/storage-explorer.tsx
@@ -1198,6 +1198,9 @@ function createStorageExplorerState({
try {
const data = await getTemporaryAPIKey({ projectRef: state.projectRef })
req.setHeader('apikey', data.api_key)
+ if (!IS_PLATFORM) {
+ req.setHeader('Authorization', `Bearer ${data.api_key}`)
+ }
} catch (error) {
throw error
}
From 73e3143b0c56e214f47c0761a71c3bbdc7b5e339 Mon Sep 17 00:00:00 2001
From: Sean Oliver <882952+seanoliver@users.noreply.github.com>
Date: Fri, 24 Oct 2025 11:25:26 -0700
Subject: [PATCH 6/7] Add type-safe event tracking utility (#39745)
Adds a clean, type-safe wrapper for telemetry event tracking that automatically injects project and organization context.
- Export TelemetryGroups type from telemetry-constants
- Add useTrack() hook with full TypeScript event validation
- Refactor project creation events to use new API
- Reduces boilerplate from ~10 lines to ~2 lines per event
---
.../ProjectCreation/SchemaGenerator.tsx | 18 ++----
apps/studio/lib/telemetry/track.ts | 61 +++++++++++++++++++
apps/studio/pages/new/[slug].tsx | 26 +++-----
packages/common/telemetry-constants.ts | 2 +-
4 files changed, 78 insertions(+), 29 deletions(-)
create mode 100644 apps/studio/lib/telemetry/track.ts
diff --git a/apps/studio/components/interfaces/ProjectCreation/SchemaGenerator.tsx b/apps/studio/components/interfaces/ProjectCreation/SchemaGenerator.tsx
index deaf56126fdb4..2e2678050f44b 100644
--- a/apps/studio/components/interfaces/ProjectCreation/SchemaGenerator.tsx
+++ b/apps/studio/components/interfaces/ProjectCreation/SchemaGenerator.tsx
@@ -4,8 +4,8 @@ import { useEffect, useState } from 'react'
import { Markdown } from 'components/interfaces/Markdown'
import { onErrorChat } from 'components/ui/AIAssistantPanel/AIAssistant.utils'
-import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { BASE_PATH } from 'lib/constants'
+import { useTrack } from 'lib/telemetry/track'
import { AiIconAnimation, Button, Label_Shadcn_, Textarea } from 'ui'
interface SupabaseService {
@@ -34,7 +34,7 @@ export const SchemaGenerator = ({
const [hasSql, setHasSql] = useState(false)
const [promptIntendSent, setPromptIntendSent] = useState(false)
- const { mutate: sendEvent } = useSendEventMutation()
+ const track = useTrack()
const { messages, setMessages, sendMessage, status, addToolResult } = useChat({
id: 'schema-generator',
@@ -193,18 +193,12 @@ export const SchemaGenerator = ({
const isNewPrompt = messages.length == 0
// distinguish between initial step or second step
if (step === 'initial') {
- sendEvent({
- action: 'project_creation_initial_step_prompt_intended',
- properties: {
- isNewPrompt,
- },
+ track('project_creation_initial_step_prompt_intended', {
+ isNewPrompt,
})
} else {
- sendEvent({
- action: 'project_creation_second_step_prompt_intended',
- properties: {
- isNewPrompt,
- },
+ track('project_creation_second_step_prompt_intended', {
+ isNewPrompt,
})
}
setPromptIntendSent(true)
diff --git a/apps/studio/lib/telemetry/track.ts b/apps/studio/lib/telemetry/track.ts
new file mode 100644
index 0000000000000..ec1755bc9a304
--- /dev/null
+++ b/apps/studio/lib/telemetry/track.ts
@@ -0,0 +1,61 @@
+import { sendTelemetryEvent } from 'common'
+import { TelemetryEvent, TelemetryGroups } from 'common/telemetry-constants'
+import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
+import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
+import { API_URL } from 'lib/constants'
+import { useRouter } from 'next/router'
+import { useCallback } from 'react'
+
+type EventMap = {
+ [E in TelemetryEvent as E['action']]: E
+}
+
+type PropertiesForAction = EventMap[A] extends { properties: infer P }
+ ? P
+ : never
+
+type HasProperties = EventMap[A] extends { properties: any }
+ ? true
+ : false
+
+/**
+ * Hook for type-safe telemetry event tracking with automatic project/org context injection.
+ *
+ * @example
+ * const track = useTrack()
+ * track('table_created', { method: 'sql_editor', schema_name: 'public' })
+ * track('help_button_clicked')
+ */
+export const useTrack = () => {
+ const { data: project } = useSelectedProjectQuery()
+ const { data: org } = useSelectedOrganizationQuery()
+ const router = useRouter()
+
+ const track = useCallback(
+ (
+ action: A,
+ ...args: HasProperties extends true
+ ? [properties: PropertiesForAction, groupOverrides?: Partial]
+ : [properties?: undefined, groupOverrides?: Partial]
+ ) => {
+ const [properties, groupOverrides] = args
+
+ const groups = {
+ ...(project?.ref && { project: project.ref }),
+ ...(org?.slug && { organization: org.slug }),
+ ...groupOverrides,
+ }
+
+ const event = {
+ action,
+ ...(properties && { properties }),
+ ...(groups && { groups }),
+ } as EventMap[A]
+
+ sendTelemetryEvent(API_URL, event, router.pathname)
+ },
+ [project?.ref, org?.slug, router.pathname]
+ )
+
+ return track
+}
diff --git a/apps/studio/pages/new/[slug].tsx b/apps/studio/pages/new/[slug].tsx
index d0448411dbdfb..5d1782ce87b58 100644
--- a/apps/studio/pages/new/[slug].tsx
+++ b/apps/studio/pages/new/[slug].tsx
@@ -43,7 +43,7 @@ import {
ProjectCreateVariables,
useProjectCreateMutation,
} from 'data/projects/project-create-mutation'
-import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
+import { useTrack } from 'lib/telemetry/track'
import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
@@ -171,7 +171,7 @@ const Wizard: NextPageWithLayout = () => {
const [isComputeCostsConfirmationModalVisible, setIsComputeCostsConfirmationModalVisible] =
useState(false)
- const { mutate: sendEvent } = useSendEventMutation()
+ const track = useTrack()
FormSchema.superRefine(({ dbPassStrength }, refinementContext) => {
if (dbPassStrength < DEFAULT_MINIMUM_PASSWORD_STRENGTH) {
@@ -247,16 +247,16 @@ const Wizard: NextPageWithLayout = () => {
isSuccess: isSuccessNewProject,
} = useProjectCreateMutation({
onSuccess: (res) => {
- sendEvent({
- action: 'project_creation_simple_version_submitted',
- properties: {
+ track(
+ 'project_creation_simple_version_submitted',
+ {
instanceSize: form.getValues('instanceSize'),
},
- groups: {
+ {
project: res.ref,
organization: res.organization_slug,
- },
- })
+ }
+ )
router.push(isHomeNew ? `/project/${res.ref}` : `/project/${res.ref}/building`)
},
})
@@ -372,14 +372,8 @@ const Wizard: NextPageWithLayout = () => {
!sizesWithNoCostConfirmationRequired.includes(values.instanceSize as DesiredInstanceSize)
if (additionalMonthlySpend > 0 && (hasOAuthApps || launchingLargerInstance)) {
- sendEvent({
- action: 'project_creation_simple_version_confirm_modal_opened',
- properties: {
- instanceSize: values.instanceSize,
- },
- groups: {
- organization: currentOrg?.slug ?? 'Unknown',
- },
+ track('project_creation_simple_version_confirm_modal_opened', {
+ instanceSize: values.instanceSize,
})
setIsComputeCostsConfirmationModalVisible(true)
} else {
diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts
index 0d370b71ea45d..300f25a189e78 100644
--- a/packages/common/telemetry-constants.ts
+++ b/packages/common/telemetry-constants.ts
@@ -9,7 +9,7 @@
* @module telemetry-frontend
*/
-type TelemetryGroups = {
+export type TelemetryGroups = {
project: string
organization: string
}
From e05981cf2cfbc1fd5958bbd495321d310c59163f Mon Sep 17 00:00:00 2001
From: Sean Oliver <882952+seanoliver@users.noreply.github.com>
Date: Fri, 24 Oct 2025 12:20:38 -0700
Subject: [PATCH 7/7] feat: add assistant variation to table quickstart
experiment (2.5 of 3) (#39825)
feat: add assistant variation to table quickstart experiment
Adds ASSISTANT variant to table quickstart that opens AI assistant with
pre-populated conversation to guide users through creating their first
database table.
Changes:
- Add QuickstartVariant.ASSISTANT enum value
- Add 'Create with Assistant' card for new projects (<7 days old)
- Implement handleOpenAssistant with proper loading states and error handling
- Add pre-populated messages to guide table creation workflow
The assistant card appears only for users bucketed into the assistant
variation of the tableQuickstart experiment on new projects.
---
.../TableEditor/TableQuickstart/types.ts | 1 +
.../studio/components/layouts/Tabs/NewTab.tsx | 58 ++++++++++++++++++-
2 files changed, 58 insertions(+), 1 deletion(-)
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/types.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/types.ts
index 8fbe022d0ee5e..331b163d31ea8 100644
--- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/types.ts
+++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/types.ts
@@ -37,6 +37,7 @@ export enum QuickstartVariant {
CONTROL = 'control',
AI = 'ai',
TEMPLATES = 'templates',
+ ASSISTANT = 'assistant',
}
export type TableSuggestion = {
diff --git a/apps/studio/components/layouts/Tabs/NewTab.tsx b/apps/studio/components/layouts/Tabs/NewTab.tsx
index aea59d0500ae9..1eb98dc29ebf6 100644
--- a/apps/studio/components/layouts/Tabs/NewTab.tsx
+++ b/apps/studio/components/layouts/Tabs/NewTab.tsx
@@ -4,7 +4,7 @@ import { partition } from 'lodash'
import { Table2 } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
-import { useMemo } from 'react'
+import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
@@ -20,10 +20,12 @@ import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { usePHFlag } from 'hooks/ui/useFlag'
import { uuidv4 } from 'lib/helpers'
import { useProfile } from 'lib/profile'
+import { useAiAssistantStateSnapshot, AssistantMessageType } from 'state/ai-assistant-state'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import {
+ AiIconAnimation,
Button,
cn,
SQL_ICON,
@@ -39,6 +41,12 @@ import { RecentItems } from './RecentItems'
const NEW_PROJECT_THRESHOLD_DAYS = 7
const TABLE_QUICKSTART_FLAG = 'tableQuickstart'
+const ASSISTANT_QUICKSTART_MESSAGES = {
+ user: 'Help me create a new database table for my project',
+ assistant:
+ "I'll help you create a database table. Please tell me:\n\n1. What does your application do?\n2. What kind of data do you want to store?\n\nI'll suggest a table structure that fits your requirements and help you create it directly in your database.",
+}
+
export function NewTab() {
const router = useRouter()
const { ref } = useParams()
@@ -50,7 +58,9 @@ export function NewTab() {
const snap = useTableEditorStateSnapshot()
const snapV2 = useSqlEditorV2StateSnapshot()
const tabs = useTabsStateSnapshot()
+ const aiSnap = useAiAssistantStateSnapshot()
+ const [isCreatingChat, setIsCreatingChat] = useState(false)
const [templates] = partition(SQL_TEMPLATES, { type: 'template' })
const [quickstarts] = partition(SQL_TEMPLATES, { type: 'quickstart' })
@@ -87,6 +97,43 @@ export function NewTab() {
? tableQuickstartVariant
: null
+ const handleOpenAssistant = () => {
+ if (isCreatingChat) return
+
+ setIsCreatingChat(true)
+
+ try {
+ const chatId = aiSnap.newChat({
+ name: 'Create a database table',
+ open: true,
+ })
+
+ if (!chatId) {
+ throw new Error('Failed to create chat')
+ }
+
+ const userMessage: AssistantMessageType = {
+ id: uuidv4(),
+ role: 'user',
+ parts: [{ type: 'text', text: ASSISTANT_QUICKSTART_MESSAGES.user }],
+ }
+
+ const assistantMessage: AssistantMessageType = {
+ id: uuidv4(),
+ role: 'assistant',
+ parts: [{ type: 'text', text: ASSISTANT_QUICKSTART_MESSAGES.assistant }],
+ }
+
+ aiSnap.saveMessage([userMessage, assistantMessage])
+ } catch (error) {
+ console.error('Failed to open AI assistant:', error)
+ const message = error instanceof Error ? error.message : 'Unknown error'
+ toast.error(`Unable to open AI assistant: ${message}`)
+ } finally {
+ setIsCreatingChat(false)
+ }
+ }
+
const tableEditorActions = [
{
icon: ,
@@ -153,6 +200,15 @@ export function NewTab() {
{actions.map((item, i) => (
))}
+ {activeQuickstartVariant === QuickstartVariant.ASSISTANT && (
+ }
+ title="Create with Assistant"
+ description="Use AI to design your database table"
+ bgColor="bg-brand-200"
+ onClick={handleOpenAssistant}
+ />
+ )}
{activeQuickstartVariant === QuickstartVariant.AI && (
snap.onAddTable(tableData)} />