auto-git:

[add] src/useChatScroll.js
 [change] src/App.jsx
This commit is contained in:
2026-05-06 03:45:14 +02:00
parent f724bf764d
commit 86bb1d0cf2
2 changed files with 250 additions and 3 deletions

View File

@@ -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([])

248
src/useChatScroll.js Normal file
View File

@@ -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,
}
}