From 724247475bc26fdb97d8a490a2bf72ec231d4c80 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:00:28 -0700 Subject: [PATCH] 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). --- src/renderer/src/App.tsx | 21 +++++-- .../src/components/right-sidebar/index.tsx | 26 ++++++-- ...right-sidebar-edge-peek-preference.test.ts | 34 +++++++++++ .../right-sidebar-edge-peek-preference.ts | 14 +++++ .../right-sidebar-edge-peek-tip-host.tsx | 56 +++++++++++++++++ .../right-sidebar-edge-peek-tip.test.ts | 61 +++++++++++++++++++ .../right-sidebar-edge-peek-tip.ts | 49 +++++++++++++++ .../right-sidebar-edge-peek.test.tsx | 27 ++++++++ .../right-sidebar/right-sidebar-edge-peek.tsx | 15 ++++- .../AppearanceWindowSidebarSection.tsx | 37 +++++++++-- .../settings/appearance-sidebar-search.ts | 41 +++++++++++++ src/shared/constants.ts | 2 + src/shared/types.ts | 8 +++ 13 files changed, 373 insertions(+), 18 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-preference.test.ts create mode 100644 src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-preference.ts create mode 100644 src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-tip-host.tsx create mode 100644 src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-tip.test.ts create mode 100644 src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-tip.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6c2522288d0..d9900bbb1aa 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 { ) + const rightSidebarEdgePeekEnabled = isRightSidebarEdgePeekEnabled(settings) const rightSidebarToggle = showRightSidebarControls ? ( @@ -2109,10 +2112,19 @@ function App(): React.JSX.Element { - - {translate('auto.App.c184e056de', 'Toggle right sidebar ({{value0}})', { - value0: rightSidebarShortcutLabel - })} + +
+ + {translate('auto.App.c184e056de', 'Toggle right sidebar ({{value0}})', { + value0: rightSidebarShortcutLabel + })} + + {rightSidebarEdgePeekEnabled ? ( + + {translate('auto.App.rightSidebarEdgePeekHint', 'Hover the right edge to peek')} + + ) : null} +
) : null @@ -2433,6 +2445,7 @@ function App(): React.JSX.Element { responsive. Unmount on the tasks view since that surface is intentionally distraction-free. */} {showRightSidebarControls ? : null} + {showRightSidebarControls ? : null} {showRightSidebarControls ? ( 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 { - - {translate( - 'auto.components.right.sidebar.index.9fffaf17c1', - 'Toggle right sidebar ({{value0}})', - { value0: rightSidebarShortcut } - )} + +
+ + {translate( + 'auto.components.right.sidebar.index.9fffaf17c1', + 'Toggle right sidebar ({{value0}})', + { value0: rightSidebarShortcut } + )} + + {rightSidebarEdgePeekEnabled ? ( + + {translate( + 'auto.components.right.sidebar.index.edgePeekHint', + 'Hover the right edge to peek' + )} + + ) : null} +
) : null diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-preference.test.ts b/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-preference.test.ts new file mode 100644 index 00000000000..bfaae56062c --- /dev/null +++ b/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-preference.test.ts @@ -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 + ) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-preference.ts b/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-preference.ts new file mode 100644 index 00000000000..721dd62910f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-preference.ts @@ -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 | null | undefined +): boolean { + return settings?.rightSidebarEdgePeekEnabled !== false +} + +export function isRightSidebarEdgePeekTipDismissed( + settings: Pick | null | undefined +): boolean { + return settings?.rightSidebarEdgePeekTipDismissed === true +} diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-tip-host.tsx b/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-tip-host.tsx new file mode 100644 index 00000000000..8c978a459fa --- /dev/null +++ b/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-tip-host.tsx @@ -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 +} diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-tip.test.ts b/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-tip.test.ts new file mode 100644 index 00000000000..b1738ffa908 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-tip.test.ts @@ -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 }) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-tip.ts b/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-tip.ts new file mode 100644 index 00000000000..ecb6fd129c8 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-tip.ts @@ -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) => Promise | 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) +} diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek.test.tsx b/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek.test.tsx index 7f39607c9bc..a50b554564f 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek.test.tsx +++ b/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek.test.tsx @@ -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() + + 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() + expect(useAppStore.getState().rightSidebarPeek).toBe(true) + + act(() => { + useAppStore.setState({ + settings: { rightSidebarEdgePeekEnabled: false } as never + }) + }) + rerender() + + expect(useAppStore.getState().rightSidebarPeek).toBe(false) + }) + it('clears an active peek when the zone unmounts on a view change', () => { const { unmount } = render() diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek.tsx b/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek.tsx index 90563ad993c..75adbdda394 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek.tsx +++ b/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek.tsx @@ -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 } diff --git a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx index 2173196651a..3b153b825dd 100644 --- a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx +++ b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx @@ -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({ /> + + + updateSettings({ rightSidebarEdgePeekEnabled: !edgePeekEnabled }) + } + ariaLabel={rightSidebarEdgePeekEntry.title} + /> + + ({ + 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( diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7e0aef0cda9..89ce8e41a2c 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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, diff --git a/src/shared/types.ts b/src/shared/types.ts index 82daa227a1b..9bda13bba08 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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