diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index a6a4acbe..3fa7a2a7 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -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)' }} > -
+
{/* Chat content */}
{/* Sticky Context button */} {allContextInjections.length > 0 && ( @@ -813,6 +830,30 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
+ {/* Scroll to bottom button */} + {showScrollButton && ( + + )} + {/* Context panel sidebar */} {isContextPanelVisible && allContextInjections.length > 0 && (
diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index 1a9758c4..472c617c 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -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, diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 61915d35..2fc3b8a5 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -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 => { )} - {/* 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. */}
{ 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 => { )}
+ {/* 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. */} +
+ {/* Right side actions */}
{ 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]);