Merge pull request #69 from proxikal/fix/window-drag-region

fix: reliable window drag region in tab bar
This commit is contained in:
matt 2026-02-23 21:59:46 +08:00 committed by GitHub
commit 9629c0a530
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 97 additions and 21 deletions

View file

@ -1,14 +1,21 @@
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';
/** Pixels from bottom considered "near bottom" for scroll-button visibility and auto-scroll. */
const SCROLL_THRESHOLD = 300;
/** Must match the `w-80` (320px) context panel width used in the layout below. */
const CONTEXT_PANEL_WIDTH_PX = 320;
import { ChatHistoryEmptyState } from './ChatHistoryEmptyState'; import { ChatHistoryEmptyState } from './ChatHistoryEmptyState';
import { ChatHistoryItem } from './ChatHistoryItem'; import { ChatHistoryItem } from './ChatHistoryItem';
import { ChatHistoryLoadingState } from './ChatHistoryLoadingState'; import { ChatHistoryLoadingState } from './ChatHistoryLoadingState';
@ -343,11 +350,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, SCROLL_THRESHOLD));
}, []);
// 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: SCROLL_THRESHOLD,
smoothDuration: 300, smoothDuration: 300,
autoBehavior: 'auto', autoBehavior: 'auto',
disabled: shouldDisableAutoScroll, disabled: shouldDisableAutoScroll,
@ -355,6 +372,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 +740,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 +836,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(${CONTEXT_PANEL_WIDTH_PX}px + 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

@ -269,8 +269,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,
@ -297,15 +296,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',
@ -347,6 +348,18 @@ 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.
Only applied on the leftmost pane in Electron to match the TabBar drag region logic. */}
<div
className="flex-1 self-stretch"
style={
{
WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined,
} 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 id1 = requestAnimationFrame(() => {
id2 = requestAnimationFrame(() => {
// Re-check disabled state — navigation may have started between effect and RAF
if (disabledRef.current) return; if (disabledRef.current) return;
// Only auto-scroll if user was at bottom before the update const shouldScroll = needsInitialScrollRef.current || wasAtBottomBeforeUpdateRef.current;
if (wasAtBottomBeforeUpdateRef.current) { if (shouldScroll) {
needsInitialScrollRef.current = false;
scrollToBottom(autoBehavior); 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]);