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:
parent
9c04e90fdd
commit
890d2d8e84
4 changed files with 86 additions and 21 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue