fix: reliable window drag region in tab bar

Includes scroll improvements:
- Scroll to bottom on session open and live auto-scroll
- Make auto-scroll StrictMode-safe via needsInitialScrollRef
- Add floating scroll-to-bottom button in chat view

Window drag fix:
- Apply drag region to leftmost pane TabBar regardless of sidebar state
- Cap tab list at 75% so drag spacer always has room
- Add explicit flex-1 drag spacer between tabs and action buttons
- Set WebkitAppRegion: no-drag on tab items for reordering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
proxy 2026-02-22 19:13:14 -05:00
parent 9c04e90fdd
commit 890d2d8e84
4 changed files with 86 additions and 21 deletions

View file

@ -1,11 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAutoScrollBottom } from '@renderer/hooks/useAutoScrollBottom'; import { isNearBottom, useAutoScrollBottom } from '@renderer/hooks/useAutoScrollBottom';
import { useTabNavigationController } from '@renderer/hooks/useTabNavigationController'; import { useTabNavigationController } from '@renderer/hooks/useTabNavigationController';
import { useTabUI } from '@renderer/hooks/useTabUI'; import { useTabUI } from '@renderer/hooks/useTabUI';
import { useVisibleAIGroup } from '@renderer/hooks/useVisibleAIGroup'; import { useVisibleAIGroup } from '@renderer/hooks/useVisibleAIGroup';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { ChevronsDown } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { SessionContextPanel } from './SessionContextPanel/index'; import { SessionContextPanel } from './SessionContextPanel/index';
@ -343,11 +344,21 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
rootRef: scrollContainerRef, rootRef: scrollContainerRef,
}); });
// Scroll-to-bottom button visibility
const [showScrollButton, setShowScrollButton] = useState(false);
const checkScrollButton = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
setShowScrollButton(!isNearBottom(scrollTop, scrollHeight, clientHeight, 300));
}, []);
// Auto-follow when conversation updates, but only if the user was already near bottom. // Auto-follow when conversation updates, but only if the user was already near bottom.
// This preserves manual reading position when the user scrolls up. // This preserves manual reading position when the user scrolls up.
// Disabled during navigation to prevent conflicts with deep-link/search scrolling. // Disabled during navigation to prevent conflicts with deep-link/search scrolling.
useAutoScrollBottom([conversation], { const { scrollToBottom } = useAutoScrollBottom([conversation], {
threshold: 150, threshold: 300,
smoothDuration: 300, smoothDuration: 300,
autoBehavior: 'auto', autoBehavior: 'auto',
disabled: shouldDisableAutoScroll, disabled: shouldDisableAutoScroll,
@ -355,6 +366,11 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
resetKey: effectiveTabId, resetKey: effectiveTabId,
}); });
// Re-check button visibility whenever conversation updates
useEffect(() => {
checkScrollButton();
}, [conversation, checkScrollButton]);
// Callback to register AI group refs (combines with visibility hook) // Callback to register AI group refs (combines with visibility hook)
const registerAIGroupRefCombined = useCallback( const registerAIGroupRefCombined = useCallback(
(groupId: string) => { (groupId: string) => {
@ -718,12 +734,13 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
className="flex flex-1 flex-col overflow-hidden" className="flex flex-1 flex-col overflow-hidden"
style={{ backgroundColor: 'var(--color-surface)' }} style={{ backgroundColor: 'var(--color-surface)' }}
> >
<div className="flex flex-1 overflow-hidden"> <div className="relative flex flex-1 overflow-hidden">
{/* Chat content */} {/* Chat content */}
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
className="flex-1 overflow-y-auto" className="flex-1 overflow-y-auto"
style={{ backgroundColor: 'var(--color-surface)' }} style={{ backgroundColor: 'var(--color-surface)' }}
onScroll={checkScrollButton}
> >
{/* Sticky Context button */} {/* Sticky Context button */}
{allContextInjections.length > 0 && ( {allContextInjections.length > 0 && (
@ -813,6 +830,30 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
</div> </div>
</div> </div>
{/* Scroll to bottom button */}
{showScrollButton && (
<button
onClick={() => {
scrollToBottom('smooth');
setShowScrollButton(false);
}}
className="absolute bottom-5 z-20 flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs shadow-lg backdrop-blur-md transition-all"
style={{
right:
isContextPanelVisible && allContextInjections.length > 0
? 'calc(320px + 1rem)'
: '1rem',
backgroundColor: 'var(--context-btn-bg)',
color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border-emphasis)',
}}
title="Scroll to bottom"
>
<ChevronsDown className="size-3.5" />
<span>Bottom</span>
</button>
)}
{/* Context panel sidebar */} {/* Context panel sidebar */}
{isContextPanelVisible && allContextInjections.length > 0 && ( {isContextPanelVisible && allContextInjections.length > 0 && (
<div className="w-80 shrink-0"> <div className="w-80 shrink-0">

View file

@ -60,7 +60,8 @@ export const SortableTab = ({
}, },
}); });
const style: React.CSSProperties = { const style = {
WebkitAppRegion: 'no-drag',
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition: isDragging ? 'none' : transition, transition: isDragging ? 'none' : transition,
opacity: isDragging ? 0.3 : 1, opacity: isDragging ? 0.3 : 1,

View file

@ -268,8 +268,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
sidebarCollapsed && isLeftmostPane sidebarCollapsed && isLeftmostPane
? 'var(--macos-traffic-light-padding-left, 72px)' ? 'var(--macos-traffic-light-padding-left, 72px)'
: '8px', : '8px',
WebkitAppRegion: WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined,
isElectronMode() && sidebarCollapsed && isLeftmostPane ? 'drag' : undefined,
backgroundColor: 'var(--color-surface)', backgroundColor: 'var(--color-surface)',
borderBottom: '1px solid var(--color-border)', borderBottom: '1px solid var(--color-border)',
opacity: isFocused || paneCount === 1 ? 1 : 0.7, opacity: isFocused || paneCount === 1 ? 1 : 0.7,
@ -296,15 +295,17 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
</button> </button>
)} )}
{/* Tab list with horizontal scroll, sortable DnD, and droppable area */} {/* Tab list with horizontal scroll, sortable DnD, and droppable area.
Capped at 75% so the drag spacer always has room to the right. */}
<div <div
ref={(el) => { ref={(el) => {
scrollContainerRef.current = el; scrollContainerRef.current = el;
setDroppableRef(el); setDroppableRef(el);
}} }}
className="scrollbar-none flex min-w-0 flex-1 items-center gap-1 overflow-x-auto" className="scrollbar-none flex min-w-0 shrink items-center gap-1 overflow-x-auto"
style={ style={
{ {
maxWidth: '75%',
WebkitAppRegion: 'no-drag', WebkitAppRegion: 'no-drag',
outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none', outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none',
outlineOffset: '-1px', outlineOffset: '-1px',
@ -346,6 +347,13 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
)} )}
</div> </div>
{/* Drag spacer fills empty space between tab list and action buttons.
Gives users a reliable window-drag target regardless of how many tabs are open. */}
<div
className="flex-1 self-stretch"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
/>
{/* Right side actions */} {/* Right side actions */}
<div <div
className="ml-2 flex shrink-0 items-center gap-1" className="ml-2 flex shrink-0 items-center gap-1"

View file

@ -138,6 +138,8 @@ export function useAutoScrollBottom(
const disabledRef = useRef(disabled); const disabledRef = useRef(disabled);
// Track resetKey to detect changes // Track resetKey to detect changes
const prevResetKeyRef = useRef(resetKey); const prevResetKeyRef = useRef(resetKey);
// Set true when resetKey changes; consumed by the content effect to force scroll on first load
const needsInitialScrollRef = useRef(false);
/** /**
* Check if the scroll container is at the bottom. * Check if the scroll container is at the bottom.
@ -223,34 +225,47 @@ export function useAutoScrollBottom(
disabledRef.current = disabled; disabledRef.current = disabled;
}, [disabled]); }, [disabled]);
// Reset isAtBottom state when resetKey changes (e.g., tab/session switch) // Reset isAtBottom state when resetKey changes (e.g., tab/session switch).
// This ensures new content will auto-scroll to bottom // Sets needsInitialScrollRef so the content effect scrolls to bottom on first load.
useEffect(() => { useEffect(() => {
if (resetKey !== prevResetKeyRef.current) { if (resetKey !== prevResetKeyRef.current) {
isAtBottomRef.current = true; isAtBottomRef.current = true;
wasAtBottomBeforeUpdateRef.current = true; wasAtBottomBeforeUpdateRef.current = true;
prevResetKeyRef.current = resetKey; prevResetKeyRef.current = resetKey;
needsInitialScrollRef.current = true;
} }
}, [resetKey]); }, [resetKey]);
/** /**
* After content updates (dependencies change), scroll to bottom if we were at bottom. * After content updates (dependencies change), scroll to bottom if:
* - User was already near the bottom before the update, OR
* - This is the first load after a tab/session switch (needsInitialScrollRef)
* Uses double-RAF + cleanup so React StrictMode's double-invoke doesn't fire twice.
*/ */
useEffect(() => { useEffect(() => {
// Skip if disabled (e.g., during navigation) or not enabled // Skip if disabled (e.g., during navigation) or not enabled
if (!enabled || disabled) return; if (!enabled || disabled) return;
// Use requestAnimationFrame to ensure DOM has updated let id1 = 0;
requestAnimationFrame(() => { let id2 = 0;
// Re-check disabled state inside RAF - it might have changed between effect and callback
// This prevents auto-scroll from firing if navigation started after the effect ran
if (disabledRef.current) return;
// Only auto-scroll if user was at bottom before the update id1 = requestAnimationFrame(() => {
if (wasAtBottomBeforeUpdateRef.current) { id2 = requestAnimationFrame(() => {
scrollToBottom(autoBehavior); // Re-check disabled state — navigation may have started between effect and RAF
} if (disabledRef.current) return;
const shouldScroll = needsInitialScrollRef.current || wasAtBottomBeforeUpdateRef.current;
if (shouldScroll) {
needsInitialScrollRef.current = false;
scrollToBottom(autoBehavior);
}
});
}); });
return () => {
cancelAnimationFrame(id1);
cancelAnimationFrame(id2);
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- Dynamic dependencies array is intentional design // eslint-disable-next-line react-hooks/exhaustive-deps -- Dynamic dependencies array is intentional design
}, [...dependencies, enabled, disabled, autoBehavior, scrollToBottom]); }, [...dependencies, enabled, disabled, autoBehavior, scrollToBottom]);