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 { useAutoScrollBottom } from '@renderer/hooks/useAutoScrollBottom';
import { isNearBottom, useAutoScrollBottom } from '@renderer/hooks/useAutoScrollBottom';
import { useTabNavigationController } from '@renderer/hooks/useTabNavigationController';
import { useTabUI } from '@renderer/hooks/useTabUI';
import { useVisibleAIGroup } from '@renderer/hooks/useVisibleAIGroup';
import { useStore } from '@renderer/store';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ChevronsDown } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { SessionContextPanel } from './SessionContextPanel/index';
@ -343,11 +344,21 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
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.
// This preserves manual reading position when the user scrolls up.
// Disabled during navigation to prevent conflicts with deep-link/search scrolling.
useAutoScrollBottom([conversation], {
threshold: 150,
const { scrollToBottom } = useAutoScrollBottom([conversation], {
threshold: 300,
smoothDuration: 300,
autoBehavior: 'auto',
disabled: shouldDisableAutoScroll,
@ -355,6 +366,11 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
resetKey: effectiveTabId,
});
// Re-check button visibility whenever conversation updates
useEffect(() => {
checkScrollButton();
}, [conversation, checkScrollButton]);
// Callback to register AI group refs (combines with visibility hook)
const registerAIGroupRefCombined = useCallback(
(groupId: string) => {
@ -718,12 +734,13 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
className="flex flex-1 flex-col overflow-hidden"
style={{ backgroundColor: 'var(--color-surface)' }}
>
<div className="flex flex-1 overflow-hidden">
<div className="relative flex flex-1 overflow-hidden">
{/* Chat content */}
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto"
style={{ backgroundColor: 'var(--color-surface)' }}
onScroll={checkScrollButton}
>
{/* Sticky Context button */}
{allContextInjections.length > 0 && (
@ -813,6 +830,30 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
</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 */}
{isContextPanelVisible && allContextInjections.length > 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),
transition: isDragging ? 'none' : transition,
opacity: isDragging ? 0.3 : 1,

View file

@ -268,8 +268,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
sidebarCollapsed && isLeftmostPane
? 'var(--macos-traffic-light-padding-left, 72px)'
: '8px',
WebkitAppRegion:
isElectronMode() && sidebarCollapsed && isLeftmostPane ? 'drag' : undefined,
WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined,
backgroundColor: 'var(--color-surface)',
borderBottom: '1px solid var(--color-border)',
opacity: isFocused || paneCount === 1 ? 1 : 0.7,
@ -296,15 +295,17 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
</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
ref={(el) => {
scrollContainerRef.current = 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={
{
maxWidth: '75%',
WebkitAppRegion: 'no-drag',
outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none',
outlineOffset: '-1px',
@ -346,6 +347,13 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
)}
</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 */}
<div
className="ml-2 flex shrink-0 items-center gap-1"

View file

@ -138,6 +138,8 @@ export function useAutoScrollBottom(
const disabledRef = useRef(disabled);
// Track resetKey to detect changes
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.
@ -223,34 +225,47 @@ export function useAutoScrollBottom(
disabledRef.current = disabled;
}, [disabled]);
// Reset isAtBottom state when resetKey changes (e.g., tab/session switch)
// This ensures new content will auto-scroll to bottom
// Reset isAtBottom state when resetKey changes (e.g., tab/session switch).
// Sets needsInitialScrollRef so the content effect scrolls to bottom on first load.
useEffect(() => {
if (resetKey !== prevResetKeyRef.current) {
isAtBottomRef.current = true;
wasAtBottomBeforeUpdateRef.current = true;
prevResetKeyRef.current = resetKey;
needsInitialScrollRef.current = true;
}
}, [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(() => {
// Skip if disabled (e.g., during navigation) or not enabled
if (!enabled || disabled) return;
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
// 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;
let id1 = 0;
let id2 = 0;
// Only auto-scroll if user was at bottom before the update
if (wasAtBottomBeforeUpdateRef.current) {
scrollToBottom(autoBehavior);
}
id1 = requestAnimationFrame(() => {
id2 = requestAnimationFrame(() => {
// 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
}, [...dependencies, enabled, disabled, autoBehavior, scrollToBottom]);