From 4ed42d47f4bfc3a1f130102170585a2676472714 Mon Sep 17 00:00:00 2001 From: bourgois Date: Wed, 10 Jun 2026 13:46:31 +0200 Subject: [PATCH 1/2] feat(chat): add scroll navigation for chat messages Adds a slim navigation rail to the chat message pane: a marker per user message (the active marker tracks the viewport as you scroll), plus jump-to-top/bottom, previous/next-message, and a load-all control for paginated history. Helps move through long conversations without free-scrolling. Scoped to the scroll-navigation feature only. Two unrelated fixes that previously rode along on #811 are split into separate PRs (the task-notification regex fix and the pending-message render race fix). Co-authored-by: WenhuaXia --- .../chat/hooks/useChatSessionState.ts | 90 ++--- src/components/chat/view/ChatInterface.tsx | 20 +- .../view/subcomponents/ChatMessagesPane.tsx | 64 ++- .../view/subcomponents/ScrollNavigation.tsx | 363 ++++++++++++++++++ src/i18n/locales/de/chat.json | 9 + src/i18n/locales/en/chat.json | 12 + src/i18n/locales/it/chat.json | 9 + src/i18n/locales/ja/chat.json | 9 + src/i18n/locales/ko/chat.json | 9 + src/i18n/locales/ru/chat.json | 9 + src/i18n/locales/tr/chat.json | 9 + src/i18n/locales/zh-CN/chat.json | 12 + 12 files changed, 512 insertions(+), 103 deletions(-) create mode 100644 src/components/chat/view/subcomponents/ScrollNavigation.tsx diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index d11ff3cb43..fcd14d99a2 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -344,24 +344,13 @@ export function useChatSessionState({ [hasMoreMessages, isLoadingMoreMessages, selectedProject, selectedSession, sessionStore], ); - const handleScroll = useCallback(async () => { + const handleScroll = useCallback(() => { const container = scrollContainerRef.current; if (!container) return; const nearBottom = isNearBottom(); setIsUserScrolledUp(!nearBottom); - - if (!allMessagesLoadedRef.current) { - const scrolledNearTop = container.scrollTop < 100; - if (!scrolledNearTop) { topLoadLockRef.current = false; return; } - if (topLoadLockRef.current) { - if (container.scrollTop > 20) topLoadLockRef.current = false; - return; - } - const didLoad = await loadOlderMessages(container); - if (didLoad) topLoadLockRef.current = true; - } - }, [isNearBottom, loadOlderMessages]); + }, [isNearBottom]); useLayoutEffect(() => { if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return; @@ -383,47 +372,12 @@ export function useChatSessionState({ setIsUserScrolledUp(false); }, [selectedProject?.projectId, selectedSession?.id]); - // Initial scroll to bottom — robust to lazy content reflow. - // The previous implementation fired one scrollToBottom() at +200ms and - // cleared the pending flag. When markdown blocks, code highlighting, or - // images finished rendering after that window, scrollHeight grew but - // nothing re-anchored the viewport, leaving the chat tab visually - // "scrolled way up" with the latest assistant message off-screen. - // - // This version re-scrolls every animation frame while scrollHeight is - // still growing, capped at ~1s (60 frames) or 3 consecutive stable - // frames. Cancels cleanly on session change via the pending flag. + // Initial scroll to bottom useEffect(() => { if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) return; if (chatMessages.length === 0) { pendingInitialScrollRef.current = false; return; } - if (searchScrollActiveRef.current) { pendingInitialScrollRef.current = false; return; } - - const container = scrollContainerRef.current; - let frame = 0; - let lastHeight = 0; - let stableCount = 0; - let rafId = 0; - - const tick = () => { - if (!pendingInitialScrollRef.current || !scrollContainerRef.current) return; - container.scrollTop = container.scrollHeight; - if (container.scrollHeight === lastHeight) { - stableCount++; - } else { - stableCount = 0; - lastHeight = container.scrollHeight; - } - frame++; - if (stableCount < 3 && frame < 60) { - rafId = requestAnimationFrame(tick); - } else { - pendingInitialScrollRef.current = false; - } - }; - rafId = requestAnimationFrame(tick); - return () => { - if (rafId) cancelAnimationFrame(rafId); - }; + pendingInitialScrollRef.current = false; + if (!searchScrollActiveRef.current) setTimeout(() => scrollToBottom(), 200); }, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]); // Main session loading effect — store-based @@ -736,23 +690,10 @@ export function useChatSessionState({ } }, [currentSessionId, isLoading, processingSessions, selectedSession?.id]); - // "Load all" overlay - const prevLoadingRef = useRef(false); + // "Load more" buttons — show whenever there are more messages useEffect(() => { - const wasLoading = prevLoadingRef.current; - prevLoadingRef.current = isLoadingMoreMessages; - - if (wasLoading && !isLoadingMoreMessages && hasMoreMessages) { - if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); - setShowLoadAllOverlay(true); - loadAllOverlayTimerRef.current = setTimeout(() => setShowLoadAllOverlay(false), 2000); - } - if (!hasMoreMessages && !isLoadingMoreMessages) { - if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); - setShowLoadAllOverlay(false); - } - return () => { if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); }; - }, [isLoadingMoreMessages, hasMoreMessages]); + setShowLoadAllOverlay(hasMoreMessages && !allMessagesLoaded); + }, [hasMoreMessages, allMessagesLoaded]); const loadAllMessages = useCallback(async () => { if (!selectedSession || !selectedProject) return; @@ -812,6 +753,20 @@ export function useChatSessionState({ setVisibleMessageCount((prev) => prev + 100); }, []); + const loadMoreMessages = useCallback(async () => { + topLoadLockRef.current = false; + const container = scrollContainerRef.current; + if (!container) return; + setIsLoadingMoreMessages(true); + try { + await loadOlderMessages(container); + } catch (error) { + console.error('[useChatSessionState] loadMoreMessages failed:', error); + } finally { + setIsLoadingMoreMessages(false); + } + }, [loadOlderMessages]); + return { chatMessages, addMessage, @@ -835,6 +790,7 @@ export function useChatSessionState({ visibleMessages, loadEarlierMessages, loadAllMessages, + loadMoreMessages, allMessagesLoaded, isLoadingAllMessages, loadAllJustFinished, diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 01ecb68a54..69052cdef1 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -15,6 +15,7 @@ import { useSessionStore } from '../../../stores/useSessionStore'; import ChatMessagesPane from './subcomponents/ChatMessagesPane'; import ChatComposer from './subcomponents/ChatComposer'; import CommandResultModal from './subcomponents/CommandResultModal'; +import ScrollNavigation from './subcomponents/ScrollNavigation'; type PendingViewSession = { @@ -110,10 +111,10 @@ function ChatInterface({ visibleMessages, loadEarlierMessages, loadAllMessages, + loadMoreMessages, allMessagesLoaded, isLoadingAllMessages, loadAllJustFinished, - showLoadAllOverlay, claudeStatus, setClaudeStatus, createDiff, @@ -309,7 +310,18 @@ function ChatInterface({ return (
- + +
+ +
+
void; loadAllMessages: () => void; + loadMoreMessages: () => void; allMessagesLoaded: boolean; isLoadingAllMessages: boolean; loadAllJustFinished: boolean; - showLoadAllOverlay: boolean; createDiff: any; onFileOpen?: (filePath: string, diffInfo?: unknown) => void; onShowSettings?: () => void; @@ -98,10 +98,10 @@ export default function ChatMessagesPane({ visibleMessages, loadEarlierMessages, loadAllMessages, + loadMoreMessages, allMessagesLoaded, isLoadingAllMessages, loadAllJustFinished, - showLoadAllOverlay, createDiff, onFileOpen, onShowSettings, @@ -145,7 +145,7 @@ export default function ChatMessagesPane({ ref={scrollContainerRef} onWheel={onWheel} onTouchMove={onTouchMove} - className="relative flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4" + className="relative h-full space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4" > {isLoadingSessionMessages && chatMessages.length === 0 ? (
@@ -180,55 +180,53 @@ export default function ChatMessagesPane({ /> ) : ( <> - {/* Loading indicator for older messages (hide when load-all is active) */} - {isLoadingMoreMessages && !isLoadingAllMessages && !allMessagesLoaded && ( + {/* Loading indicator for older messages */} + {(isLoadingMoreMessages || isLoadingAllMessages) && !allMessagesLoaded && (
-

{t('session.loading.olderMessages')}

+

{isLoadingAllMessages ? t('session.messages.loadingAll') : t('session.loading.olderMessages')}

)} - {/* Indicator showing there are more messages to load (hide when all loaded) */} - {hasMoreMessages && !isLoadingMoreMessages && !allMessagesLoaded && ( + {/* "Load more" buttons — show when there are more messages */} + {hasMoreMessages && !isLoadingMoreMessages && !isLoadingAllMessages && !allMessagesLoaded && (
{totalMessages > 0 && ( {t('session.messages.showingOf', { shown: sessionMessagesCount, total: totalMessages })}{' '} - {t('session.messages.scrollToLoad')} )} -
- )} - - {/* Floating "Load all messages" overlay */} - {(showLoadAllOverlay || isLoadingAllMessages || loadAllJustFinished) && ( -
- {loadAllJustFinished ? ( -
+
+
- ) : ( + {t('session.messages.loadMore', { count: 20 })} + - )} +
+
+ )} + + {/* "All loaded" success indicator */} + {loadAllJustFinished && ( +
+
+ + + + {t('session.messages.allLoaded')} +
)} diff --git a/src/components/chat/view/subcomponents/ScrollNavigation.tsx b/src/components/chat/view/subcomponents/ScrollNavigation.tsx new file mode 100644 index 0000000000..4d02755ee7 --- /dev/null +++ b/src/components/chat/view/subcomponents/ScrollNavigation.tsx @@ -0,0 +1,363 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { ChatMessage } from '../../types/types'; +import Tooltip from '../../../../shared/view/ui/Tooltip'; + +interface ScrollNavigationProps { + scrollContainerRef: React.RefObject; + chatMessages: ChatMessage[]; + loadAllMessages?: () => void; + allMessagesLoaded?: boolean; + hasMoreMessages?: boolean; + totalMessages?: number; + sessionMessagesCount?: number; +} + +function truncateSnippet(content: string): string { + return (content || '') + .replace(/\n/g, ' ') + .trim() + .slice(0, 60) + ((content || '').length > 60 ? '...' : ''); +} + +function ArrowUpIcon() { + return ( + + + + ); +} + +function ArrowDownIcon() { + return ( + + + + ); +} + +function TopIcon() { + return ( + + + + + ); +} + +function BottomIcon() { + return ( + + + + + ); +} + +function LoadAllIcon() { + return ( + + + + ); +} + +/** Vertical scroll navigation strip with dots, quick-jump buttons, and load-more controls. */ +/** + * Floating navigation control for the chat message pane. + * + * Renders a vertical rail of user-message markers plus jump-to-top/bottom, + * previous/next, and load-all controls. Marker positions are derived from the + * live DOM offsets of each user message inside `scrollContainerRef`, so the + * active dot tracks the viewport as the user scrolls or content reflows. + */ +export default function ScrollNavigation({ + scrollContainerRef, + chatMessages, + loadAllMessages, + allMessagesLoaded = true, + hasMoreMessages = false, + totalMessages = 0, + sessionMessagesCount = 0, +}: ScrollNavigationProps) { + const { t } = useTranslation('chat'); + const [activeDotIndex, setActiveDotIndex] = useState(-1); + const [isStripHovered, setIsStripHovered] = useState(false); + const rafIdRef = useRef(null); + const mountedRef = useRef(true); + const skipUpdateRef = useRef(false); + + const userMessages = useMemo( + () => chatMessages.filter((m) => m.type === 'user'), + [chatMessages], + ); + + const shouldShow = chatMessages.length >= 1; + const hasMore = hasMoreMessages; + + const scheduleUpdate = useCallback(() => { + if (rafIdRef.current != null) return; + rafIdRef.current = requestAnimationFrame(() => { + rafIdRef.current = null; + if (skipUpdateRef.current) { + skipUpdateRef.current = false; + return; + } + const container = scrollContainerRef.current; + if (!container) return; + + const { scrollTop, scrollHeight, clientHeight } = container; + const maxScroll = scrollHeight - clientHeight; + if (maxScroll <= 0) { + setActiveDotIndex(userMessages.length > 0 ? userMessages.length - 1 : -1); + return; + } + + const totalUserMessages = userMessages.length; + if (totalUserMessages <= 1) { + setActiveDotIndex(0); + return; + } + + // Use DOM element positions instead of scroll ratio — + // messages have varying heights so ratio-based indexing drifts. + const elements = container.querySelectorAll('.chat-message.user'); + const viewportCenter = scrollTop + clientHeight / 2; + let activeIdx = elements.length - 1; + for (let i = 0; i < elements.length; i++) { + const top = elements[i].getBoundingClientRect().top - container.getBoundingClientRect().top + scrollTop; + if (top <= viewportCenter) { + activeIdx = i; + } else { + break; + } + } + if (mountedRef.current) setActiveDotIndex(activeIdx); + }); + }, [scrollContainerRef, userMessages.length]); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + container.addEventListener('scroll', scheduleUpdate, { passive: true }); + return () => container.removeEventListener('scroll', scheduleUpdate); + }, [scrollContainerRef, scheduleUpdate]); + + useEffect(() => { + const timer = setTimeout(() => scheduleUpdate(), 200); + return () => clearTimeout(timer); + }, [chatMessages.length, scheduleUpdate]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + if (rafIdRef.current != null) cancelAnimationFrame(rafIdRef.current); + }; + }, []); + + const scrollToDot = useCallback( + (index: number) => { + setActiveDotIndex(index); + skipUpdateRef.current = true; + const container = scrollContainerRef.current; + if (!container) return; + + const elements = container.querySelectorAll('.chat-message.user'); + if (elements.length > index) { + elements[index].scrollIntoView({ block: 'center', behavior: 'instant' }); + return; + } + + const totalUserMessages = userMessages.length; + if (totalUserMessages <= 1) return; + + const targetRatio = index / (totalUserMessages - 1); + const maxScroll = container.scrollHeight - container.clientHeight; + container.scrollTop = targetRatio * maxScroll; + }, + [scrollContainerRef, userMessages.length], + ); + + const scrollToTop = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) return; + + container.scrollTop = 0; + }, [scrollContainerRef]); + + const scrollToBottom = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) return; + + container.scrollTop = container.scrollHeight; + }, [scrollContainerRef]); + + const scrollPrev = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) return; + + const elements = container.querySelectorAll('.chat-message.user'); + if (elements.length === 0) return; + + const { scrollTop, clientHeight } = container; + const viewportCenter = scrollTop + clientHeight / 2; + + let currentIndex = 0; + for (let i = 0; i < elements.length; i++) { + const top = elements[i].getBoundingClientRect().top - container.getBoundingClientRect().top + scrollTop; + if (top <= viewportCenter) { + currentIndex = i; + } else { + break; + } + } + + const targetIndex = Math.max(0, currentIndex - 1); + elements[targetIndex].scrollIntoView({ block: 'center', behavior: 'instant' }); + }, [scrollContainerRef]); + + const scrollNext = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) return; + + const elements = container.querySelectorAll('.chat-message.user'); + if (elements.length === 0) return; + + const { scrollTop, clientHeight } = container; + const viewportCenter = scrollTop + clientHeight / 2; + + let currentIndex = elements.length - 1; + for (let i = elements.length - 1; i >= 0; i--) { + const top = elements[i].getBoundingClientRect().top - container.getBoundingClientRect().top + scrollTop; + if (top <= viewportCenter) { + currentIndex = i; + break; + } + } + + const targetIndex = Math.min(elements.length - 1, currentIndex + 1); + elements[targetIndex].scrollIntoView({ block: 'center', behavior: 'instant' }); + }, [scrollContainerRef]); + + if (!shouldShow) return null; + + const hasUserMessages = userMessages.length > 0; + const navButtons = [ + { + label: t('scrollNav.first'), + icon: , + action: scrollToTop, + disabled: false, + }, + { + label: t('scrollNav.previous'), + icon: , + action: scrollPrev, + disabled: !hasUserMessages, + }, + { + label: t('scrollNav.next'), + icon: , + action: scrollNext, + disabled: !hasUserMessages, + }, + { + label: t('scrollNav.last'), + icon: , + action: scrollToBottom, + disabled: false, + }, + ]; + + return ( +
+
setIsStripHovered(true)} + onMouseLeave={() => setIsStripHovered(false)} + > + {/* Nav buttons section */} +
+ {hasMore && loadAllMessages && ( + + + + )} + + {navButtons.map((btn) => ( + + + + ))} +
+ + {/* Dots section — only render when there are user messages */} + {userMessages.length > 0 && ( + <> + {/* Divider */} +
+ +
+ {userMessages.map((msg, i) => { + const isActive = i === activeDotIndex; + const snippet = truncateSnippet(msg.content || ''); + + return ( + +
+ + )} +
+
+ ); +} diff --git a/src/i18n/locales/de/chat.json b/src/i18n/locales/de/chat.json index f0b10d7a85..37775daaa3 100644 --- a/src/i18n/locales/de/chat.json +++ b/src/i18n/locales/de/chat.json @@ -235,5 +235,14 @@ }, "tasks": { "nextTaskPrompt": "Nächste Aufgabe starten" + }, + "scrollNav": { + "jumpTo": "Springe zu: {{snippet}}", + "jumpToMessage": "Springe zu Nachricht {{index}}", + "loadAll": "Alle laden", + "first": "Erste", + "previous": "Vorherige", + "next": "Nächste", + "last": "Letzte" } } diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index 8d3f4e93e8..1fb78ce284 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -174,6 +174,7 @@ "showingLast": "Showing last {{count}} messages ({{total}} total)", "loadEarlier": "Load earlier messages", "loadAll": "Load all messages", + "loadMore": "Load {{count}} more", "loadingAll": "Loading all messages...", "allLoaded": "All messages loaded", "perfWarning": "All messages loaded — scrolling may be slower. Click \"Scroll to bottom\" to restore performance." @@ -237,5 +238,16 @@ }, "tasks": { "nextTaskPrompt": "Start the next task" + }, + "scrollNav": { + "jumpTo": "Jump to: {{snippet}}", + "jumpToMessage": "Jump to message {{index}}", + "fromYou": "You", + "aiReply": "AI Reply", + "loadAll": "Load all", + "first": "Top", + "previous": "Previous", + "next": "Next", + "last": "Bottom" } } diff --git a/src/i18n/locales/it/chat.json b/src/i18n/locales/it/chat.json index ae845d9a18..489ea9ed08 100644 --- a/src/i18n/locales/it/chat.json +++ b/src/i18n/locales/it/chat.json @@ -235,5 +235,14 @@ }, "tasks": { "nextTaskPrompt": "Inizia l'attività successiva" + }, + "scrollNav": { + "jumpTo": "Vai a: {{snippet}}", + "jumpToMessage": "Vai al messaggio {{index}}", + "loadAll": "Carica tutto", + "first": "Primo", + "previous": "Precedente", + "next": "Successivo", + "last": "Ultimo" } } diff --git a/src/i18n/locales/ja/chat.json b/src/i18n/locales/ja/chat.json index cd20292e8f..e54ad64b0c 100644 --- a/src/i18n/locales/ja/chat.json +++ b/src/i18n/locales/ja/chat.json @@ -202,5 +202,14 @@ "providers": { "assistant": "Assistant" } + }, + "scrollNav": { + "jumpTo": "ジャンプ: {{snippet}}", + "jumpToMessage": "{{index}}番目のメッセージに移動", + "loadAll": "全ロード", + "first": "最初", + "previous": "前へ", + "next": "次へ", + "last": "最後" } } diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json index c9df5c2ec9..057409d85d 100644 --- a/src/i18n/locales/ko/chat.json +++ b/src/i18n/locales/ko/chat.json @@ -217,5 +217,14 @@ }, "tasks": { "nextTaskPrompt": "다음 작업 시작" + }, + "scrollNav": { + "jumpTo": "이동: {{snippet}}", + "jumpToMessage": "{{index}}번 메시지로 이동", + "loadAll": "전체 로드", + "first": "첫 번째", + "previous": "이전", + "next": "다음", + "last": "마지막" } } diff --git a/src/i18n/locales/ru/chat.json b/src/i18n/locales/ru/chat.json index 8d3e9e093c..9d56b3663c 100644 --- a/src/i18n/locales/ru/chat.json +++ b/src/i18n/locales/ru/chat.json @@ -235,5 +235,14 @@ }, "tasks": { "nextTaskPrompt": "Начать следующую задачу" + }, + "scrollNav": { + "jumpTo": "Перейти к: {{snippet}}", + "jumpToMessage": "Перейти к сообщению {{index}}", + "loadAll": "Загрузить всё", + "first": "Первое", + "previous": "Предыдущее", + "next": "Следующее", + "last": "Последнее" } } diff --git a/src/i18n/locales/tr/chat.json b/src/i18n/locales/tr/chat.json index 74ce954807..f47b9a4d2f 100644 --- a/src/i18n/locales/tr/chat.json +++ b/src/i18n/locales/tr/chat.json @@ -235,5 +235,14 @@ }, "tasks": { "nextTaskPrompt": "Sonraki görevi başlat" + }, + "scrollNav": { + "jumpTo": "Git: {{snippet}}", + "jumpToMessage": "{{index}}. mesaja git", + "loadAll": "Tümünü yükle", + "first": "İlk", + "previous": "Önceki", + "next": "Sonraki", + "last": "Son" } } diff --git a/src/i18n/locales/zh-CN/chat.json b/src/i18n/locales/zh-CN/chat.json index 8ebe68d14a..e27adec6f0 100644 --- a/src/i18n/locales/zh-CN/chat.json +++ b/src/i18n/locales/zh-CN/chat.json @@ -154,6 +154,7 @@ "showingLast": "显示最近 {{count}} 条消息(共 {{total}} 条)", "loadEarlier": "加载更早的消息", "loadAll": "加载全部消息", + "loadMore": "加载更多({{count}}条)", "loadingAll": "正在加载全部消息...", "allLoaded": "全部消息已加载", "perfWarning": "已加载全部消息 - 滚动可能变慢。点击「滚动到底部」恢复性能。" @@ -217,5 +218,16 @@ }, "tasks": { "nextTaskPrompt": "开始下一个任务" + }, + "scrollNav": { + "jumpTo": "跳转到: {{snippet}}", + "jumpToMessage": "跳转到第 {{index}} 条消息", + "fromYou": "你", + "aiReply": "AI 回复", + "loadAll": "加载全部", + "first": "顶部", + "previous": "上一条", + "next": "下一条", + "last": "底部" } } From 58c7a7cbce89aeb152d14ba8a0c0b90bfcd49dbd Mon Sep 17 00:00:00 2001 From: bourgois Date: Thu, 11 Jun 2026 21:08:55 +0200 Subject: [PATCH 2/2] feat(chat): make scroll navigation opt-in and clear of the scrollbar Addresses review feedback on the scroll-navigation feature: - Add a "Show scroll navigation" toggle to Quick Settings (View Options), defaulting to OFF. Wired through useUiPreferences (persisted), the quick-settings panel, MainContent, and ChatInterface, which now only renders the rail when the preference is enabled. i18n added for all eight locales. - Offset the rail from the right edge (right-0 -> right-3) so it no longer covers the message pane's native scrollbar. Flush at the edge it sat on top of a classic Windows/Chrome scrollbar, making the bar hard to grab (not visible with macOS overlay scrollbars). Co-authored-by: WenhuaXia --- src/components/chat/types/types.ts | 1 + src/components/chat/view/ChatInterface.tsx | 21 +++++++++++-------- .../view/subcomponents/ScrollNavigation.tsx | 5 ++++- .../main-content/view/MainContent.tsx | 3 ++- .../quick-settings-panel/constants.ts | 6 ++++++ src/components/quick-settings-panel/types.ts | 1 + .../view/QuickSettingsPanelView.tsx | 2 ++ src/hooks/useUiPreferences.ts | 2 ++ src/i18n/locales/de/settings.json | 1 + src/i18n/locales/en/settings.json | 1 + src/i18n/locales/it/settings.json | 1 + src/i18n/locales/ja/settings.json | 1 + src/i18n/locales/ko/settings.json | 1 + src/i18n/locales/ru/settings.json | 1 + src/i18n/locales/tr/settings.json | 1 + src/i18n/locales/zh-CN/settings.json | 1 + 16 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/components/chat/types/types.ts b/src/components/chat/types/types.ts index 474f23e13a..3ed50c6b80 100644 --- a/src/components/chat/types/types.ts +++ b/src/components/chat/types/types.ts @@ -121,6 +121,7 @@ export interface ChatInterfaceProps { showRawParameters?: boolean; showThinking?: boolean; autoScrollToBottom?: boolean; + showScrollNavigation?: boolean; sendByCtrlEnter?: boolean; externalMessageUpdate?: number; newSessionTrigger?: number; diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 69052cdef1..0c14e98899 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -41,6 +41,7 @@ function ChatInterface({ showRawParameters, showThinking, autoScrollToBottom, + showScrollNavigation, sendByCtrlEnter, externalMessageUpdate, newSessionTrigger, @@ -311,15 +312,17 @@ function ChatInterface({
- + {showScrollNavigation && ( + + )}
; diff --git a/src/components/quick-settings-panel/view/QuickSettingsPanelView.tsx b/src/components/quick-settings-panel/view/QuickSettingsPanelView.tsx index 0de1bbc750..76647ed370 100644 --- a/src/components/quick-settings-panel/view/QuickSettingsPanelView.tsx +++ b/src/components/quick-settings-panel/view/QuickSettingsPanelView.tsx @@ -26,10 +26,12 @@ export default function QuickSettingsPanelView() { showRawParameters: preferences.showRawParameters, showThinking: preferences.showThinking, autoScrollToBottom: preferences.autoScrollToBottom, + showScrollNavigation: preferences.showScrollNavigation, sendByCtrlEnter: preferences.sendByCtrlEnter, }), [ preferences.autoExpandTools, preferences.autoScrollToBottom, + preferences.showScrollNavigation, preferences.sendByCtrlEnter, preferences.showRawParameters, preferences.showThinking, diff --git a/src/hooks/useUiPreferences.ts b/src/hooks/useUiPreferences.ts index eb0b833967..4240cb0a1a 100644 --- a/src/hooks/useUiPreferences.ts +++ b/src/hooks/useUiPreferences.ts @@ -5,6 +5,7 @@ type UiPreferences = { showRawParameters: boolean; showThinking: boolean; autoScrollToBottom: boolean; + showScrollNavigation: boolean; sendByCtrlEnter: boolean; sidebarVisible: boolean; }; @@ -37,6 +38,7 @@ const DEFAULTS: UiPreferences = { showRawParameters: false, showThinking: true, autoScrollToBottom: true, + showScrollNavigation: false, sendByCtrlEnter: false, sidebarVisible: true, }; diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json index 237e59507c..529ce4f9c8 100644 --- a/src/i18n/locales/de/settings.json +++ b/src/i18n/locales/de/settings.json @@ -62,6 +62,7 @@ "showRawParameters": "Rohe Parameter anzeigen", "showThinking": "Denken anzeigen", "autoScrollToBottom": "Automatisch nach unten scrollen", + "showScrollNavigation": "Scroll-Navigation anzeigen", "sendByCtrlEnter": "Mit Strg+Enter senden", "sendByCtrlEnterDescription": "Wenn aktiviert, sendet Strg+Enter die Nachricht anstelle von Enter. Dies ist nützlich für IME-Benutzer:innen, um versehentliches Senden zu vermeiden.", "dragHandle": { diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index d5bc790039..e85f88e507 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -62,6 +62,7 @@ "showRawParameters": "Show raw parameters", "showThinking": "Show thinking", "autoScrollToBottom": "Auto-scroll to bottom", + "showScrollNavigation": "Show scroll navigation", "sendByCtrlEnter": "Send by Ctrl+Enter", "sendByCtrlEnterDescription": "When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.", "dragHandle": { diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index d283bdb923..46d2aafa88 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -62,6 +62,7 @@ "showRawParameters": "Mostra parametri grezzi", "showThinking": "Mostra ragionamento", "autoScrollToBottom": "Scorrimento automatico in basso", + "showScrollNavigation": "Mostra navigazione di scorrimento", "sendByCtrlEnter": "Invia con Ctrl+Invio", "sendByCtrlEnterDescription": "Se abilitato, premere Ctrl+Invio invierà il messaggio invece di Invio. Utile per gli utenti IME per evitare invii accidentali.", "dragHandle": { diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index 0ad10c46fb..6af9fab80b 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -62,6 +62,7 @@ "showRawParameters": "生パラメータを表示", "showThinking": "思考を表示", "autoScrollToBottom": "自動スクロール", + "showScrollNavigation": "スクロールナビゲーションを表示", "sendByCtrlEnter": "Ctrl+Enterで送信", "sendByCtrlEnterDescription": "有効にすると、Enterではなく Ctrl+Enter でメッセージを送信します。IMEユーザーの誤送信防止に便利です。", "dragHandle": { diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index 3fd7a28540..1c24b98e79 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -62,6 +62,7 @@ "showRawParameters": "Raw 파라미터 표시", "showThinking": "생각 과정 표시", "autoScrollToBottom": "자동 스크롤", + "showScrollNavigation": "스크롤 탐색 표시", "sendByCtrlEnter": "Ctrl+Enter로 전송", "sendByCtrlEnterDescription": "활성화하면 Enter 대신 Ctrl+Enter로 메시지를 전송합니다. IME 사용자가 실수로 전송하는 것을 방지하는 데 유용합니다.", "dragHandle": { diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 94e88f3772..e61c9015d3 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -62,6 +62,7 @@ "showRawParameters": "Показывать сырые параметры", "showThinking": "Показывать размышления", "autoScrollToBottom": "Автопрокрутка вниз", + "showScrollNavigation": "Показать навигацию прокрутки", "sendByCtrlEnter": "Отправка по Ctrl+Enter", "sendByCtrlEnterDescription": "Когда включено, нажатие Ctrl+Enter будет отправлять сообщение вместо просто Enter. Это полезно для пользователей IME, чтобы избежать случайной отправки.", "dragHandle": { diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 01763542e0..5605012fe3 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -62,6 +62,7 @@ "showRawParameters": "Ham parametreleri göster", "showThinking": "Düşünmeyi göster", "autoScrollToBottom": "Otomatik en alta kaydır", + "showScrollNavigation": "Kaydırma navigasyonunu göster", "sendByCtrlEnter": "Ctrl+Enter ile gönder", "sendByCtrlEnterDescription": "Etkinleştirildiğinde, Ctrl+Enter'a basmak yalnız Enter yerine mesajı gönderir. IME (girdi metot düzenleyici) kullananlar için yanlışlıkla göndermeyi önler.", "dragHandle": { diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 5567695cd4..c152af9b08 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -62,6 +62,7 @@ "showRawParameters": "显示原始参数", "showThinking": "显示思考过程", "autoScrollToBottom": "自动滚动到底部", + "showScrollNavigation": "显示滚动导航", "sendByCtrlEnter": "使用 Ctrl+Enter 发送", "sendByCtrlEnterDescription": "启用后,按 Ctrl+Enter 发送消息,而不是仅按 Enter。这对于使用输入法的用户可以避免意外发送。", "dragHandle": {