|
1 | 1 | import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; |
2 | 2 |
|
3 | | -import { useAutoScrollBottom } from '@renderer/hooks/useAutoScrollBottom'; |
| 3 | +import { isNearBottom, useAutoScrollBottom } from '@renderer/hooks/useAutoScrollBottom'; |
4 | 4 | import { useTabNavigationController } from '@renderer/hooks/useTabNavigationController'; |
5 | 5 | import { useTabUI } from '@renderer/hooks/useTabUI'; |
6 | 6 | import { useVisibleAIGroup } from '@renderer/hooks/useVisibleAIGroup'; |
7 | 7 | import { useStore } from '@renderer/store'; |
8 | 8 | import { useVirtualizer } from '@tanstack/react-virtual'; |
| 9 | +import { ChevronsDown } from 'lucide-react'; |
9 | 10 | import { useShallow } from 'zustand/react/shallow'; |
10 | 11 |
|
11 | 12 | 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 | + |
12 | 19 | import { ChatHistoryEmptyState } from './ChatHistoryEmptyState'; |
13 | 20 | import { ChatHistoryItem } from './ChatHistoryItem'; |
14 | 21 | import { ChatHistoryLoadingState } from './ChatHistoryLoadingState'; |
@@ -343,18 +350,33 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { |
343 | 350 | rootRef: scrollContainerRef, |
344 | 351 | }); |
345 | 352 |
|
| 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 | + |
346 | 363 | // Auto-follow when conversation updates, but only if the user was already near bottom. |
347 | 364 | // This preserves manual reading position when the user scrolls up. |
348 | 365 | // 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, |
351 | 368 | smoothDuration: 300, |
352 | 369 | autoBehavior: 'auto', |
353 | 370 | disabled: shouldDisableAutoScroll, |
354 | 371 | externalRef: scrollContainerRef, |
355 | 372 | resetKey: effectiveTabId, |
356 | 373 | }); |
357 | 374 |
|
| 375 | + // Re-check button visibility whenever conversation updates |
| 376 | + useEffect(() => { |
| 377 | + checkScrollButton(); |
| 378 | + }, [conversation, checkScrollButton]); |
| 379 | + |
358 | 380 | // Callback to register AI group refs (combines with visibility hook) |
359 | 381 | const registerAIGroupRefCombined = useCallback( |
360 | 382 | (groupId: string) => { |
@@ -718,12 +740,13 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { |
718 | 740 | className="flex flex-1 flex-col overflow-hidden" |
719 | 741 | style={{ backgroundColor: 'var(--color-surface)' }} |
720 | 742 | > |
721 | | - <div className="flex flex-1 overflow-hidden"> |
| 743 | + <div className="relative flex flex-1 overflow-hidden"> |
722 | 744 | {/* Chat content */} |
723 | 745 | <div |
724 | 746 | ref={scrollContainerRef} |
725 | 747 | className="flex-1 overflow-y-auto" |
726 | 748 | style={{ backgroundColor: 'var(--color-surface)' }} |
| 749 | + onScroll={checkScrollButton} |
727 | 750 | > |
728 | 751 | {/* Sticky Context button */} |
729 | 752 | {allContextInjections.length > 0 && ( |
@@ -813,6 +836,30 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { |
813 | 836 | </div> |
814 | 837 | </div> |
815 | 838 |
|
| 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 | + |
816 | 863 | {/* Context panel sidebar */} |
817 | 864 | {isContextPanelVisible && allContextInjections.length > 0 && ( |
818 | 865 | <div className="w-80 shrink-0"> |
|
0 commit comments