diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-tip-host.test.tsx b/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-tip-host.test.tsx new file mode 100644 index 00000000000..19607e31d39 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek-tip-host.test.tsx @@ -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() + expect(screen.queryByTestId('right-sidebar-edge-peek-tip')).toBeNull() + + act(() => { + useAppStore.setState({ rightSidebarOpen: false }) + }) + rerender() + + 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() + + act(() => { + useAppStore.setState({ rightSidebarOpen: false }) + }) + rerender() + 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() + + act(() => { + useAppStore.setState({ rightSidebarOpen: false }) + }) + rerender() + + expect(screen.queryByTestId('right-sidebar-edge-peek-tip')).toBeNull() + }) +}) 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 index 8c978a459fa..483c12fbc7b 100644 --- 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 @@ -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(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 ( +
+
+ {translate( + 'auto.components.right.sidebar.edge.peek.tip.title', + 'Hover the right edge to peek' + )} + {/* Tooltip-style caret pointing at the window edge. */} + +
+
+ ) } 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 index b1738ffa908..3e484fdc411 100644 --- 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 @@ -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) + }) +}) 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 index ecb6fd129c8..d3bcc42f734 100644 --- 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 @@ -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) => 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 - } + void Promise.resolve(args.updateSettings({ rightSidebarEdgePeekTipDismissed: true })).catch( + () => {} ) } - -export function dismissRightSidebarEdgePeekTipToast(): void { - toast.dismiss(RIGHT_SIDEBAR_EDGE_PEEK_TIP_TOAST_ID) -}