11import {
22 forwardRef ,
3- useCallback ,
43 useEffect ,
54 useEffectEvent ,
65 useImperativeHandle ,
@@ -15,6 +14,7 @@ import { useShallow } from "zustand/react/shallow";
1514import { isThreadTurnActive , type Thread } from "@/shared/contracts" ;
1615import { chatMessageSurfaceClass } from "./parts/items/chatMessageSurface" ;
1716import { readBridge } from "@/renderer/bridge" ;
17+ import { useScrollFade } from "@/renderer/hooks/useScrollFade" ;
1818import { useAppStore } from "@/renderer/state/appStore" ;
1919import { hydrateThreadRuntimeItems } from "@/renderer/state/chatRuntimePersister" ;
2020import {
@@ -76,53 +76,20 @@ export function ChatPane(props: ChatPaneProps) {
7676 layoutChangeToken,
7777 } = props ;
7878 const { id : threadId , projectId, status, worktreePath, worktreeBranch } = thread ;
79- const scrollRef = useRef < HTMLDivElement > ( null ) ;
79+ const contentRef = useRef < HTMLDivElement > ( null ) ;
8080 // `scrollEl` mirrors `scrollRef.current` as React state so the virtualizer
8181 // in `MessageList` sees the element transition from `null` to mounted across
8282 // a real React render. Without this, after a drag-drop pane move the
8383 // virtualizer's internal observer-driven rerender can be lost and the chat
8484 // renders empty (with a scrollbar from `getTotalSize`) until the next state
8585 // change forces a recompute.
86- const [ scrollEl , setScrollEl ] = useState < HTMLDivElement | null > ( null ) ;
87- const setScrollContainer = useCallback ( ( el : HTMLDivElement | null ) => {
88- scrollRef . current = el ;
89- setScrollEl ( el ) ;
90- } , [ ] ) ;
91- const contentRef = useRef < HTMLDivElement > ( null ) ;
86+ const { setScrollContainer, scrollRef, scrollEl, scrollFadeStyle } =
87+ useScrollFade < HTMLDivElement > ( { contentRef } ) ;
9288 const [ initialScrollSettledThreadId , setInitialScrollSettledThreadId ] = useState < string | null > (
9389 null ,
9490 ) ;
9591 const isInitialScrollSettled = initialScrollSettledThreadId === threadId ;
9692
97- useEffect ( ( ) => {
98- const el = scrollEl ;
99- if ( ! el ) return ;
100-
101- const updateFades = ( ) => {
102- const { scrollTop, scrollHeight, clientHeight } = el ;
103- const topFade = Math . min ( 32 , scrollTop ) ;
104- const bottomFade = Math . min ( 32 , Math . max ( 0 , scrollHeight - scrollTop - clientHeight ) ) ;
105-
106- el . style . setProperty ( "--top-fade-size" , `${ topFade } px` ) ;
107- el . style . setProperty ( "--bottom-fade-size" , `${ bottomFade } px` ) ;
108- } ;
109-
110- updateFades ( ) ;
111- el . addEventListener ( "scroll" , updateFades , { passive : true } ) ;
112-
113- // Re-check when the container or its content resizes.
114- const observer = new ResizeObserver ( updateFades ) ;
115- observer . observe ( el ) ;
116- if ( contentRef . current ) {
117- observer . observe ( contentRef . current ) ;
118- }
119-
120- return ( ) => {
121- el . removeEventListener ( "scroll" , updateFades ) ;
122- observer . disconnect ( ) ;
123- } ;
124- } , [ scrollEl ] ) ;
125-
12693 const scrollControlsRef = useRef < ChatScrollControlsHandle > ( null ) ;
12794 const virtualScrollToBottomRef = useRef < ( ( ) => void ) | null > ( null ) ;
12895 const timelineEntries = useAppStore (
@@ -279,12 +246,7 @@ export function ChatPane(props: ChatPaneProps) {
279246 < div
280247 ref = { setScrollContainer }
281248 className = "min-h-0 h-full overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable]"
282- style = { {
283- WebkitMaskImage :
284- "linear-gradient(to bottom, transparent, black var(--top-fade-size, 0px), black calc(100% - var(--bottom-fade-size, 0px)), transparent)" ,
285- maskImage :
286- "linear-gradient(to bottom, transparent, black var(--top-fade-size, 0px), black calc(100% - var(--bottom-fade-size, 0px)), transparent)" ,
287- } }
249+ style = { scrollFadeStyle }
288250 onWheelCapture = { ( event ) => {
289251 if ( event . deltaY < 0 ) {
290252 scrollControlsRef . current ?. markUserScrollIntent ( ) ;
0 commit comments