From fb4c3ec6d4b4f6eea1c3bcccadad6f6e5772675a Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:53:50 +1000 Subject: [PATCH] feat(studio): add dev toolbar launcher to account settings menu (#49285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Feature ## What is the current behavior? The dev toolbar is only discoverable via `window.devToolbar()` in the browser console, or by having your email on the `devToolbarDefaultOn` ConfigCat flag. Once enabled, Studio shows a floating trigger button. ## What is the new behavior? In local and staging Studio, the account/settings dropdown (avatar menu) includes a **Local tools** section above **Theme** with a **Dev toolbar** checkbox toggle. - **On**: shows the floating orb (persists via localStorage, same as `window.devToolbar()`) - **Off**: hides the orb and dismisses the toolbar Open the panel itself via the orb once it is visible. Production builds are unchanged (`isAvailable` is false and the menu item is hidden). | After | | --- | | CleanShot 2026-08-20 at 12 46
38@2x | | CleanShot 2026-08-20 at 12 47
04@2x | ## Manual testing Run `pnpm dev:studio` and open http://localhost:8082. 1. **Find the entry point:** top-right avatar/settings menu → **Local tools** → **Dev toolbar** (above **Theme**). Should not appear in production builds. 2. **Turn it on:** check **Dev toolbar**. A green floating orb should appear (default bottom-right). 3. **Open the panel:** click the orb. The **Dev Toolbar** sheet should open with Events and Flags tabs. 4. **Event count:** navigate around Studio (e.g. open a project, switch pages). The orb badge should increment and stay readable in light and dark mode. 5. **Turn it off:** reopen the avatar menu and uncheck **Dev toolbar**. The orb and panel should disappear. 6. **Close vs hide:** with the toolbar on, open the sheet and use **Close** (X). The orb should remain; only the sheet closes. Optional: confirm `window.devToolbar()` in the browser console still enables the orb. ## Summary by CodeRabbit * **New Features** * Added a Local tools option to enable the development toolbar when available. * Toolbar activation and dismissal preferences now persist between sessions. * Added clearer event-count badges with responsive sizing for larger counts. * **Improvements** * Simplified toolbar controls by removing the separate hide option. * Improved toolbar availability handling across local and production environments. * **Tests** * Expanded coverage for activation, persistence, visibility, and event-count badges. --------- Co-authored-by: Cursor Agent Co-authored-by: Danny White Co-authored-by: Sean Oliver <882952+seanoliver@users.noreply.github.com> --- .../interfaces/DevToolbarMenuGroup.tsx | 30 ++++ .../interfaces/LocalDropdown.test.tsx | 122 +++++++++++-- .../components/interfaces/LocalDropdown.tsx | 2 + .../components/interfaces/UserDropdown.tsx | 9 +- packages/dev-tools/DevToolbar.test.tsx | 160 ++++++++++++++++++ packages/dev-tools/DevToolbar.tsx | 15 +- packages/dev-tools/DevToolbarContext.tsx | 26 ++- packages/dev-tools/DevToolbarTrigger.tsx | 20 +-- packages/dev-tools/index.ts | 2 + packages/dev-tools/types.ts | 2 + packages/dev-tools/utils.ts | 14 ++ 11 files changed, 357 insertions(+), 45 deletions(-) create mode 100644 apps/studio/components/interfaces/DevToolbarMenuGroup.tsx 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[] }) { ))}
- - - 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' } +}