feat(studio): add dev toolbar launcher to account settings menu (#49285)

## 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 |
| --- |
| <img width="226" height="204" alt="CleanShot 2026-08-20 at 12 46
38@2x"
src="https://github.com/user-attachments/assets/c846b119-626d-48f5-9a02-aef4d006326c"
/> |
| <img width="558" height="1024" alt="CleanShot 2026-08-20 at 12 47
04@2x"
src="https://github.com/user-attachments/assets/4b3dd22b-537b-4874-821d-c202033c4ad7"
/> |

## 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.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Danny White <dnywh@users.noreply.github.com>
Co-authored-by: Sean Oliver <882952+seanoliver@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-08-21 10:53:50 +10:00
1 parent 10c425ad0b
commit fb4c3ec6d4
11 files changed
+357 -45

No files matched your search

@@ -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 (
<DropdownMenuGroup>
<DropdownMenuLabel>Local tools</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={isEnabled}
onCheckedChange={handleToggleDevToolbar}
className="cursor-pointer"
>
Dev toolbar
</DropdownMenuCheckboxItem>
</DropdownMenuGroup>
)
}
@@ -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 () => {
</button>
),
DropdownMenuLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuCheckboxItem: ({
children,
checked,
onCheckedChange,
}: {
children: ReactNode
checked?: boolean
onCheckedChange?: (checked: boolean) => void
}) => (
<button tabIndex={0} aria-checked={checked} onClick={() => onCheckedChange?.(!checked)}>
{children}
</button>
),
DropdownMenuSeparator: () => <hr />,
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(<LocalDropdown />)
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(<LocalDropdown />)
await user.click(screen.getByRole('button', { name: 'Dev toolbar' }))
expect(mockDismissDevToolbar).toHaveBeenCalled()
expect(mockEnableToolbar).not.toHaveBeenCalled()
})
})
@@ -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
</DropdownMenuItem>
<DropdownMenuSeparator />
<DevToolbarMenuGroup />
<DropdownMenuGroup>
<DropdownMenuLabel>Theme</DropdownMenuLabel>
<DropdownMenuRadioGroup
@@ -1,3 +1,4 @@
import { useDevToolbar } from 'dev-tools'
import { FlaskConical, Loader2, ScrollText, User2 } from 'lucide-react'
import { useTheme } from 'next-themes'
import Link from 'next/link'
@@ -19,6 +20,7 @@ import {
import { ButtonTooltip } from '../ui/ButtonTooltip'
import { useFeaturePreviewModal } from './App/FeaturePreview/FeaturePreviewContext'
import { DevToolbarMenuGroup } from './DevToolbarMenuGroup'
import { TimezoneDropdown } from './UserDropdown/TimezoneDropdown'
import { ProfileImage } from '@/components/ui/ProfileImage'
import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton'
@@ -44,6 +46,8 @@ export function UserDropdown({
const { toggleFeaturePreviewModal } = useFeaturePreviewModal()
const track = useTrack()
const { isAvailable: isDevToolbarAvailable } = useDevToolbar()
const shouldShowSectionSeparator = IS_PLATFORM || isDevToolbarAvailable
// The upgrade CTA is org-scoped, so only enable it on routes where an org is in scope.
// Excludes /account/*, /organizations, /new, marketing routes, etc. Gating the hook here
@@ -136,11 +140,14 @@ export function UserDropdown({
Changelog
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
</DropdownMenuGroup>
</>
)}
{shouldShowSectionSeparator && <DropdownMenuSeparator />}
<DevToolbarMenuGroup />
<DropdownMenuGroup>
<DropdownMenuLabel>Theme</DropdownMenuLabel>
<DropdownMenuRadioGroup
+160
View File
@@ -256,6 +256,132 @@ describe('DevToolbar', () => {
})
})
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 (
<button type="button" onClick={enableToolbar}>
{isAvailable ? 'Launch toolbar' : 'Unavailable'}
</button>
)
}
render(
<TooltipProvider>
<DevToolbarProvider apiUrl="http://localhost:3000">
<ToolbarLauncher />
<DevToolbarTrigger />
</DevToolbarProvider>
</TooltipProvider>
)
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 <span>{isAvailable ? 'available' : 'unavailable'}</span>
}
render(
<DevToolbarProvider apiUrl="http://localhost:3000">
<ToolbarAvailability />
</DevToolbarProvider>
)
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 (
<button type="button" onClick={dismissToolbar}>
Dismiss toolbar
</button>
)
}
return render(
<TooltipProvider>
<DevToolbarProvider apiUrl="http://localhost:3000">
<ToolbarDismisser />
<DevToolbarTrigger />
</DevToolbarProvider>
</TooltipProvider>
)
}
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',
})
})
})
})
+2 -13
View File
@@ -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<string>('events')
const [flagsSubTab, setFlagsSubTab] = useState<'posthog' | 'configcat'>('posthog')
const [eventFilter, setEventFilter] = useState<string>('')
@@ -421,17 +421,6 @@ export function DevToolbar({ extraTabs = [] }: { extraTabs?: ExtraTab[] }) {
))}
</TabsList>
<div className="ml-auto flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
icon={<EyeOff className="w-4 h-4" />}
onClick={dismissToolbar}
className="text-foreground-light hover:text-foreground p-1"
/>
</TooltipTrigger>
<TooltipContent side="top">Hide Dev Toolbar</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<SheetClose asChild>
+17 -9
View File
@@ -53,9 +53,18 @@ export function DevToolbarProvider({ children, apiUrl }: DevToolbarProviderProps
const sseRetryDelayRef = useRef(SSE_INITIAL_RETRY_MS)
const sseRetryTimeoutRef = useRef<NodeJS.Timeout | null>(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 (
<DevToolbarContext.Provider
value={{
isAvailable: IS_TOOLBAR_ENABLED,
isEnabled,
isOpen,
setIsOpen,
enableToolbar,
events,
setEvents,
dismissToolbar,
@@ -206,9 +212,11 @@ export function useDevToolbar() {
const context = useContext(DevToolbarContext)
if (!context) {
return {
isAvailable: false,
isEnabled: false,
isOpen: false,
setIsOpen: () => {},
enableToolbar: () => {},
events: [],
setEvents: () => {},
dismissToolbar: () => {},
+10 -10
View File
@@ -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 (
<div style={containerStyle}>
<Button
variant="text"
variant="default"
rounded
className={cn(
'relative rounded-full h-10 w-10 p-0',
'bg-surface-100 border border-overlay shadow-md',
'text-foreground-light hover:text-foreground hover:bg-surface-200',
'relative h-10 w-10 p-0 shadow-md',
'focus-visible:outline-0 focus-visible:outline-transparent focus-visible:outline-offset-0',
'select-none touch-none',
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
@@ -239,17 +239,17 @@ export function DevToolbarTrigger() {
aria-hidden="true"
className="pointer-events-none"
/>
{eventCount > 0 && (
{eventCountBadge && (
<span
className={cn(
'absolute -top-1 -right-1',
'h-4 min-w-4 px-0.5',
'inline-flex items-center justify-center',
'rounded-full bg-destructive text-foreground',
'text-[10px] font-medium leading-none'
'rounded-full bg-brand text-black',
'text-[9px] font-medium leading-none tracking-tighter tabular-nums',
eventCountBadge.sizeClass
)}
>
{eventCount > 99 ? '99+' : eventCount}
{eventCountBadge.label}
</span>
)}
</Button>
+2
View File
@@ -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: () => {},
+2
View File
@@ -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<SetStateAction<DevTelemetryEvent[]>>
dismissToolbar: () => void
+14
View File
@@ -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' }
}