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 { 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';
/** 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 { ChatHistoryItem } from './ChatHistoryItem';
import { ChatHistoryLoadingState } from './ChatHistoryLoadingState';
@ -343,11 +350,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, SCROLL_THRESHOLD));
}, []);
// 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: SCROLL_THRESHOLD,
smoothDuration: 300,
autoBehavior: 'auto',
disabled: shouldDisableAutoScroll,
@ -355,6 +372,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 +740,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 +836,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(${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 */}
{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

@ -269,8 +269,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,
@ -297,15 +296,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',
@ -347,6 +348,18 @@ 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.
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 */}
<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]);