diff --git a/apps/studio/components/interfaces/DevToolbarMenuGroup.tsx b/apps/studio/components/interfaces/DevToolbarMenuGroup.tsx
new file mode 100644
index 00000000000..495c4da8861
--- /dev/null
+++ b/apps/studio/components/interfaces/DevToolbarMenuGroup.tsx
@@ -0,0 +1,30 @@
+import { useDevToolbar } from 'dev-tools'
+import { DropdownMenuCheckboxItem, DropdownMenuGroup, DropdownMenuLabel } from 'ui'
+
+export function DevToolbarMenuGroup() {
+ const { isAvailable, isEnabled, enableToolbar, dismissToolbar } = useDevToolbar()
+
+ if (!isAvailable) return null
+
+ const handleToggleDevToolbar = (isChecked: boolean) => {
+ if (isChecked) {
+ enableToolbar()
+ return
+ }
+
+ dismissToolbar()
+ }
+
+ return (
+
+ Local tools
+
+ Dev toolbar
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/LocalDropdown.test.tsx b/apps/studio/components/interfaces/LocalDropdown.test.tsx
index 5afef5f725f..7cdfdb91a9d 100644
--- a/apps/studio/components/interfaces/LocalDropdown.test.tsx
+++ b/apps/studio/components/interfaces/LocalDropdown.test.tsx
@@ -1,21 +1,41 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { MouseEventHandler, ReactElement, ReactNode } from 'react'
-import { describe, expect, it, vi } from 'vitest'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LocalDropdown } from './LocalDropdown'
-const { mockRouter, mockSetTheme, mockSetLastRoute, mockToggleFeaturePreviewModal } = vi.hoisted(
- () => ({
- mockRouter: {
- pathname: '/project/[ref]/editor',
- asPath: '/project/default/editor',
- },
- mockSetTheme: vi.fn(),
- mockSetLastRoute: vi.fn(),
- mockToggleFeaturePreviewModal: vi.fn(),
- })
-)
+const {
+ mockRouter,
+ mockSetTheme,
+ mockSetLastRoute,
+ mockToggleFeaturePreviewModal,
+ mockEnableToolbar,
+ mockDismissDevToolbar,
+ mockSetDevToolbarOpen,
+ mockUseDevToolbar,
+} = vi.hoisted(() => ({
+ mockRouter: {
+ pathname: '/project/[ref]/editor',
+ asPath: '/project/default/editor',
+ },
+ mockSetTheme: vi.fn(),
+ mockSetLastRoute: vi.fn(),
+ mockToggleFeaturePreviewModal: vi.fn(),
+ mockEnableToolbar: vi.fn(),
+ mockDismissDevToolbar: vi.fn(),
+ mockSetDevToolbarOpen: vi.fn(),
+ mockUseDevToolbar: vi.fn(() => ({
+ isAvailable: false,
+ isEnabled: false,
+ isOpen: false,
+ setIsOpen: mockSetDevToolbarOpen,
+ enableToolbar: mockEnableToolbar,
+ dismissToolbar: mockDismissDevToolbar,
+ events: [],
+ setEvents: vi.fn(),
+ })),
+}))
vi.mock('next/router', () => ({
useRouter: () => mockRouter,
@@ -62,6 +82,10 @@ vi.mock('./App/FeaturePreview/FeaturePreviewContext', () => ({
vi.mock('@/lib/telemetry/track', () => ({ useTrack: () => vi.fn() }))
+vi.mock('dev-tools', () => ({
+ useDevToolbar: () => mockUseDevToolbar(),
+}))
+
vi.mock('ui', async () => {
const React = await import('react')
@@ -104,6 +128,19 @@ vi.mock('ui', async () => {
),
DropdownMenuLabel: ({ children }: { children: ReactNode }) =>
{children}
,
+ DropdownMenuCheckboxItem: ({
+ children,
+ checked,
+ onCheckedChange,
+ }: {
+ children: ReactNode
+ checked?: boolean
+ onCheckedChange?: (checked: boolean) => void
+ }) => (
+
+ ),
DropdownMenuSeparator: () =>
,
DropdownMenuRadioGroup: ({
children,
@@ -144,6 +181,20 @@ vi.mock('ui', async () => {
})
describe('LocalDropdown', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseDevToolbar.mockReturnValue({
+ isAvailable: false,
+ isEnabled: false,
+ isOpen: false,
+ setIsOpen: mockSetDevToolbarOpen,
+ enableToolbar: mockEnableToolbar,
+ dismissToolbar: mockDismissDevToolbar,
+ events: [],
+ setEvents: vi.fn(),
+ })
+ })
+
it('shows Preferences, removes Command menu, and keeps theme controls wired', async () => {
const user = userEvent.setup()
@@ -152,6 +203,7 @@ describe('LocalDropdown', () => {
expect(screen.getByText('Preferences')).toBeInTheDocument()
expect(screen.queryByText('Command menu')).not.toBeInTheDocument()
expect(screen.getByText('Theme')).toBeInTheDocument()
+ expect(screen.queryByText('Dev toolbar')).not.toBeInTheDocument()
await user.click(screen.getByText('Preferences'))
expect(mockSetLastRoute).toHaveBeenCalledWith('/project/default/editor')
@@ -162,4 +214,50 @@ describe('LocalDropdown', () => {
await user.click(screen.getByText('Light'))
expect(mockSetTheme).toHaveBeenCalledWith('light')
})
+
+ it('toggles Dev toolbar visibility from the menu', async () => {
+ mockUseDevToolbar.mockReturnValue({
+ isAvailable: true,
+ isEnabled: false,
+ isOpen: false,
+ setIsOpen: mockSetDevToolbarOpen,
+ enableToolbar: mockEnableToolbar,
+ dismissToolbar: mockDismissDevToolbar,
+ events: [],
+ setEvents: vi.fn(),
+ })
+
+ const user = userEvent.setup()
+
+ render()
+
+ expect(screen.getByText('Local tools')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'Dev toolbar' }))
+
+ expect(mockEnableToolbar).toHaveBeenCalled()
+ expect(mockDismissDevToolbar).not.toHaveBeenCalled()
+ })
+
+ it('hides Dev toolbar from the menu when toggled off', async () => {
+ mockUseDevToolbar.mockReturnValue({
+ isAvailable: true,
+ isEnabled: true,
+ isOpen: false,
+ setIsOpen: mockSetDevToolbarOpen,
+ enableToolbar: mockEnableToolbar,
+ dismissToolbar: mockDismissDevToolbar,
+ events: [],
+ setEvents: vi.fn(),
+ })
+
+ const user = userEvent.setup()
+
+ render()
+
+ await user.click(screen.getByRole('button', { name: 'Dev toolbar' }))
+
+ expect(mockDismissDevToolbar).toHaveBeenCalled()
+ expect(mockEnableToolbar).not.toHaveBeenCalled()
+ })
})
diff --git a/apps/studio/components/interfaces/LocalDropdown.tsx b/apps/studio/components/interfaces/LocalDropdown.tsx
index ea78833af65..6f550d14537 100644
--- a/apps/studio/components/interfaces/LocalDropdown.tsx
+++ b/apps/studio/components/interfaces/LocalDropdown.tsx
@@ -18,6 +18,7 @@ import {
import { ButtonTooltip } from '../ui/ButtonTooltip'
import { useFeaturePreviewModal } from './App/FeaturePreview/FeaturePreviewContext'
+import { DevToolbarMenuGroup } from './DevToolbarMenuGroup'
import { ProfileImage } from '@/components/ui/ProfileImage'
import { useTrack } from '@/lib/telemetry/track'
import { useAppStateSnapshot } from '@/state/app-state'
@@ -74,6 +75,7 @@ export const LocalDropdown = ({
Feature previews
+
Theme
-
>
)}
+ {shouldShowSectionSeparator && }
+
+
+
Theme
{
})
})
+ describe('enableToolbar', () => {
+ beforeEach(() => {
+ process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
+ })
+
+ it('enables toolbar and exposes isAvailable in local development', async () => {
+ vi.resetModules()
+ const { DevToolbarProvider, useDevToolbar } = await import('./DevToolbarContext')
+ const { DevToolbarTrigger } = await import('./DevToolbarTrigger')
+ const { TooltipProvider } = await import('ui')
+
+ function ToolbarLauncher() {
+ const { isAvailable, enableToolbar } = useDevToolbar()
+ return (
+
+ )
+ }
+
+ render(
+
+
+
+
+
+
+ )
+
+ expect(screen.getByRole('button', { name: 'Launch toolbar' })).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Open dev toolbar' })).not.toBeInTheDocument()
+
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Launch toolbar' }))
+
+ expect(localStorage.getItem('dev-telemetry-toolbar-enabled')).toBe('true')
+ expect(screen.getByRole('button', { name: 'Open dev toolbar' })).toBeInTheDocument()
+ })
+
+ it('returns isAvailable false in production', async () => {
+ process.env.NEXT_PUBLIC_ENVIRONMENT = 'prod'
+
+ vi.resetModules()
+ const { DevToolbarProvider, useDevToolbar } = await import('./DevToolbarContext')
+
+ function ToolbarAvailability() {
+ const { isAvailable } = useDevToolbar()
+ return {isAvailable ? 'available' : 'unavailable'}
+ }
+
+ render(
+
+
+
+ )
+
+ expect(screen.getByText('unavailable')).toBeInTheDocument()
+ })
+ })
+
+ describe('dismissToolbar', () => {
+ beforeEach(() => {
+ process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
+ })
+
+ async function renderDismissHarness() {
+ const { DevToolbarProvider, useDevToolbar } = await import('./DevToolbarContext')
+ const { DevToolbarTrigger } = await import('./DevToolbarTrigger')
+ const { TooltipProvider } = await import('ui')
+
+ function ToolbarDismisser() {
+ const { dismissToolbar } = useDevToolbar()
+ return (
+
+ )
+ }
+
+ return render(
+
+
+
+
+
+
+ )
+ }
+
+ it('persists the opt-out so it survives a remount', async () => {
+ localStorage.setItem('dev-telemetry-toolbar-enabled', 'true')
+
+ vi.resetModules()
+ const { unmount } = await renderDismissHarness()
+
+ expect(screen.getByRole('button', { name: 'Open dev toolbar' })).toBeInTheDocument()
+
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Dismiss toolbar' }))
+
+ expect(localStorage.getItem('dev-telemetry-toolbar-enabled')).toBe('false')
+ expect(screen.queryByRole('button', { name: 'Open dev toolbar' })).not.toBeInTheDocument()
+
+ unmount()
+ await renderDismissHarness()
+
+ expect(screen.queryByRole('button', { name: 'Open dev toolbar' })).not.toBeInTheDocument()
+ })
+
+ it('takes precedence over the devToolbarDefaultOn flag', async () => {
+ flags.devToolbarDefaultOn = true
+
+ vi.resetModules()
+ const { unmount } = await renderDismissHarness()
+
+ expect(screen.getByRole('button', { name: 'Open dev toolbar' })).toBeInTheDocument()
+
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Dismiss toolbar' }))
+
+ expect(screen.queryByRole('button', { name: 'Open dev toolbar' })).not.toBeInTheDocument()
+
+ unmount()
+ await renderDismissHarness()
+
+ expect(screen.queryByRole('button', { name: 'Open dev toolbar' })).not.toBeInTheDocument()
+ })
+ })
+
describe('window.devToolbar function', () => {
beforeEach(() => {
process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
@@ -461,4 +587,38 @@ describe('DevToolbar utils', () => {
expect(valuesAreEqual('value', null)).toBe(false)
})
})
+
+ describe('getEventCountBadge', () => {
+ it('returns null for zero or negative counts', async () => {
+ vi.resetModules()
+ const { getEventCountBadge } = await import('./utils')
+
+ expect(getEventCountBadge(0)).toBeNull()
+ expect(getEventCountBadge(-1)).toBeNull()
+ })
+
+ it('returns a compact circle for single-digit counts', async () => {
+ vi.resetModules()
+ const { getEventCountBadge } = await import('./utils')
+
+ expect(getEventCountBadge(7)).toEqual({ label: '7', sizeClass: 'size-3.5' })
+ })
+
+ it('returns a larger circle for double-digit counts', async () => {
+ vi.resetModules()
+ const { getEventCountBadge } = await import('./utils')
+
+ expect(getEventCountBadge(42)).toEqual({ label: '42', sizeClass: 'size-4' })
+ })
+
+ it('returns a capped pill for large counts', async () => {
+ vi.resetModules()
+ const { getEventCountBadge } = await import('./utils')
+
+ expect(getEventCountBadge(150)).toEqual({
+ label: '99+',
+ sizeClass: 'h-3.5 min-w-3.5 px-1',
+ })
+ })
+ })
})
diff --git a/packages/dev-tools/DevToolbar.tsx b/packages/dev-tools/DevToolbar.tsx
index 4ab86866ed0..7f28bfd1abd 100644
--- a/packages/dev-tools/DevToolbar.tsx
+++ b/packages/dev-tools/DevToolbar.tsx
@@ -1,7 +1,7 @@
'use client'
import { useFeatureFlags } from 'common'
-import { Copy, EyeOff, Search, X } from 'lucide-react'
+import { Copy, Search, X } from 'lucide-react'
import Image from 'next/image'
import {
useCallback,
@@ -208,7 +208,7 @@ function FlagRow({
}
export function DevToolbar({ extraTabs = [] }: { extraTabs?: ExtraTab[] }) {
- const { isEnabled, isOpen, setIsOpen, events, setEvents, dismissToolbar } = useDevToolbar()
+ const { isEnabled, isOpen, setIsOpen, events, setEvents } = useDevToolbar()
const [activeTab, setActiveTab] = useState('events')
const [flagsSubTab, setFlagsSubTab] = useState<'posthog' | 'configcat'>('posthog')
const [eventFilter, setEventFilter] = useState('')
@@ -421,17 +421,6 @@ export function DevToolbar({ extraTabs = [] }: { extraTabs?: ExtraTab[] }) {
))}
-
-
- }
- onClick={dismissToolbar}
- className="text-foreground-light hover:text-foreground p-1"
- />
-
- Hide Dev Toolbar
-
diff --git a/packages/dev-tools/DevToolbarContext.tsx b/packages/dev-tools/DevToolbarContext.tsx
index 6ba2c7cf643..2b95a640a2e 100644
--- a/packages/dev-tools/DevToolbarContext.tsx
+++ b/packages/dev-tools/DevToolbarContext.tsx
@@ -53,9 +53,18 @@ export function DevToolbarProvider({ children, apiUrl }: DevToolbarProviderProps
const sseRetryDelayRef = useRef(SSE_INITIAL_RETRY_MS)
const sseRetryTimeoutRef = useRef(null)
+ const enableToolbar = useCallback(() => {
+ try {
+ localStorage.setItem(STORAGE_KEY, 'true')
+ } catch {}
+ setIsEnabled(true)
+ }, [])
+
+ // Persist 'false' rather than clearing the key, so an explicit opt-out survives
+ // a reload and takes precedence over the devToolbarDefaultOn flag.
const dismissToolbar = useCallback(() => {
try {
- localStorage.removeItem(STORAGE_KEY)
+ localStorage.setItem(STORAGE_KEY, 'false')
} catch {}
setIsEnabled(false)
setIsOpen(false)
@@ -68,21 +77,16 @@ export function DevToolbarProvider({ children, apiUrl }: DevToolbarProviderProps
try {
stored = localStorage.getItem(STORAGE_KEY)
} catch {}
- if (stored === 'true' || isDefaultOn) {
+ if (stored === 'true' || (stored !== 'false' && isDefaultOn)) {
setIsEnabled(true)
}
- window.devToolbar = () => {
- try {
- localStorage.setItem(STORAGE_KEY, 'true')
- } catch {}
- setIsEnabled(true)
- }
+ window.devToolbar = enableToolbar
return () => {
delete window.devToolbar
}
- }, [isDefaultOn])
+ }, [enableToolbar, isDefaultOn])
const appendEvent = useCallback((event: DevTelemetryEvent) => {
setEvents((prev) => {
@@ -189,9 +193,11 @@ export function DevToolbarProvider({ children, apiUrl }: DevToolbarProviderProps
return (
{},
+ enableToolbar: () => {},
events: [],
setEvents: () => {},
dismissToolbar: () => {},
diff --git a/packages/dev-tools/DevToolbarTrigger.tsx b/packages/dev-tools/DevToolbarTrigger.tsx
index 5942fa592ac..b3bad0adeba 100644
--- a/packages/dev-tools/DevToolbarTrigger.tsx
+++ b/packages/dev-tools/DevToolbarTrigger.tsx
@@ -6,6 +6,7 @@ import type { CSSProperties, PointerEvent } from 'react'
import { Button, cn } from 'ui'
import { useDevToolbar } from './DevToolbarContext'
+import { getEventCountBadge } from './utils'
// Duplicated for tree-shaking — bundler must see literal process.env reference.
// Keep in sync: index.ts, DevToolbarContext.tsx, DevToolbar.tsx, feature-flags.tsx
@@ -163,7 +164,7 @@ export function DevToolbarTrigger() {
if (!IS_TOOLBAR_ENABLED || !isEnabled) return null
- const eventCount = events.length
+ const eventCountBadge = getEventCountBadge(events.length)
const isDragging = dragPos !== null
const snapCoords = getSnapCoords(snapPosition, viewport.w, viewport.h)
const FULL_TRANSITION = `${SNAP_TRANSITION}, opacity 200ms ease`
@@ -210,11 +211,10 @@ export function DevToolbarTrigger() {
return (
- {eventCount > 0 && (
+ {eventCountBadge && (
- {eventCount > 99 ? '99+' : eventCount}
+ {eventCountBadge.label}
)}
diff --git a/packages/dev-tools/index.ts b/packages/dev-tools/index.ts
index e084b3902e3..91deb39cc3b 100644
--- a/packages/dev-tools/index.ts
+++ b/packages/dev-tools/index.ts
@@ -15,9 +15,11 @@ const env = process.env.NEXT_PUBLIC_ENVIRONMENT
const isToolbarEnabled = env === 'local' || env === 'staging'
const noopContext: DevTelemetryToolbarContextType = {
+ isAvailable: false,
isEnabled: false,
isOpen: false,
setIsOpen: () => {},
+ enableToolbar: () => {},
events: [],
setEvents: () => {},
dismissToolbar: () => {},
diff --git a/packages/dev-tools/types.ts b/packages/dev-tools/types.ts
index 87afb1a0abe..bfbc3b486fe 100644
--- a/packages/dev-tools/types.ts
+++ b/packages/dev-tools/types.ts
@@ -32,9 +32,11 @@ export interface ExtraTab {
}
export interface DevTelemetryToolbarContextType {
+ isAvailable: boolean
isEnabled: boolean
isOpen: boolean
setIsOpen: (open: boolean) => void
+ enableToolbar: () => void
events: DevTelemetryEvent[]
setEvents: Dispatch>
dismissToolbar: () => void
diff --git a/packages/dev-tools/utils.ts b/packages/dev-tools/utils.ts
index c8101330f47..960da738e3c 100644
--- a/packages/dev-tools/utils.ts
+++ b/packages/dev-tools/utils.ts
@@ -88,3 +88,17 @@ export function parseOverrideValue(value: unknown, original: unknown): unknown {
}
return value
}
+
+export function getEventCountBadge(count: number): { label: string; sizeClass: string } | null {
+ if (count <= 0) return null
+
+ if (count > 99) {
+ return { label: '99+', sizeClass: 'h-4 min-w-4 px-1' }
+ }
+
+ if (count < 10) {
+ return { label: String(count), sizeClass: 'size-3.5' }
+ }
+
+ return { label: String(count), sizeClass: 'size-4' }
+}