diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 2aa419d9be0..3f88cf1e051 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -2161,6 +2161,9 @@ function App(): React.JSX.Element { className="sidebar-toggle mr-2" onClick={actions.toggleRightSidebar} aria-label={translate('auto.App.9e0b441a91', 'Toggle right sidebar')} + // Why: one-shot edge-peek tip anchors to this titlebar control after + // the first close (the control reappears when the sidebar is closed). + data-right-sidebar-toggle="" > 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 index 19607e31d39..d614857d199 100644 --- 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 @@ -8,9 +8,16 @@ import { RIGHT_SIDEBAR_EDGE_PEEK_TIP_VISIBLE_MS } from './right-sidebar-edge-pee const initialAppState = useAppStore.getInitialState() +function clearToggleAnchors(): void { + for (const el of document.querySelectorAll('[data-right-sidebar-toggle]')) { + el.remove() + } +} + beforeEach(() => { vi.useFakeTimers() cleanup() + clearToggleAnchors() useAppStore.setState({ ...initialAppState, rightSidebarOpen: true, @@ -25,12 +32,33 @@ beforeEach(() => { afterEach(() => { cleanup() + clearToggleAnchors() vi.useRealTimers() useAppStore.setState(initialAppState, true) }) +function mountToggleAnchor(): void { + clearToggleAnchors() + const btn = document.createElement('button') + btn.setAttribute('data-right-sidebar-toggle', '') + btn.getBoundingClientRect = () => + ({ + top: 8, + left: 1600, + width: 32, + height: 24, + bottom: 32, + right: 1632, + x: 1600, + y: 8, + toJSON: () => ({}) + }) as DOMRect + document.body.appendChild(btn) +} + describe('RightSidebarEdgePeekTipHost', () => { - it('shows a brief edge tip on the first open→closed transition', () => { + it('shows a brief tip under the toggle on the first open→closed transition', () => { + mountToggleAnchor() const { rerender } = render() expect(screen.queryByTestId('right-sidebar-edge-peek-tip')).toBeNull() @@ -39,12 +67,13 @@ describe('RightSidebarEdgePeekTipHost', () => { }) rerender() - expect(screen.getByTestId('right-sidebar-edge-peek-tip').textContent).toContain( - 'Hover the right edge to peek' - ) + const tip = screen.getByTestId('right-sidebar-edge-peek-tip') + expect(tip.textContent).toContain('Hover the right edge to peek') + expect(tip.textContent).toContain('Turn this off in Settings') }) it('auto-hides after the visible duration and marks the tip dismissed', () => { + mountToggleAnchor() const updateSettings = vi.fn().mockResolvedValue(undefined) useAppStore.setState({ updateSettings }) const { rerender } = render() @@ -64,6 +93,7 @@ describe('RightSidebarEdgePeekTipHost', () => { }) it('does not show when the tip was already dismissed', () => { + mountToggleAnchor() useAppStore.setState({ settings: { rightSidebarEdgePeekEnabled: true, @@ -79,4 +109,15 @@ describe('RightSidebarEdgePeekTipHost', () => { expect(screen.queryByTestId('right-sidebar-edge-peek-tip')).toBeNull() }) + + it('does not show without a measurable toggle anchor', () => { + 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 483c12fbc7b..266e3b45a55 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,4 +1,4 @@ -import { useEffect, useRef, useState, type JSX } from 'react' +import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type JSX } from 'react' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { @@ -8,8 +8,24 @@ import { } from './right-sidebar-edge-peek-tip' import { isRightSidebarEdgePeekEnabled } from './right-sidebar-edge-peek-preference' +const TOGGLE_SELECTOR = '[data-right-sidebar-toggle]' + +type AnchorRect = { top: number; left: number; width: number; height: number } + +function measureToggleAnchor(): AnchorRect | null { + const el = document.querySelector(TOGGLE_SELECTOR) + if (!(el instanceof HTMLElement)) { + return null + } + const rect = el.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) { + return null + } + return { top: rect.top, left: rect.left, width: rect.width, height: rect.height } +} + /** - * One-shot tip: a quiet tooltip-style chip on the right edge after the first + * One-shot tip anchored under the right-sidebar titlebar toggle after the first * close while peek is enabled. Auto-hides after ~1s. Successful peeks also mark * the tip seen. */ @@ -19,6 +35,7 @@ export function RightSidebarEdgePeekTipHost(): JSX.Element | null { const settings = useAppStore((s) => s.settings) const updateSettings = useAppStore((s) => s.updateSettings) const [visible, setVisible] = useState(false) + const [anchor, setAnchor] = useState(null) // 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) @@ -79,28 +96,68 @@ export function RightSidebarEdgePeekTipHost(): JSX.Element | null { } }, [settings, visible]) - if (!visible) { + // Why: measure after paint so the closed-state titlebar toggle has laid out; + // re-measure on resize while the tip is up so a window drag doesn't leave it + // floating off the icon. + useLayoutEffect(() => { + if (!visible) { + setAnchor(null) + return + } + const update = (): void => { + setAnchor(measureToggleAnchor()) + } + update() + // Titlebar toggle mounts in the same close transition; retry once if the + // first paint still has the open-sidebar control (which unmounts). + const retry = window.setTimeout(update, 0) + window.addEventListener('resize', update) + return () => { + window.clearTimeout(retry) + window.removeEventListener('resize', update) + } + }, [visible, rightSidebarOpen]) + + if (!visible || !anchor) { return null } + // Why: hang left from the toggle so a near-edge titlebar icon doesn't clip + // the tip off-screen; caret sits under the icon center. + const caretOffsetPx = 14 + const style: CSSProperties = { + position: 'fixed', + top: anchor.top + anchor.height + 8, + left: anchor.left + anchor.width / 2 + caretOffsetPx, + transform: 'translateX(-100%)' + } + return (
-
- {translate( - 'auto.components.right.sidebar.edge.peek.tip.title', - 'Hover the right edge to peek' - )} - {/* Tooltip-style caret pointing at the window edge. */} +
+
+ {translate( + 'auto.components.right.sidebar.edge.peek.tip.title', + 'Hover the right edge to peek' + )} +
+
+ {translate( + 'auto.components.right.sidebar.edge.peek.tip.settings', + 'Turn this off in Settings' + )} +
+ {/* Tooltip-style caret pointing up at the toggle icon. */}