diff --git a/src/App.jsx b/src/App.jsx index 3a994f4..adad4c4 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,5 @@ // /Users/giers/Heimgeist/src/App.jsx -import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' import { flushSync } from 'react-dom'; import TextareaAutosize from 'react-textarea-autosize'; import AssistantMessageContent from './AssistantMessageContent' @@ -22,11 +22,9 @@ import { readFileAsDataUrl, } from './attachments' import { - BOTTOM_EPSILON, DEFAULT_BACKEND_API_URL, MAX_IMAGE_ATTACHMENT_BYTES, MAX_IMAGE_ATTACHMENTS, - TOP_ALIGN_OFFSET, WEBSEARCH_ENGINES_KEY, WEBSEARCH_URL_KEY, migrateLegacySearxUrl, @@ -46,6 +44,7 @@ import { } from './websearchEngines' import { formatRecordingDuration, useAudioInput } from './useAudioInput' import { useChatLibrarySelection } from './useChatLibrarySelection' +import { useChatScroll } from './useChatScroll' export default function App() { const [chatSessions, setChatSessions] = useState([]) diff --git a/src/useChatScroll.js b/src/useChatScroll.js new file mode 100644 index 0000000..fc52b9d --- /dev/null +++ b/src/useChatScroll.js @@ -0,0 +1,248 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { BOTTOM_EPSILON, TOP_ALIGN_OFFSET } from './appConfig' + +export function useChatScroll({ + activeSessionId, + activeSidebarMode, + chatRef, + messagesLength, +}) { + const [scrollPositions, setScrollPositions] = useState({}) + const [userScrolledUpState, setUserScrolledUpState] = useState({}) + const userScrolledUpRef = useRef({}) + const [pendingScrollToLastUser, setPendingScrollToLastUser] = useState({}) + const scrollTopsRef = useRef({}) + const prevScrollTopsRef = useRef({}) + const [newMsgTip, setNewMsgTip] = useState({}) + const restoredForRef = useRef(null) + const activeSessionIdRef = useRef(activeSessionId) + + useEffect(() => { + activeSessionIdRef.current = activeSessionId + }, [activeSessionId]) + + const setUserScrolledUp = useCallback((sessionId, value) => { + setUserScrolledUpState(prev => { + const next = { ...prev, [sessionId]: value } + userScrolledUpRef.current = next + return next + }) + }, []) + + useEffect(() => { + const leavingSessionId = activeSessionId + const leavingMode = activeSidebarMode + + return () => { + if (leavingMode === 'chats' && leavingSessionId) { + const top = typeof scrollTopsRef.current[leavingSessionId] === 'number' + ? scrollTopsRef.current[leavingSessionId] + : (chatRef.current ? chatRef.current.scrollTop : 0) + + setScrollPositions(prev => { + const updated = { ...prev, [leavingSessionId]: top } + window.electronAPI.updateSettings({ scrollPositions: updated }) + return updated + }) + } + } + }, [activeSessionId, activeSidebarMode, chatRef]) + + useEffect(() => { + const chatDiv = chatRef.current + if (!chatDiv) return + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = chatDiv + const isAtBottom = (scrollHeight - scrollTop - clientHeight) <= BOTTOM_EPSILON + + if (activeSessionId) { + const prevScrollTop = prevScrollTopsRef.current[activeSessionId] + const scrolledUp = typeof prevScrollTop === 'number' && scrollTop < prevScrollTop + + scrollTopsRef.current[activeSessionId] = scrollTop + + if (isAtBottom) { + setUserScrolledUp(activeSessionId, false) + } else if (scrolledUp) { + setUserScrolledUp(activeSessionId, true) + } + prevScrollTopsRef.current[activeSessionId] = scrollTop + } + } + + chatDiv.addEventListener('scroll', handleScroll) + return () => chatDiv.removeEventListener('scroll', handleScroll) + }, [activeSessionId, chatRef, setUserScrolledUp]) + + useEffect(() => { + const sid = activeSessionId + if (!sid) return + if (userScrolledUpState[sid] === false) { + setNewMsgTip(prev => { + if (!(sid in prev)) return prev + const rest = { ...prev } + delete rest[sid] + return rest + }) + } + }, [activeSessionId, userScrolledUpState]) + + useLayoutEffect(() => { + if (activeSidebarMode !== 'chats' || !activeSessionId) return + + const div = chatRef.current + if (!div) return + + restoredForRef.current = null + + const applyRestore = () => { + if (restoredForRef.current === activeSessionId) return + + const liveSaved = typeof scrollTopsRef.current[activeSessionId] === 'number' + ? scrollTopsRef.current[activeSessionId] + : undefined + const saved = typeof liveSaved === 'number' + ? liveSaved + : scrollPositions[activeSessionId] + + if (typeof saved === 'number') { + div.scrollTop = saved + restoredForRef.current = activeSessionId + return + } + if (messagesLength > 0) { + div.scrollTop = div.scrollHeight + restoredForRef.current = activeSessionId + } + } + + applyRestore() + const r0 = requestAnimationFrame(applyRestore) + + const onDomChange = () => { + if (restoredForRef.current !== activeSessionId) { + requestAnimationFrame(applyRestore) + } + } + + const mo = new MutationObserver(onDomChange) + mo.observe(div, { childList: true, subtree: true }) + + const ro = new ResizeObserver(onDomChange) + ro.observe(div) + + return () => { + cancelAnimationFrame(r0) + mo.disconnect() + ro.disconnect() + } + }, [activeSessionId, activeSidebarMode, chatRef, messagesLength, scrollPositions]) + + useEffect(() => { + if (activeSidebarMode !== 'chats' || !activeSessionId) return + if (restoredForRef.current === activeSessionId) return + + const liveSaved = typeof scrollTopsRef.current[activeSessionId] === 'number' + ? scrollTopsRef.current[activeSessionId] + : undefined + const savedScrollTop = typeof liveSaved === 'number' + ? liveSaved + : scrollPositions[activeSessionId] + + if (typeof savedScrollTop !== 'number' && messagesLength > 0) { + requestAnimationFrame(() => { + const div = chatRef.current + if (!div) return + div.scrollTop = div.scrollHeight + restoredForRef.current = activeSessionId + }) + } + }, [messagesLength, activeSessionId, activeSidebarMode, chatRef, scrollPositions]) + + const scrollToBottom = useCallback((behavior = 'smooth', sessionId = null) => { + const chatDiv = chatRef.current + if (!chatDiv) return + const target = sessionId ?? activeSessionIdRef.current + if (activeSessionIdRef.current !== target) return + chatDiv.scrollTo({ top: chatDiv.scrollHeight, behavior }) + setUserScrolledUp(target, false) + }, [chatRef, setUserScrolledUp]) + + const scrollMessageToTop = useCallback((msgId, behavior = 'auto', sessionId = null) => { + const chatDiv = chatRef.current + if (!chatDiv) return + const target = sessionId ?? activeSessionIdRef.current + if (activeSessionIdRef.current !== target) return + const el = document.getElementById(msgId) + if (el) { + const top = Math.max(0, el.offsetTop - TOP_ALIGN_OFFSET) + chatDiv.scrollTo({ top, behavior }) + } + }, [chatRef]) + + const handleNewMsgTipClick = useCallback(() => { + const sid = activeSessionIdRef.current + const msgId = newMsgTip[sid] + if (msgId) { + scrollMessageToTop(msgId, 'smooth', sid) + setNewMsgTip(prev => { + const { [sid]: _omit, ...rest } = prev + return rest + }) + } + }, [newMsgTip, scrollMessageToTop]) + + const scrollPendingMessageForSession = useCallback((sessionId, chatSessions) => { + const pendingId = pendingScrollToLastUser[sessionId] + if (!pendingId) return + + requestAnimationFrame(() => { + let tries = 12 + const attempt = () => { + const chatDiv = chatRef.current + if (!chatDiv) return + + let el = document.getElementById(pendingId) + if (!el) { + const sess = chatSessions.find(s => s.session_id === sessionId) + if (sess && Array.isArray(sess.messages)) { + for (let i = sess.messages.length - 1; i >= 0; i--) { + const m = sess.messages[i] + if (m.role === 'assistant' && m.id) { + el = document.getElementById(m.id) + break + } + } + } + } + + if (el) { + scrollMessageToTop(el.id, 'smooth', sessionId) + setPendingScrollToLastUser(prev => { + const { [sessionId]: _omit, ...rest } = prev + return rest + }) + } else if (tries-- > 0) { + requestAnimationFrame(attempt) + } + } + requestAnimationFrame(attempt) + }) + }, [chatRef, pendingScrollToLastUser, scrollMessageToTop]) + + return { + activeSessionIdRef, + handleNewMsgTipClick, + newMsgTip, + restoredForRef, + scrollMessageToTop, + scrollPendingMessageForSession, + scrollToBottom, + setNewMsgTip, + setPendingScrollToLastUser, + setScrollPositions, + setUserScrolledUp, + userScrolledUpRef, + } +}