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
This commit is contained in:
@@ -24,15 +24,28 @@ export function nextAnchorState(anchored: boolean, distanceFromBottom: number, s
|
||||
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<HTMLDivElement | null>,
|
||||
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;
|
||||
@@ -61,6 +74,48 @@ export function useConversationScrollAnchor(
|
||||
};
|
||||
}, [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: () => {
|
||||
|
||||
Reference in New Issue
Block a user