Skip to content

Commit 9629c0a

Browse files
authored
Merge pull request #69 from proxikal/fix/window-drag-region
fix: reliable window drag region in tab bar
2 parents a95f107 + dbdfc27 commit 9629c0a

4 files changed

Lines changed: 97 additions & 21 deletions

File tree

src/renderer/components/chat/ChatHistory.tsx

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
22

3-
import { useAutoScrollBottom } from '@renderer/hooks/useAutoScrollBottom';
3+
import { isNearBottom, useAutoScrollBottom } from '@renderer/hooks/useAutoScrollBottom';
44
import { useTabNavigationController } from '@renderer/hooks/useTabNavigationController';
55
import { useTabUI } from '@renderer/hooks/useTabUI';
66
import { useVisibleAIGroup } from '@renderer/hooks/useVisibleAIGroup';
77
import { useStore } from '@renderer/store';
88
import { useVirtualizer } from '@tanstack/react-virtual';
9+
import { ChevronsDown } from 'lucide-react';
910
import { useShallow } from 'zustand/react/shallow';
1011

1112
import { SessionContextPanel } from './SessionContextPanel/index';
13+
14+
/** Pixels from bottom considered "near bottom" for scroll-button visibility and auto-scroll. */
15+
const SCROLL_THRESHOLD = 300;
16+
/** Must match the `w-80` (320px) context panel width used in the layout below. */
17+
const CONTEXT_PANEL_WIDTH_PX = 320;
18+
1219
import { ChatHistoryEmptyState } from './ChatHistoryEmptyState';
1320
import { ChatHistoryItem } from './ChatHistoryItem';
1421
import { ChatHistoryLoadingState } from './ChatHistoryLoadingState';
@@ -343,18 +350,33 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
343350
rootRef: scrollContainerRef,
344351
});
345352

353+
// Scroll-to-bottom button visibility
354+
const [showScrollButton, setShowScrollButton] = useState(false);
355+
356+
const checkScrollButton = useCallback(() => {
357+
const container = scrollContainerRef.current;
358+
if (!container) return;
359+
const { scrollTop, scrollHeight, clientHeight } = container;
360+
setShowScrollButton(!isNearBottom(scrollTop, scrollHeight, clientHeight, SCROLL_THRESHOLD));
361+
}, []);
362+
346363
// Auto-follow when conversation updates, but only if the user was already near bottom.
347364
// This preserves manual reading position when the user scrolls up.
348365
// Disabled during navigation to prevent conflicts with deep-link/search scrolling.
349-
useAutoScrollBottom([conversation], {
350-
threshold: 150,
366+
const { scrollToBottom } = useAutoScrollBottom([conversation], {
367+
threshold: SCROLL_THRESHOLD,
351368
smoothDuration: 300,
352369
autoBehavior: 'auto',
353370
disabled: shouldDisableAutoScroll,
354371
externalRef: scrollContainerRef,
355372
resetKey: effectiveTabId,
356373
});
357374

375+
// Re-check button visibility whenever conversation updates
376+
useEffect(() => {
377+
checkScrollButton();
378+
}, [conversation, checkScrollButton]);
379+
358380
// Callback to register AI group refs (combines with visibility hook)
359381
const registerAIGroupRefCombined = useCallback(
360382
(groupId: string) => {
@@ -718,12 +740,13 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
718740
className="flex flex-1 flex-col overflow-hidden"
719741
style={{ backgroundColor: 'var(--color-surface)' }}
720742
>
721-
<div className="flex flex-1 overflow-hidden">
743+
<div className="relative flex flex-1 overflow-hidden">
722744
{/* Chat content */}
723745
<div
724746
ref={scrollContainerRef}
725747
className="flex-1 overflow-y-auto"
726748
style={{ backgroundColor: 'var(--color-surface)' }}
749+
onScroll={checkScrollButton}
727750
>
728751
{/* Sticky Context button */}
729752
{allContextInjections.length > 0 && (
@@ -813,6 +836,30 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
813836
</div>
814837
</div>
815838

839+
{/* Scroll to bottom button */}
840+
{showScrollButton && (
841+
<button
842+
onClick={() => {
843+
scrollToBottom('smooth');
844+
setShowScrollButton(false);
845+
}}
846+
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"
847+
style={{
848+
right:
849+
isContextPanelVisible && allContextInjections.length > 0
850+
? `calc(${CONTEXT_PANEL_WIDTH_PX}px + 1rem)`
851+
: '1rem',
852+
backgroundColor: 'var(--context-btn-bg)',
853+
color: 'var(--color-text-secondary)',
854+
border: '1px solid var(--color-border-emphasis)',
855+
}}
856+
title="Scroll to bottom"
857+
>
858+
<ChevronsDown className="size-3.5" />
859+
<span>Bottom</span>
860+
</button>
861+
)}
862+
816863
{/* Context panel sidebar */}
817864
{isContextPanelVisible && allContextInjections.length > 0 && (
818865
<div className="w-80 shrink-0">

src/renderer/components/layout/SortableTab.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ export const SortableTab = ({
6060
},
6161
});
6262

63-
const style: React.CSSProperties = {
63+
const style = {
64+
WebkitAppRegion: 'no-drag',
6465
transform: CSS.Transform.toString(transform),
6566
transition: isDragging ? 'none' : transition,
6667
opacity: isDragging ? 0.3 : 1,

src/renderer/components/layout/TabBar.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -269,8 +269,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
269269
sidebarCollapsed && isLeftmostPane
270270
? 'var(--macos-traffic-light-padding-left, 72px)'
271271
: '8px',
272-
WebkitAppRegion:
273-
isElectronMode() && sidebarCollapsed && isLeftmostPane ? 'drag' : undefined,
272+
WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined,
274273
backgroundColor: 'var(--color-surface)',
275274
borderBottom: '1px solid var(--color-border)',
276275
opacity: isFocused || paneCount === 1 ? 1 : 0.7,
@@ -297,15 +296,17 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
297296
</button>
298297
)}
299298

300-
{/* Tab list with horizontal scroll, sortable DnD, and droppable area */}
299+
{/* Tab list with horizontal scroll, sortable DnD, and droppable area.
300+
Capped at 75% so the drag spacer always has room to the right. */}
301301
<div
302302
ref={(el) => {
303303
scrollContainerRef.current = el;
304304
setDroppableRef(el);
305305
}}
306-
className="scrollbar-none flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"
306+
className="scrollbar-none flex min-w-0 shrink items-center gap-1 overflow-x-auto"
307307
style={
308308
{
309+
maxWidth: '75%',
309310
WebkitAppRegion: 'no-drag',
310311
outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none',
311312
outlineOffset: '-1px',
@@ -347,6 +348,18 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
347348
)}
348349
</div>
349350

351+
{/* Drag spacer — fills empty space between tab list and action buttons.
352+
Gives users a reliable window-drag target regardless of how many tabs are open.
353+
Only applied on the leftmost pane in Electron to match the TabBar drag region logic. */}
354+
<div
355+
className="flex-1 self-stretch"
356+
style={
357+
{
358+
WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined,
359+
} as React.CSSProperties
360+
}
361+
/>
362+
350363
{/* Right side actions */}
351364
<div
352365
className="ml-2 flex shrink-0 items-center gap-1"

src/renderer/hooks/useAutoScrollBottom.ts

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ export function useAutoScrollBottom(
138138
const disabledRef = useRef(disabled);
139139
// Track resetKey to detect changes
140140
const prevResetKeyRef = useRef(resetKey);
141+
// Set true when resetKey changes; consumed by the content effect to force scroll on first load
142+
const needsInitialScrollRef = useRef(false);
141143

142144
/**
143145
* Check if the scroll container is at the bottom.
@@ -223,34 +225,47 @@ export function useAutoScrollBottom(
223225
disabledRef.current = disabled;
224226
}, [disabled]);
225227

226-
// Reset isAtBottom state when resetKey changes (e.g., tab/session switch)
227-
// This ensures new content will auto-scroll to bottom
228+
// Reset isAtBottom state when resetKey changes (e.g., tab/session switch).
229+
// Sets needsInitialScrollRef so the content effect scrolls to bottom on first load.
228230
useEffect(() => {
229231
if (resetKey !== prevResetKeyRef.current) {
230232
isAtBottomRef.current = true;
231233
wasAtBottomBeforeUpdateRef.current = true;
232234
prevResetKeyRef.current = resetKey;
235+
needsInitialScrollRef.current = true;
233236
}
234237
}, [resetKey]);
235238

236239
/**
237-
* After content updates (dependencies change), scroll to bottom if we were at bottom.
240+
* After content updates (dependencies change), scroll to bottom if:
241+
* - User was already near the bottom before the update, OR
242+
* - This is the first load after a tab/session switch (needsInitialScrollRef)
243+
* Uses double-RAF + cleanup so React StrictMode's double-invoke doesn't fire twice.
238244
*/
239245
useEffect(() => {
240246
// Skip if disabled (e.g., during navigation) or not enabled
241247
if (!enabled || disabled) return;
242248

243-
// Use requestAnimationFrame to ensure DOM has updated
244-
requestAnimationFrame(() => {
245-
// Re-check disabled state inside RAF - it might have changed between effect and callback
246-
// This prevents auto-scroll from firing if navigation started after the effect ran
247-
if (disabledRef.current) return;
249+
let id1 = 0;
250+
let id2 = 0;
248251

249-
// Only auto-scroll if user was at bottom before the update
250-
if (wasAtBottomBeforeUpdateRef.current) {
251-
scrollToBottom(autoBehavior);
252-
}
252+
id1 = requestAnimationFrame(() => {
253+
id2 = requestAnimationFrame(() => {
254+
// Re-check disabled state — navigation may have started between effect and RAF
255+
if (disabledRef.current) return;
256+
257+
const shouldScroll = needsInitialScrollRef.current || wasAtBottomBeforeUpdateRef.current;
258+
if (shouldScroll) {
259+
needsInitialScrollRef.current = false;
260+
scrollToBottom(autoBehavior);
261+
}
262+
});
253263
});
264+
265+
return () => {
266+
cancelAnimationFrame(id1);
267+
cancelAnimationFrame(id2);
268+
};
254269
// eslint-disable-next-line react-hooks/exhaustive-deps -- Dynamic dependencies array is intentional design
255270
}, [...dependencies, enabled, disabled, autoBehavior, scrollToBottom]);
256271

0 commit comments

Comments
 (0)