diff --git a/packages/ui/src/components/layout/ContextSidebarTab.tsx b/packages/ui/src/components/layout/ContextSidebarTab.tsx index d6f5d549..bcf0390b 100644 --- a/packages/ui/src/components/layout/ContextSidebarTab.tsx +++ b/packages/ui/src/components/layout/ContextSidebarTab.tsx @@ -20,6 +20,7 @@ import { } from './rawMessagePreview'; import type { TimeFormatPreference } from '@/stores/useUIStore'; import { formatDateTimeForPreference } from '@/lib/timeFormat'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; type SessionMessage = { info: Message; parts: Part[] }; @@ -410,7 +411,7 @@ export const ContextPanelContent: React.FC = () => { ]; return ( -
+
{/* ── Session header ── */} @@ -644,6 +645,6 @@ export const ContextPanelContent: React.FC = () => {
- + ); }; diff --git a/packages/ui/src/components/layout/Sidebar.tsx b/packages/ui/src/components/layout/Sidebar.tsx index a716541b..77a7b32d 100644 --- a/packages/ui/src/components/layout/Sidebar.tsx +++ b/packages/ui/src/components/layout/Sidebar.tsx @@ -3,6 +3,7 @@ import { cn } from '@/lib/utils'; import { ErrorBoundary } from '../ui/ErrorBoundary'; import { useI18n } from '@/lib/i18n'; import { useUIStore } from '@/stores/useUIStore'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; const SIDEBAR_CONTENT_WIDTH = 280; const SIDEBAR_MIN_WIDTH = 280; @@ -174,9 +175,9 @@ export const Sidebar: React.FC = ({ isOpen, isMobile, children, cl aria-hidden={!isOpen} > {topBar} -
+ {children} -
+ ); diff --git a/packages/ui/src/components/sections/projects/ProjectActionsSection.test.tsx b/packages/ui/src/components/sections/projects/ProjectActionsSection.test.tsx index da2e1789..a192c39e 100644 --- a/packages/ui/src/components/sections/projects/ProjectActionsSection.test.tsx +++ b/packages/ui/src/components/sections/projects/ProjectActionsSection.test.tsx @@ -7,7 +7,6 @@ import { I18nProvider } from '@/lib/i18n'; const desktopSshState = { instances: [], load: async () => undefined }; -mock.module('@/lib/desktop', () => ({ isDesktopShell: () => false })); mock.module('@/stores/useDesktopSshStore', () => ({ useDesktopSshStore: (selector: (state: typeof desktopSshState) => T): T => selector(desktopSshState), })); diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index fc34e08a..04f80218 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Dialog, DialogContent, @@ -681,7 +682,7 @@ export const DirectoryExplorerDialog: React.FC = ( const resultsSection = (
-
+
{t('directoryExplorerDialog.browse.directories')}
@@ -780,7 +781,7 @@ export const DirectoryExplorerDialog: React.FC = ( })}
)} -
+ ); diff --git a/packages/ui/src/components/session/GitHubIntegrationDialog.tsx b/packages/ui/src/components/session/GitHubIntegrationDialog.tsx index 03bf5a3a..5b6b8446 100644 --- a/packages/ui/src/components/session/GitHubIntegrationDialog.tsx +++ b/packages/ui/src/components/session/GitHubIntegrationDialog.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Dialog, DialogContent, @@ -362,7 +363,7 @@ export function GitHubIntegrationDialog({ {/* List Content */}
-
+ {/* Loading */} {loading && (
@@ -505,7 +506,7 @@ export function GitHubIntegrationDialog({ )}
)} -
+
)} diff --git a/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx b/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx index a861ac7b..13e303d8 100644 --- a/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx +++ b/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Dialog, DialogContent, @@ -514,7 +515,7 @@ export function GitHubIssuePickerDialog({ /> -
+ {!projectDirectory ? (
{t('session.githubIssuePicker.empty.noActiveProject')}
) : null} @@ -636,7 +637,7 @@ export function GitHubIssuePickerDialog({
) : null} - + {mode !== 'select' && (
diff --git a/packages/ui/src/components/session/GitHubPrPickerDialog.tsx b/packages/ui/src/components/session/GitHubPrPickerDialog.tsx index fcd0b716..66158455 100644 --- a/packages/ui/src/components/session/GitHubPrPickerDialog.tsx +++ b/packages/ui/src/components/session/GitHubPrPickerDialog.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Dialog, DialogContent, @@ -318,7 +319,7 @@ export function GitHubPrPickerDialog({
-
+ {!projectDirectory ? (
{t('session.githubPrPicker.empty.noActiveProject')}
) : null} @@ -439,7 +440,7 @@ export function GitHubPrPickerDialog({
) : null} - + ); diff --git a/packages/ui/src/components/session/NewWorktreeDialog.behavior.test.tsx b/packages/ui/src/components/session/NewWorktreeDialog.behavior.test.tsx index 2b74920f..81313ce7 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.behavior.test.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.behavior.test.tsx @@ -140,6 +140,8 @@ const DOM_GLOBAL_NAMES = [ 'HTMLElement', 'HTMLIFrameElement', 'localStorage', + 'requestAnimationFrame', + 'cancelAnimationFrame', 'IS_REACT_ACT_ENVIRONMENT', ] as const; @@ -157,6 +159,8 @@ const installDom = () => { HTMLElement: happyWindow.HTMLElement, HTMLIFrameElement: happyWindow.HTMLIFrameElement, localStorage: happyWindow.localStorage, + requestAnimationFrame: happyWindow.requestAnimationFrame.bind(happyWindow), + cancelAnimationFrame: happyWindow.cancelAnimationFrame.bind(happyWindow), IS_REACT_ACT_ENVIRONMENT: true, }; for (const name of DOM_GLOBAL_NAMES) { @@ -168,6 +172,7 @@ const installDom = () => { return { container, restore: () => { + happyWindow.close(); for (const [name, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, name, descriptor); else Reflect.deleteProperty(globalThis, name); diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index 1d22fe02..cf88ea49 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Dialog, DialogContent, @@ -1788,7 +1789,7 @@ export function NewWorktreeDialog({ -
+ {/* Branch Name / Existing Branch Selection */} {mode === 'existing-branch' ? (
@@ -2213,7 +2214,7 @@ export function NewWorktreeDialog({ )}
)} -
+ {/* Footer */} diff --git a/packages/ui/src/components/session/SessionSwitcherDropdown.tsx b/packages/ui/src/components/session/SessionSwitcherDropdown.tsx index 017e6b56..0cfba1ec 100644 --- a/packages/ui/src/components/session/SessionSwitcherDropdown.tsx +++ b/packages/ui/src/components/session/SessionSwitcherDropdown.tsx @@ -22,6 +22,7 @@ import { formatSessionCompactDateLabel } from './sidebar/utils'; import type { SessionNode } from './sidebar/types'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; type SecondaryMeta = SwitcherItem['secondaryMeta']; @@ -86,7 +87,7 @@ function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentP }, [onSelect, openNewSessionDraft]); const [expandedParents, setExpandedParents] = React.useState>(new Set()); - const contentRef = React.useRef(null); + const contentRef = React.useRef(null); const initialFocusCompleteRef = React.useRef(false); const initialTarget = isNewSessionDraftOpen ? NEW_SESSION_SWITCHER_TARGET : currentSessionId; const toggleParent = React.useCallback((sessionId: string) => { @@ -127,7 +128,11 @@ function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentP }, [expandedParents, initialTarget, items]); return ( -
+
-
+
); } diff --git a/packages/ui/src/components/ui/HelpDialog.tsx b/packages/ui/src/components/ui/HelpDialog.tsx index f5f776b5..4cd4ec45 100644 --- a/packages/ui/src/components/ui/HelpDialog.tsx +++ b/packages/ui/src/components/ui/HelpDialog.tsx @@ -18,6 +18,7 @@ import { import { useI18n, type I18nKey } from "@/lib/i18n"; import { isVSCodeRuntime } from "@/lib/desktop"; import type { IconName } from "@/components/icon/icons"; +import { ScrollableOverlay } from "@/components/ui/ScrollableOverlay"; type ShortcutItem = { id?: ShortcutActionId; @@ -219,7 +220,11 @@ export const HelpDialog: React.FC = () => { -
+
{shortcuts.map((section) => (
@@ -308,7 +313,7 @@ export const HelpDialog: React.FC = () => {
-
+ ); diff --git a/packages/ui/src/components/ui/OverlayScrollbar.test.tsx b/packages/ui/src/components/ui/OverlayScrollbar.test.tsx index e75ecf14..88059012 100644 --- a/packages/ui/src/components/ui/OverlayScrollbar.test.tsx +++ b/packages/ui/src/components/ui/OverlayScrollbar.test.tsx @@ -393,4 +393,157 @@ describe('OverlayScrollbar', () => { expect(scrollbar.dataset.visible).toBe('true'); }); + test('re-measures on hover so a horizontally overflowing container shows its horizontal thumb', async () => { + await renderScrollbar({ disableHorizontal: false }); + const scrollbar = host.querySelector('.overlay-scrollbar'); + if (!scrollbar) throw new Error('OverlayScrollbar did not render its container'); + const horizontalThumb = host.querySelector('[data-overlay-scrollbar-thumb="horizontal"]'); + if (!horizontalThumb) throw new Error('OverlayScrollbar did not render its horizontal thumb'); + + // Horizontal overflow appears after mount without a resize, so the mount + // measure saw no overflow and the thumb is hidden. The hover re-measure is + // the only path that can reveal it now (no ResizeObserver is triggered here). + expect(horizontalThumb.hidden).toBe(true); + Object.defineProperty(scroller, 'scrollWidth', { configurable: true, get: () => 300 }); + + scroller.dispatchEvent(new window.PointerEvent('pointerenter', { bubbles: false, pointerType: 'mouse' })); + await flushFrames(); + + expect(scrollbar.dataset.visible).toBe('true'); + expect(horizontalThumb.hidden).toBe(false); + expect(horizontalThumb.style.width).not.toBe(''); + }); + + test('keeps the horizontal thumb hidden on hover when there is no horizontal overflow', async () => { + await renderScrollbar({ disableHorizontal: false }); + const horizontalThumb = host.querySelector('[data-overlay-scrollbar-thumb="horizontal"]'); + if (!horizontalThumb) throw new Error('OverlayScrollbar did not render its horizontal thumb'); + + expect(horizontalThumb.hidden).toBe(true); + scroller.dispatchEvent(new window.PointerEvent('pointerenter', { bubbles: false, pointerType: 'mouse' })); + await flushFrames(); + + expect(horizontalThumb.hidden).toBe(true); + }); + + test('does not reveal the thumb on touch taps (pointerType guard)', async () => { + await renderScrollbar(); + const scrollbar = host.querySelector('.overlay-scrollbar'); + if (!scrollbar) throw new Error('OverlayScrollbar did not render its container'); + + scroller.dispatchEvent(new window.PointerEvent('pointerenter', { bubbles: false, pointerType: 'touch' })); + await flushFrames(); + + expect(scrollbar.dataset.visible).toBe('false'); + }); + + test('keeps the thumb visible while the pointer crosses from container onto the thumb', async () => { + await renderScrollbar({ hideDelayMs: 10 }); + const scrollbar = host.querySelector('.overlay-scrollbar'); + if (!scrollbar) throw new Error('OverlayScrollbar did not render its container'); + const thumb = host.querySelector('[data-overlay-scrollbar-thumb="vertical"]'); + if (!thumb) throw new Error('OverlayScrollbar did not render its vertical thumb'); + + // Enter the container: thumb reveals. + scroller.dispatchEvent(new window.PointerEvent('pointerenter', { bubbles: false, pointerType: 'mouse' })); + expect(scrollbar.dataset.visible).toBe('true'); + + // Leave the container (pointer moves toward the sibling thumb): this arms + // the hide timer. The thumb's pointerover fires after the container's + // pointerleave, so the fire-time re-check must keep the thumb visible. + scroller.dispatchEvent(new window.PointerEvent('pointerleave', { bubbles: false, pointerType: 'mouse' })); + thumb.dispatchEvent(new window.PointerEvent('pointerover', { bubbles: true, pointerType: 'mouse' })); + await flushFrames(); + await new Promise((resolve) => setTimeout(resolve, 30)); + + expect(scrollbar.dataset.visible).toBe('true'); + thumb.dispatchEvent(new window.PointerEvent('pointerout', { bubbles: true, pointerType: 'mouse' })); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(scrollbar.dataset.visible).toBe('false'); + }); + + test('refreshes the vertical thumb on hover after hidden programmatic scrolling', async () => { + await renderScrollbar({ userIntentOnly: true }); + scrollTop = 200; + scroller.dispatchEvent(new window.Event('scroll')); + await flushFrames(); + const thumb = host.querySelector('[data-overlay-scrollbar-thumb="vertical"]'); + expect(thumb?.style.transform).toBe('translate3d(0, 8px, 0)'); + horizontalLayoutReads = 0; + + scroller.dispatchEvent(new window.PointerEvent('pointerenter', { pointerType: 'mouse' })); + await flushFrames(); + expect(host.querySelector('.overlay-scrollbar')?.dataset.visible).toBe('true'); + expect(thumb?.style.transform).toBe('translate3d(0, 34px, 0)'); + expect(horizontalLayoutReads).toBe(0); + }); + + test('keeps a hovered user-intent scrollbar visible and positioned during programmatic scrolling', async () => { + await renderScrollbar({ userIntentOnly: true }); + scroller.dispatchEvent(new window.PointerEvent('pointerenter', { pointerType: 'mouse' })); + await flushFrames(); + verticalLayoutReads = 0; + horizontalLayoutReads = 0; + scrollbarCommits = 0; + + scrollTop = 200; + for (let i = 0; i < 100; i += 1) scroller.dispatchEvent(new window.Event('scroll')); + await flushFrames(); + expect(host.querySelector('.overlay-scrollbar')?.dataset.visible).toBe('true'); + expect(host.querySelector('[data-overlay-scrollbar-thumb="vertical"]')?.style.transform).toBe('translate3d(0, 34px, 0)'); + expect(verticalLayoutReads).toBe(0); + expect(horizontalLayoutReads).toBe(0); + expect(scrollbarCommits).toBe(0); + }); + + test('restores hover visibility and position when programmatic suppression ends', async () => { + await renderScrollbar({ userIntentOnly: true, suppressVisibility: true }); + scroller.dispatchEvent(new window.PointerEvent('pointerenter', { pointerType: 'mouse' })); + scrollTop = 200; + scroller.dispatchEvent(new window.Event('scroll')); + expect(host.querySelector('.overlay-scrollbar')?.dataset.visible).toBe('false'); + + await renderScrollbar({ userIntentOnly: true, suppressVisibility: false }); + expect(host.querySelector('.overlay-scrollbar')?.dataset.visible).toBe('true'); + expect(host.querySelector('[data-overlay-scrollbar-thumb="vertical"]')?.style.transform).toBe('translate3d(0, 34px, 0)'); + expect(TestResizeObserver.instances).toHaveLength(1); + + await renderScrollbar({ userIntentOnly: true, suppressVisibility: true }); + expect(host.querySelector('.overlay-scrollbar')?.dataset.visible).toBe('false'); + }); + + test('turning off always-visible retains hover until the mouse leaves', async () => { + useUIStore.getState().setAlwaysShowScrollbars(true); + await renderScrollbar({ hideDelayMs: 10 }); + scroller.dispatchEvent(new window.PointerEvent('pointerenter', { pointerType: 'mouse' })); + await act(async () => useUIStore.getState().setAlwaysShowScrollbars(false)); + await flushFrames(); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(host.querySelector('.overlay-scrollbar')?.dataset.visible).toBe('true'); + + scroller.dispatchEvent(new window.PointerEvent('pointerleave', { pointerType: 'mouse' })); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(host.querySelector('.overlay-scrollbar')?.dataset.visible).toBe('false'); + }); + + test('hides the thumb after the pointer leaves the container', async () => { + await renderScrollbar({ hideDelayMs: 0 }); + const scrollbar = host.querySelector('.overlay-scrollbar'); + if (!scrollbar) throw new Error('OverlayScrollbar did not render its container'); + + scroller.dispatchEvent(new window.PointerEvent('pointerenter', { bubbles: false, pointerType: 'mouse' })); + expect(scrollbar.dataset.visible).toBe('true'); + + scroller.dispatchEvent(new window.PointerEvent('pointerleave', { bubbles: false, pointerType: 'mouse' })); + await flushFrames(); + + // The hide path is a setTimeout (hideDelayMs: 0 still schedules a 0ms + // timer). happy-dom runs real timers, so we must let the macrotask fire + // before asserting; rAF frames alone are not enough. + await new Promise((resolve) => setTimeout(resolve, 5)); + await flushFrames(); + + expect(scrollbar.dataset.visible).toBe('false'); + }); + }); diff --git a/packages/ui/src/components/ui/OverlayScrollbar.tsx b/packages/ui/src/components/ui/OverlayScrollbar.tsx index 6e183e3a..d241edf4 100644 --- a/packages/ui/src/components/ui/OverlayScrollbar.tsx +++ b/packages/ui/src/components/ui/OverlayScrollbar.tsx @@ -67,6 +67,11 @@ function bindScrollbar( let hideDeadlineMs = 0; let lastUserIntentTimeMs = Number.NEGATIVE_INFINITY; let pointerOverThumb = false; + // The thumb is a sibling overlay of the container, so hover state is tracked + // for both: moving the pointer from the container onto the thumb fires the + // container's pointerleave before the thumb's pointerover, and the hide timer + // must not run in the gap between those two events. + let pointerOverContainer = false; // Drag state is the minimum snapshot needed to convert pointer travel back into a scroll offset. let drag: { @@ -84,11 +89,11 @@ function bindScrollbar( }; const scheduleHide = () => { - if (options.alwaysVisible || pointerOverThumb || drag || hideTimerId !== null) return; + if (options.alwaysVisible || pointerOverThumb || pointerOverContainer || drag || hideTimerId !== null) return; const hide = () => { hideTimerId = null; - if (options.alwaysVisible || pointerOverThumb || drag) return; + if (options.alwaysVisible || pointerOverThumb || pointerOverContainer || drag) return; const delay = hideDeadlineMs - performance.now(); if (delay > 0) { hideTimerId = setTimeout(hide, delay); @@ -163,7 +168,7 @@ function bindScrollbar( const onScroll = () => { const shouldShow = options.alwaysVisible || drag || (!options.suppressVisibility - && (!options.userIntentOnly + && (pointerOverContainer || pointerOverThumb || !options.userIntentOnly || performance.now() - lastUserIntentTimeMs <= USER_INTENT_DURATION_MS)); if (!shouldShow) { @@ -247,7 +252,34 @@ function bindScrollbar( scheduleHide(); }; + // Hover makes the thumb reachable without wheel input. Touch/pen pointerenter + // also fires on contact, so only mouse pointers get this affordance. + const onContainerPointerEnter = (event: PointerEvent) => { + if (event.pointerType !== "mouse") return; + pointerOverContainer = true; + if (options.suppressVisibility && !options.alwaysVisible) return; + hideDeadlineMs = performance.now() + options.hideDelayMs; + if (hideTimerId !== null) { + clearTimeout(hideTimerId); + hideTimerId = null; + } + setVisible(true); + // Hidden programmatic scrolling may have skipped positioning. Refresh both + // position and overflow on reveal; disabled horizontal geometry stays unread. + scheduleUpdate(true); + }; + + const onContainerPointerLeave = (event: PointerEvent) => { + if (event.pointerType !== "mouse") return; + pointerOverContainer = false; + if (options.suppressVisibility) return; + hideDeadlineMs = performance.now() + options.hideDelayMs; + scheduleHide(); + }; + container.addEventListener("scroll", onScroll, { passive: true }); + container.addEventListener("pointerenter", onContainerPointerEnter); + container.addEventListener("pointerleave", onContainerPointerLeave); root.addEventListener("pointerdown", onPointerDown); root.addEventListener("pointermove", onPointerMove); root.addEventListener("pointerup", onPointerEnd); @@ -325,10 +357,13 @@ function bindScrollbar( } if (visibilityChanged) scheduleUpdate(); setVisible(true); - } else if (visibilityChanged) { - setVisible(Boolean(drag || pointerOverThumb)); } else if (options.suppressVisibility && !drag) { setVisible(false); + } else if (pointerOverContainer || pointerOverThumb) { + scheduleUpdate(); + setVisible(true); + } else if (visibilityChanged) { + setVisible(Boolean(drag)); } }, disconnect() { @@ -338,6 +373,8 @@ function bindScrollbar( verticalThumb.hidden = true; horizontalThumb.hidden = true; container.removeEventListener("scroll", onScroll); + container.removeEventListener("pointerenter", onContainerPointerEnter); + container.removeEventListener("pointerleave", onContainerPointerLeave); setUserIntentListeners(false); root.removeEventListener("pointerdown", onPointerDown); root.removeEventListener("pointermove", onPointerMove); diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index 920def3c..db9e80c9 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -991,17 +991,17 @@ export const SettingsView: React.FC = ({ onClose, forceMobile // No sidebar available; fall back to direct content. const fallback = renderPageContent(settingsSlug); return ( -
+ {fallback} -
+ ); } return ( -
+ {renderPageSidebar(settingsSlug, { onItemSelect: handleMobilePageSidebarItemSelect })} -
+ ); } @@ -1009,9 +1009,9 @@ export const SettingsView: React.FC = ({ onClose, forceMobile const content = renderPageContent(settingsSlug); return ( -
+ {content} -
+ ); }; @@ -1026,17 +1026,17 @@ export const SettingsView: React.FC = ({ onClose, forceMobile
{renderPageSidebar(settingsSlug, {})}
-
+ {renderPageContent(settingsSlug)} -
+ ); } return ( -
+ {renderPageContent(settingsSlug)} -
+ ); };