Improve right-sidebar edge peek discoverability and add a setting

Teach the edge-hover gesture via the existing toggle tooltip and a
one-shot first-close coachmark, and make Arc-style edge peek opt-out
in Appearance settings (default on).
This commit is contained in:
Brennan Benson
2026-07-16 15:00:28 -07:00
parent ea9ffbaea5
commit 724247475b
13 changed files with 373 additions and 18 deletions
+17 -4
View File
@@ -56,6 +56,8 @@ import {
} from './components/terminal-pane/use-system-prefers-dark'
import RightSidebar from './components/right-sidebar'
import { RightSidebarEdgePeekZone } from './components/right-sidebar/right-sidebar-edge-peek'
import { RightSidebarEdgePeekTipHost } from './components/right-sidebar/right-sidebar-edge-peek-tip-host'
import { isRightSidebarEdgePeekEnabled } from './components/right-sidebar/right-sidebar-edge-peek-preference'
import { StarNagCard } from './components/StarNagCard'
import { StarNagAgentValueMomentObserver } from './components/star-nag/StarNagAgentValueMomentObserver'
import { StarNagToastHost } from './components/star-nag/StarNagToastHost'
@@ -2098,6 +2100,7 @@ function App(): React.JSX.Element {
</div>
)
const rightSidebarEdgePeekEnabled = isRightSidebarEdgePeekEnabled(settings)
const rightSidebarToggle = showRightSidebarControls ? (
<Tooltip>
<TooltipTrigger asChild>
@@ -2109,10 +2112,19 @@ function App(): React.JSX.Element {
<PanelRight size={16} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.App.c184e056de', 'Toggle right sidebar ({{value0}})', {
value0: rightSidebarShortcutLabel
})}
<TooltipContent side="bottom" sideOffset={6} className="max-w-[220px]">
<div className="flex flex-col gap-0.5">
<span>
{translate('auto.App.c184e056de', 'Toggle right sidebar ({{value0}})', {
value0: rightSidebarShortcutLabel
})}
</span>
{rightSidebarEdgePeekEnabled ? (
<span className="opacity-80">
{translate('auto.App.rightSidebarEdgePeekHint', 'Hover the right edge to peek')}
</span>
) : null}
</div>
</TooltipContent>
</Tooltip>
) : null
@@ -2433,6 +2445,7 @@ function App(): React.JSX.Element {
responsive. Unmount on the tasks view since that surface is
intentionally distraction-free. */}
{showRightSidebarControls ? <RightSidebarEdgePeekZone /> : null}
{showRightSidebarControls ? <RightSidebarEdgePeekTipHost /> : null}
{showRightSidebarControls ? (
<RecoverableRenderErrorBoundary
boundaryId="right-sidebar"
@@ -40,6 +40,7 @@ import {
import { translate } from '@/i18n/i18n'
import { RightSidebarPanelContent } from './right-sidebar-panel-content'
import { useRightSidebarEdgePeekDismiss } from './right-sidebar-edge-peek'
import { isRightSidebarEdgePeekEnabled } from './right-sidebar-edge-peek-preference'
import { useMeasuredWidth } from './right-sidebar-measured-width'
import { normalizeRightSidebarRoute } from '@/store/right-sidebar-route'
import { AgentSessionHistoryIcon } from './agent-session-history-icon'
@@ -65,6 +66,7 @@ function RightSidebarInner(): React.JSX.Element {
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
const rightSidebarPeek = useAppStore((s) => s.rightSidebarPeek)
const setRightSidebarPeek = useAppStore((s) => s.setRightSidebarPeek)
const rightSidebarEdgePeekEnabled = useAppStore((s) => isRightSidebarEdgePeekEnabled(s.settings))
const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth)
const setRightSidebarWidth = useAppStore((s) => s.setRightSidebarWidth)
const rightSidebarTab = useAppStore((s) => s.rightSidebarTab)
@@ -280,12 +282,24 @@ function RightSidebarInner(): React.JSX.Element {
<PanelRight size={16} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate(
'auto.components.right.sidebar.index.9fffaf17c1',
'Toggle right sidebar ({{value0}})',
{ value0: rightSidebarShortcut }
)}
<TooltipContent side="bottom" sideOffset={6} className="max-w-[220px]">
<div className="flex flex-col gap-0.5">
<span>
{translate(
'auto.components.right.sidebar.index.9fffaf17c1',
'Toggle right sidebar ({{value0}})',
{ value0: rightSidebarShortcut }
)}
</span>
{rightSidebarEdgePeekEnabled ? (
<span className="opacity-80">
{translate(
'auto.components.right.sidebar.index.edgePeekHint',
'Hover the right edge to peek'
)}
</span>
) : null}
</div>
</TooltipContent>
</Tooltip>
) : null
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import {
isRightSidebarEdgePeekEnabled,
isRightSidebarEdgePeekTipDismissed
} from './right-sidebar-edge-peek-preference'
describe('isRightSidebarEdgePeekEnabled', () => {
it('defaults on when settings are missing or the flag is absent', () => {
expect(isRightSidebarEdgePeekEnabled(null)).toBe(true)
expect(isRightSidebarEdgePeekEnabled(undefined)).toBe(true)
expect(isRightSidebarEdgePeekEnabled({})).toBe(true)
})
it('respects an explicit opt-out', () => {
expect(isRightSidebarEdgePeekEnabled({ rightSidebarEdgePeekEnabled: false })).toBe(false)
expect(isRightSidebarEdgePeekEnabled({ rightSidebarEdgePeekEnabled: true })).toBe(true)
})
})
describe('isRightSidebarEdgePeekTipDismissed', () => {
it('defaults to not dismissed', () => {
expect(isRightSidebarEdgePeekTipDismissed(null)).toBe(false)
expect(isRightSidebarEdgePeekTipDismissed({})).toBe(false)
})
it('returns true only for an explicit dismiss', () => {
expect(isRightSidebarEdgePeekTipDismissed({ rightSidebarEdgePeekTipDismissed: true })).toBe(
true
)
expect(isRightSidebarEdgePeekTipDismissed({ rightSidebarEdgePeekTipDismissed: false })).toBe(
false
)
})
})
@@ -0,0 +1,14 @@
import type { GlobalSettings } from '../../../../shared/types'
/** Absent means on: edge peek is default-enabled for legacy profiles. */
export function isRightSidebarEdgePeekEnabled(
settings: Pick<GlobalSettings, 'rightSidebarEdgePeekEnabled'> | null | undefined
): boolean {
return settings?.rightSidebarEdgePeekEnabled !== false
}
export function isRightSidebarEdgePeekTipDismissed(
settings: Pick<GlobalSettings, 'rightSidebarEdgePeekTipDismissed'> | null | undefined
): boolean {
return settings?.rightSidebarEdgePeekTipDismissed === true
}
@@ -0,0 +1,56 @@
import { useEffect, useRef } from 'react'
import { useAppStore } from '@/store'
import {
dismissRightSidebarEdgePeekTipToast,
shouldShowRightSidebarEdgePeekTip,
showRightSidebarEdgePeekTip
} 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.
*/
export function RightSidebarEdgePeekTipHost(): 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)
// 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)
useEffect(() => {
const wasOpen = prevOpenRef.current
prevOpenRef.current = rightSidebarOpen
if (!wasOpen || rightSidebarOpen) {
return
}
if (!shouldShowRightSidebarEdgePeekTip(settings)) {
return
}
showRightSidebarEdgePeekTip({ updateSettings })
}, [rightSidebarOpen, settings, updateSettings])
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)) {
return
}
if (settings?.rightSidebarEdgePeekTipDismissed === true) {
return
}
dismissRightSidebarEdgePeekTipToast()
void Promise.resolve(updateSettings({ rightSidebarEdgePeekTipDismissed: true })).catch(() => {})
}, [rightSidebarPeek, settings, updateSettings])
useEffect(() => {
if (!isRightSidebarEdgePeekEnabled(settings)) {
dismissRightSidebarEdgePeekTipToast()
}
}, [settings])
return null
}
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import {
RIGHT_SIDEBAR_EDGE_PEEK_TIP_TOAST_ID,
shouldShowRightSidebarEdgePeekTip,
showRightSidebarEdgePeekTip
} 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)
expect(
shouldShowRightSidebarEdgePeekTip({
rightSidebarEdgePeekEnabled: true,
rightSidebarEdgePeekTipDismissed: false
})
).toBe(true)
expect(
shouldShowRightSidebarEdgePeekTip({
rightSidebarEdgePeekEnabled: false
})
).toBe(false)
expect(
shouldShowRightSidebarEdgePeekTip({
rightSidebarEdgePeekTipDismissed: true
})
).toBe(false)
})
})
describe('showRightSidebarEdgePeekTip', () => {
beforeEach(() => {
vi.mocked(toast.info).mockReset()
})
it('shows a dismissible info toast and persists dismiss on close', () => {
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?.()
expect(updateSettings).toHaveBeenCalledWith({ rightSidebarEdgePeekTipDismissed: true })
})
})
@@ -0,0 +1,49 @@
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'
type TipSettings = Pick<
GlobalSettings,
'rightSidebarEdgePeekEnabled' | 'rightSidebarEdgePeekTipDismissed'
> | null
export function shouldShowRightSidebarEdgePeekTip(settings: TipSettings): boolean {
return isRightSidebarEdgePeekEnabled(settings) && !isRightSidebarEdgePeekTipDismissed(settings)
}
export function showRightSidebarEdgePeekTip(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
}
)
}
export function dismissRightSidebarEdgePeekTipToast(): void {
toast.dismiss(RIGHT_SIDEBAR_EDGE_PEEK_TIP_TOAST_ID)
}
@@ -88,6 +88,33 @@ describe('RightSidebarEdgePeekZone', () => {
expect(useAppStore.getState().rightSidebarPeek).toBe(false)
})
it('does not arm when edge peek is disabled in settings', () => {
useAppStore.setState({
settings: { rightSidebarEdgePeekEnabled: false } as never
})
render(<RightSidebarEdgePeekZone />)
moveMouse(RIGHT_EDGE_X, BELOW_TITLEBAR_Y)
vi.advanceTimersByTime(PEEK_OPEN_DELAY_MS)
expect(useAppStore.getState().rightSidebarPeek).toBe(false)
})
it('clears an active peek when edge peek is turned off', () => {
useAppStore.setState({ rightSidebarPeek: true })
const { rerender } = render(<RightSidebarEdgePeekZone />)
expect(useAppStore.getState().rightSidebarPeek).toBe(true)
act(() => {
useAppStore.setState({
settings: { rightSidebarEdgePeekEnabled: false } as never
})
})
rerender(<RightSidebarEdgePeekZone />)
expect(useAppStore.getState().rightSidebarPeek).toBe(false)
})
it('clears an active peek when the zone unmounts on a view change', () => {
const { unmount } = render(<RightSidebarEdgePeekZone />)
@@ -1,5 +1,6 @@
import React, { useEffect, useRef } from 'react'
import { useAppStore } from '@/store'
import { isRightSidebarEdgePeekEnabled } from './right-sidebar-edge-peek-preference'
// Arc-style edge peek: hovering the window's right edge while the sidebar is
// closed reveals it as a floating overlay instead of reflowing the editor.
@@ -21,6 +22,7 @@ export function RightSidebarEdgePeekZone(): React.JSX.Element | null {
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
const rightSidebarPeek = useAppStore((s) => s.rightSidebarPeek)
const setRightSidebarPeek = useAppStore((s) => s.setRightSidebarPeek)
const edgePeekEnabled = useAppStore((s) => isRightSidebarEdgePeekEnabled(s.settings))
useEffect(() => {
// Why: the zone unmounts when the active view loses sidebar controls; a
@@ -29,11 +31,20 @@ export function RightSidebarEdgePeekZone(): React.JSX.Element | null {
return () => setRightSidebarPeek(false)
}, [setRightSidebarPeek])
useEffect(() => {
// Why: an active peek must collapse immediately when the user turns the
// setting off mid-gesture so the overlay doesn't stick without a dismiss
// path that matches their preference.
if (!edgePeekEnabled && rightSidebarPeek) {
setRightSidebarPeek(false)
}
}, [edgePeekEnabled, rightSidebarPeek, setRightSidebarPeek])
useEffect(() => {
// The revealed overlay covers the edge, so arming is only needed while the
// sidebar is fully hidden. Skipping the listener here also guarantees an
// armed timer can't fire after the sidebar opens (the cleanup clears it).
if (rightSidebarOpen || rightSidebarPeek) {
if (!edgePeekEnabled || rightSidebarOpen || rightSidebarPeek) {
return
}
let openTimer: number | null = null
@@ -65,7 +76,7 @@ export function RightSidebarEdgePeekZone(): React.JSX.Element | null {
clearOpenTimer()
window.removeEventListener('mousemove', onMouseMove)
}
}, [rightSidebarOpen, rightSidebarPeek, setRightSidebarPeek])
}, [edgePeekEnabled, rightSidebarOpen, rightSidebarPeek, setRightSidebarPeek])
return null
}
@@ -16,8 +16,10 @@ import { getLayoutEntries, getSidebarEntries, getStatusBarToggles } from './appe
import { LeftSidebarAppearanceSetting } from './LeftSidebarAppearanceSetting'
import {
getLeftSidebarAppearanceEntry,
getRightSidebarEdgePeekEntry,
getWorkspaceCardLayoutEntry
} from './appearance-sidebar-search'
import { isRightSidebarEdgePeekEnabled } from '../right-sidebar/right-sidebar-edge-peek-preference'
import { translate } from '@/i18n/i18n'
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search'
@@ -62,9 +64,16 @@ export function AppearanceWindowSidebarSection({
const setWorktreeCardMode = useAppStore((state) => state.setWorktreeCardMode)
const visibleStatusBarToggles = useAvailableStatusBarToggles(getStatusBarToggles())
const leftSidebarAppearanceEntry = getLeftSidebarAppearanceEntry()
const rightSidebarEdgePeekEntry = getRightSidebarEdgePeekEntry()
const sidebarEntries = getSidebarEntries()
const workspaceCardLayoutEntry = getWorkspaceCardLayoutEntry()
const layoutEntries = getLayoutEntries()
// Why: sidebarEntries[0] is the edge-peek entry; later indices keep the
// existing left-sidebar button toggles that this section already renders.
const showTasksEntry = sidebarEntries[1]
const showAutomationsEntry = sidebarEntries[2]
const showMobileEntry = sidebarEntries[3]
const edgePeekEnabled = isRightSidebarEdgePeekEnabled(settings)
const statusBarTitle = translate(
'auto.components.settings.AppearancePane.3e4175e5c6',
'Status Bar'
@@ -191,13 +200,29 @@ export function AppearanceWindowSidebarSection({
/>
</SearchableSetting>
<SearchableSetting
title={rightSidebarEdgePeekEntry.title}
description={rightSidebarEdgePeekEntry.description}
keywords={rightSidebarEdgePeekEntry.keywords}
>
<SettingsSwitchRow
label={rightSidebarEdgePeekEntry.title}
description={rightSidebarEdgePeekEntry.description}
checked={edgePeekEnabled}
onChange={() =>
updateSettings({ rightSidebarEdgePeekEnabled: !edgePeekEnabled })
}
ariaLabel={rightSidebarEdgePeekEntry.title}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.cf81907069',
'Show Tasks Button'
)}
description={sidebarEntries[0]?.description}
keywords={sidebarEntries[0]?.keywords ?? ['tasks', 'sidebar', 'button']}
description={showTasksEntry?.description}
keywords={showTasksEntry?.keywords ?? ['tasks', 'sidebar', 'button']}
>
<SettingsSwitchRow
label={translate(
@@ -216,9 +241,9 @@ export function AppearanceWindowSidebarSection({
'auto.components.settings.AppearancePane.511f270ebb',
'Show Automations Button'
)}
description={sidebarEntries[1]?.description}
description={showAutomationsEntry?.description}
keywords={
sidebarEntries[1]?.keywords ?? ['automations', 'automation', 'schedule']
showAutomationsEntry?.keywords ?? ['automations', 'automation', 'schedule']
}
>
<SettingsSwitchRow
@@ -240,8 +265,8 @@ export function AppearanceWindowSidebarSection({
'auto.components.settings.AppearancePane.9da1020447',
'Show Orca Mobile Button'
)}
description={sidebarEntries[2]?.description}
keywords={sidebarEntries[2]?.keywords ?? ['mobile', 'phone', 'sidebar']}
description={showMobileEntry?.description}
keywords={showMobileEntry?.keywords ?? ['mobile', 'phone', 'sidebar']}
>
<SettingsSwitchRow
label={translate(
@@ -79,7 +79,48 @@ export const getWorkspaceCardLayoutEntry = createLocalizedCatalog(
})
)
export const getRightSidebarEdgePeekEntry = createLocalizedCatalog(
(): SettingsSearchEntry => ({
title: translate(
'auto.components.settings.appearance.search.rightSidebarEdgePeek.title',
'Right Sidebar Edge Peek'
),
description: translate(
'auto.components.settings.appearance.search.rightSidebarEdgePeek.description',
'Hover the window edge to temporarily reveal a closed right sidebar.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.appearance.search.5bff6a2ef0', 'sidebar'),
...translateSearchKeyword(
'auto.components.settings.appearance.search.rightSidebarEdgePeek.right',
'right sidebar'
),
...translateSearchKeyword(
'auto.components.settings.appearance.search.rightSidebarEdgePeek.peek',
'peek'
),
...translateSearchKeyword(
'auto.components.settings.appearance.search.rightSidebarEdgePeek.hover',
'hover'
),
...translateSearchKeyword(
'auto.components.settings.appearance.search.rightSidebarEdgePeek.edge',
'edge'
),
...translateSearchKeyword(
'auto.components.settings.appearance.search.rightSidebarEdgePeek.overlay',
'overlay'
),
...translateSearchKeyword(
'auto.components.settings.appearance.search.rightSidebarEdgePeek.arc',
'arc'
)
]
})
)
export const getSidebarEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [
getRightSidebarEdgePeekEntry(),
{
title: translate('auto.components.settings.appearance.search.155a1e7438', 'Show Tasks Button'),
description: translate(
+2
View File
@@ -275,6 +275,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
experimentalNativeChat: false,
openInApplications: [...DEFAULT_OPEN_IN_APPLICATIONS],
rightSidebarOpenByDefault: true,
rightSidebarEdgePeekEnabled: true,
rightSidebarEdgePeekTipDismissed: false,
showGitIgnoredFiles: true,
sourceControlViewMode: 'list',
sourceControlGroupOrder: DEFAULT_SOURCE_CONTROL_GROUP_ORDER,
+8
View File
@@ -2642,6 +2642,14 @@ export type GlobalSettings = {
openInApplications?: OpenInApplication[]
/** Deprecated: migration/backward-compat only. Use PersistedUIState.rightSidebarOpen. */
rightSidebarOpenByDefault: boolean
/** When enabled (default), hovering near the window's right edge while the
* right sidebar is closed peeks it as a floating overlay. Opt-out for users
* who only want the explicit toggle/shortcut. Optional for legacy profiles;
* readers treat absent as true. */
rightSidebarEdgePeekEnabled?: boolean
/** One-shot coachmark after first closing the right sidebar while edge peek
* is enabled. Absent/false means the tip can still show. */
rightSidebarEdgePeekTipDismissed?: boolean
showGitIgnoredFiles?: boolean
/** Preferred Source Control changes layout. Per-user, not per-workspace. */
sourceControlViewMode: SourceControlViewMode