Files
Jyotisha/frontend/src/hooks/use-conversation-scroll-anchor.ts
T
Jesse_ChenandClaude Fable 5.1 f542c02490 refactor(chat): share composer, scroll follow and jump control across both chat surfaces
The rectification session rendered its own textarea (no length ceiling,
no count), its own rAF scroll follow with a sticky-scroll helper, and its
own jump chip styled differently from the main chat's inline Tailwind
button, while the main chat re-ran scrollTo on every streamed token and
added a smooth scroll on settle. ChatComposer now accepts a controlled
value so the rectification surface reuses it without touching the main
draft store; useConversationScrollAnchor owns the follow through a
ResizeObserver, one frame per content change, for both surfaces; a
single JumpToLatestButton replaces both chips; the 720px transcript
override is gone. DESIGN.md records the shared composer, the jump
control, the reading width and the two shadow tokens.

BUG-477 BUG-478

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:29:01 +00:00

131 lines
4.4 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
export const conversationAnchorThreshold = 96;
type AnchorState = {
readonly key: string;
readonly anchored: boolean;
};
type ConversationScrollAnchor = {
readonly anchored: boolean;
readonly anchorToLatest: () => void;
};
export function conversationDistanceFromBottom(container: HTMLElement) {
return container.scrollHeight - container.scrollTop - container.clientHeight;
}
export function nextAnchorState(anchored: boolean, distanceFromBottom: number, scrolledUp: boolean) {
if (distanceFromBottom <= conversationAnchorThreshold) return true;
return scrolledUp ? false : anchored;
}
/**
* Owns both halves of "follow the conversation": whether the reader is
* anchored to the bottom (a scroll listener), and landing the viewport on the
* newest content while they are (a resize observer over the scroller's
* children, one frame per change). Both chat surfaces use this one hook, so
* streamed tokens never call scrollTo directly and settlement never adds a
* second, smooth scroll on top of the follow.
*/
export function useConversationScrollAnchor(
container: RefObject<HTMLElement | null>,
active: boolean,
resetKey: string,
): ConversationScrollAnchor {
const [anchor, setAnchor] = useState<AnchorState>({ key: resetKey, anchored: true });
const lastScrollTop = useRef(0);
const anchoredRef = useRef(true);
const anchored = anchor.key === resetKey ? anchor.anchored : true;
useEffect(() => {
anchoredRef.current = anchored;
}, [anchored]);
useEffect(() => {
const element = container.current;
if (!active || !element) return;
lastScrollTop.current = element.scrollTop;
let frame = 0;
const measure = () => {
frame = 0;
const distance = conversationDistanceFromBottom(element);
const scrolledUp = element.scrollTop < lastScrollTop.current;
lastScrollTop.current = element.scrollTop;
setAnchor((current) => {
const currentAnchored = current.key === resetKey ? current.anchored : true;
const next = nextAnchorState(currentAnchored, distance, scrolledUp);
return next === currentAnchored && current.key === resetKey ? current : { key: resetKey, anchored: next };
});
};
const onScroll = () => {
if (frame) return;
frame = window.requestAnimationFrame(measure);
};
element.addEventListener("scroll", onScroll, { passive: true });
frame = window.requestAnimationFrame(measure);
return () => {
if (frame) window.cancelAnimationFrame(frame);
element.removeEventListener("scroll", onScroll);
};
}, [active, container, resetKey]);
// Follow: while anchored, any change in the scroller's content height lands the
// viewport on the bottom, at most once per frame. Switching conversations
// (resetKey) lands there immediately.
useEffect(() => {
const element = container.current;
if (!active || !element) return;
let frame = 0;
const follow = () => {
frame = 0;
if (!anchoredRef.current) return;
element.scrollTop = element.scrollHeight;
};
const requestFollow = () => {
if (frame) return;
frame = window.requestAnimationFrame(follow);
};
requestFollow();
if (typeof ResizeObserver === "undefined") {
return () => {
if (frame) window.cancelAnimationFrame(frame);
};
}
const sizes = new ResizeObserver(requestFollow);
const observeChildren = () => {
sizes.disconnect();
for (const child of Array.from(element.children)) sizes.observe(child);
};
observeChildren();
const children = typeof MutationObserver === "undefined"
? null
: new MutationObserver(() => {
observeChildren();
requestFollow();
});
children?.observe(element, { childList: true });
return () => {
sizes.disconnect();
children?.disconnect();
if (frame) window.cancelAnimationFrame(frame);
};
}, [active, container, resetKey]);
return {
anchored,
anchorToLatest: () => {
const element = container.current;
if (element) {
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
element.scrollTo({ top: element.scrollHeight, behavior: reduceMotion ? "auto" : "smooth" });
}
setAnchor({ key: resetKey, anchored: true });
},
};
}