Swap edge-peek coachmark toast for a brief tooltip chip

Show a quiet right-edge tooltip for one second after the first sidebar
close instead of a heavy sonner toast, and keep the one-shot dismiss
behavior.
This commit is contained in:
Brennan Benson
2026-07-16 17:16:12 -07:00
parent 1c662a60cb
commit fe26a9654c
4 changed files with 176 additions and 85 deletions
@@ -0,0 +1,82 @@
// @vitest-environment happy-dom
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { RightSidebarEdgePeekTipHost } from './right-sidebar-edge-peek-tip-host'
import { RIGHT_SIDEBAR_EDGE_PEEK_TIP_VISIBLE_MS } from './right-sidebar-edge-peek-tip'
const initialAppState = useAppStore.getInitialState()
beforeEach(() => {
vi.useFakeTimers()
cleanup()
useAppStore.setState({
...initialAppState,
rightSidebarOpen: true,
rightSidebarPeek: false,
settings: {
rightSidebarEdgePeekEnabled: true,
rightSidebarEdgePeekTipDismissed: false
} as never,
updateSettings: vi.fn().mockResolvedValue(undefined)
})
})
afterEach(() => {
cleanup()
vi.useRealTimers()
useAppStore.setState(initialAppState, true)
})
describe('RightSidebarEdgePeekTipHost', () => {
it('shows a brief edge tip on the first open→closed transition', () => {
const { rerender } = render(<RightSidebarEdgePeekTipHost />)
expect(screen.queryByTestId('right-sidebar-edge-peek-tip')).toBeNull()
act(() => {
useAppStore.setState({ rightSidebarOpen: false })
})
rerender(<RightSidebarEdgePeekTipHost />)
expect(screen.getByTestId('right-sidebar-edge-peek-tip').textContent).toContain(
'Hover the right edge to peek'
)
})
it('auto-hides after the visible duration and marks the tip dismissed', () => {
const updateSettings = vi.fn().mockResolvedValue(undefined)
useAppStore.setState({ updateSettings })
const { rerender } = render(<RightSidebarEdgePeekTipHost />)
act(() => {
useAppStore.setState({ rightSidebarOpen: false })
})
rerender(<RightSidebarEdgePeekTipHost />)
expect(screen.getByTestId('right-sidebar-edge-peek-tip')).toBeTruthy()
act(() => {
vi.advanceTimersByTime(RIGHT_SIDEBAR_EDGE_PEEK_TIP_VISIBLE_MS)
})
expect(screen.queryByTestId('right-sidebar-edge-peek-tip')).toBeNull()
expect(updateSettings).toHaveBeenCalledWith({ rightSidebarEdgePeekTipDismissed: true })
})
it('does not show when the tip was already dismissed', () => {
useAppStore.setState({
settings: {
rightSidebarEdgePeekEnabled: true,
rightSidebarEdgePeekTipDismissed: true
} as never
})
const { rerender } = render(<RightSidebarEdgePeekTipHost />)
act(() => {
useAppStore.setState({ rightSidebarOpen: false })
})
rerender(<RightSidebarEdgePeekTipHost />)
expect(screen.queryByTestId('right-sidebar-edge-peek-tip')).toBeNull()
})
})
@@ -1,25 +1,41 @@
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState, type JSX } from 'react'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import {
dismissRightSidebarEdgePeekTipToast,
shouldShowRightSidebarEdgePeekTip,
showRightSidebarEdgePeekTip
RIGHT_SIDEBAR_EDGE_PEEK_TIP_VISIBLE_MS,
markRightSidebarEdgePeekTipDismissed,
shouldShowRightSidebarEdgePeekTip
} from './right-sidebar-edge-peek-tip'
import { isRightSidebarEdgePeekEnabled } from './right-sidebar-edge-peek-preference'
/**
* One-shot coachmark when the user first closes the right sidebar while edge
* peek is enabled. Successful peeks also dismiss the tip (they already found
* the gesture). Renders nothing.
* One-shot tip: a quiet tooltip-style chip on the right edge after the first
* close while peek is enabled. Auto-hides after ~1s. Successful peeks also mark
* the tip seen.
*/
export function RightSidebarEdgePeekTipHost(): null {
export function RightSidebarEdgePeekTipHost(): JSX.Element | null {
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
const rightSidebarPeek = useAppStore((s) => s.rightSidebarPeek)
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
const [visible, setVisible] = useState(false)
// Why: only fire on a true open→closed transition, not on first mount when
// the sidebar happens to start closed (no prior close gesture to teach from).
const prevOpenRef = useRef(rightSidebarOpen)
const hideTimerRef = useRef<number | null>(null)
const updateSettingsRef = useRef(updateSettings)
updateSettingsRef.current = updateSettings
const clearHideTimer = (): void => {
if (hideTimerRef.current !== null) {
window.clearTimeout(hideTimerRef.current)
hideTimerRef.current = null
}
}
useEffect(() => {
return () => clearHideTimer()
}, [])
useEffect(() => {
const wasOpen = prevOpenRef.current
@@ -30,27 +46,63 @@ export function RightSidebarEdgePeekTipHost(): null {
if (!shouldShowRightSidebarEdgePeekTip(settings)) {
return
}
showRightSidebarEdgePeekTip({ updateSettings })
}, [rightSidebarOpen, settings, updateSettings])
setVisible(true)
clearHideTimer()
// Why: brief, non-blocking discoverability — a full toast is too heavy for
// a one-shot edge gesture hint.
hideTimerRef.current = window.setTimeout(() => {
hideTimerRef.current = null
setVisible(false)
markRightSidebarEdgePeekTipDismissed({ updateSettings: updateSettingsRef.current })
}, RIGHT_SIDEBAR_EDGE_PEEK_TIP_VISIBLE_MS)
}, [rightSidebarOpen, settings])
useEffect(() => {
// Why: if they discover the edge themselves, mark the tip seen so we never
// nag about a gesture they already use.
if (!rightSidebarPeek || !isRightSidebarEdgePeekEnabled(settings)) {
// Why: if they discover the edge themselves, drop the tip so we never nag.
if (!rightSidebarPeek) {
return
}
if (settings?.rightSidebarEdgePeekTipDismissed === true) {
return
}
dismissRightSidebarEdgePeekTipToast()
void Promise.resolve(updateSettings({ rightSidebarEdgePeekTipDismissed: true })).catch(() => {})
}, [rightSidebarPeek, settings, updateSettings])
useEffect(() => {
if (!isRightSidebarEdgePeekEnabled(settings)) {
dismissRightSidebarEdgePeekTipToast()
return
}
}, [settings])
clearHideTimer()
setVisible(false)
if (settings?.rightSidebarEdgePeekTipDismissed !== true) {
markRightSidebarEdgePeekTipDismissed({ updateSettings: updateSettingsRef.current })
}
}, [rightSidebarPeek, settings])
return null
useEffect(() => {
if (!isRightSidebarEdgePeekEnabled(settings) && visible) {
clearHideTimer()
setVisible(false)
}
}, [settings, visible])
if (!visible) {
return null
}
return (
<div
role="status"
aria-live="polite"
data-testid="right-sidebar-edge-peek-tip"
// Why: fixed to the right edge mid-window so the hint points at the
// gesture target (not the titlebar toggle the user just used).
className="pointer-events-none fixed top-1/2 right-3 z-[90] -translate-y-1/2 animate-in fade-in-0 zoom-in-95 slide-in-from-right-2 duration-150 motion-reduce:animate-none"
>
<div className="relative rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background shadow-xs">
{translate(
'auto.components.right.sidebar.edge.peek.tip.title',
'Hover the right edge to peek'
)}
{/* Tooltip-style caret pointing at the window edge. */}
<span
aria-hidden
className="absolute top-1/2 -right-1 size-2 -translate-y-1/2 rotate-45 rounded-[2px] bg-foreground"
/>
</div>
</div>
)
}
@@ -1,18 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import { describe, expect, it, vi } from 'vitest'
import {
RIGHT_SIDEBAR_EDGE_PEEK_TIP_TOAST_ID,
shouldShowRightSidebarEdgePeekTip,
showRightSidebarEdgePeekTip
RIGHT_SIDEBAR_EDGE_PEEK_TIP_VISIBLE_MS,
markRightSidebarEdgePeekTipDismissed,
shouldShowRightSidebarEdgePeekTip
} from './right-sidebar-edge-peek-tip'
vi.mock('sonner', () => ({
toast: {
info: vi.fn(),
dismiss: vi.fn()
}
}))
describe('shouldShowRightSidebarEdgePeekTip', () => {
it('shows only when edge peek is enabled and the tip is not dismissed', () => {
expect(shouldShowRightSidebarEdgePeekTip(null)).toBe(true)
@@ -35,27 +27,16 @@ describe('shouldShowRightSidebarEdgePeekTip', () => {
})
})
describe('showRightSidebarEdgePeekTip', () => {
beforeEach(() => {
vi.mocked(toast.info).mockReset()
})
it('shows a dismissible info toast and persists dismiss on close', () => {
describe('markRightSidebarEdgePeekTipDismissed', () => {
it('persists the one-shot dismiss flag', () => {
const updateSettings = vi.fn().mockResolvedValue(undefined)
showRightSidebarEdgePeekTip({ updateSettings })
expect(toast.info).toHaveBeenCalledTimes(1)
const [, options] = vi.mocked(toast.info).mock.calls[0] as [
string,
{
id: string
onDismiss?: () => void
onAutoClose?: () => void
}
]
expect(options.id).toBe(RIGHT_SIDEBAR_EDGE_PEEK_TIP_TOAST_ID)
options.onDismiss?.()
markRightSidebarEdgePeekTipDismissed({ updateSettings })
expect(updateSettings).toHaveBeenCalledWith({ rightSidebarEdgePeekTipDismissed: true })
})
})
describe('RIGHT_SIDEBAR_EDGE_PEEK_TIP_VISIBLE_MS', () => {
it('stays brief (tooltip-length, not toast-length)', () => {
expect(RIGHT_SIDEBAR_EDGE_PEEK_TIP_VISIBLE_MS).toBe(1000)
})
})
@@ -1,12 +1,11 @@
import { toast } from 'sonner'
import type { GlobalSettings } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
import {
isRightSidebarEdgePeekEnabled,
isRightSidebarEdgePeekTipDismissed
} from './right-sidebar-edge-peek-preference'
export const RIGHT_SIDEBAR_EDGE_PEEK_TIP_TOAST_ID = 'right-sidebar-edge-peek-tip'
/** How long the one-shot edge-peek hint stays visible. */
export const RIGHT_SIDEBAR_EDGE_PEEK_TIP_VISIBLE_MS = 1000
type TipSettings = Pick<
GlobalSettings,
@@ -17,33 +16,10 @@ export function shouldShowRightSidebarEdgePeekTip(settings: TipSettings): boolea
return isRightSidebarEdgePeekEnabled(settings) && !isRightSidebarEdgePeekTipDismissed(settings)
}
export function showRightSidebarEdgePeekTip(args: {
export function markRightSidebarEdgePeekTipDismissed(args: {
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void> | void
}): void {
const dismissTip = (): void => {
void Promise.resolve(args.updateSettings({ rightSidebarEdgePeekTipDismissed: true })).catch(
() => {}
)
}
// Why: one-shot discoverability for an invisible edge gesture; long enough
// to read but not sticky like opt-in setting suggestions.
toast.info(
translate('auto.components.right.sidebar.edge.peek.tip.title', 'Hover the right edge to peek'),
{
id: RIGHT_SIDEBAR_EDGE_PEEK_TIP_TOAST_ID,
description: translate(
'auto.components.right.sidebar.edge.peek.tip.description',
'The right sidebar slides over the editor without reflowing it. Click the toggle or use the shortcut to pin it open. Change this anytime in Settings › Appearance.'
),
duration: 10_000,
dismissible: true,
onDismiss: dismissTip,
onAutoClose: dismissTip
}
void Promise.resolve(args.updateSettings({ rightSidebarEdgePeekTipDismissed: true })).catch(
() => {}
)
}
export function dismissRightSidebarEdgePeekTipToast(): void {
toast.dismiss(RIGHT_SIDEBAR_EDGE_PEEK_TIP_TOAST_ID)
}