Framework-level UX fixes found while auditing staging (BUG-216..220).
- chat: route 44 previously discarded composer notices to sonner with
dedupe, so recovery, cancel and archive feedback is actually visible
(BUG-216)
- chat: anchor stream auto-scroll to bottom proximity and add a
jump-to-latest control, so reading history is no longer interrupted
on every token (BUG-218)
- reports: replace the silent 120s poll cutoff with an explicit
timed-out state, an 8m budget, stepped backoff and an elapsed
counter (BUG-217)
- reports: pause polling while the tab is hidden, via a shared hook
- app: add root error, global-error and not-found boundaries (BUG-219)
- admin: add antd SSR style extraction and the React 19 render adapter,
and move admin-only css out of the global stylesheet (BUG-220)
- membership: run bootstrap fetches concurrently and pause payment
polling while hidden
- build: configure optimizePackageImports
Verified on top of 2d370f2e: tsc, eslint, next build, and the related
frontend contract suites.
Co-authored-by: Cursor <cursoragent@cursor.com>
76 lines
2.5 KiB
TypeScript
76 lines
2.5 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;
|
|
}
|
|
|
|
export function useConversationScrollAnchor(
|
|
container: RefObject<HTMLDivElement | null>,
|
|
active: boolean,
|
|
resetKey: string,
|
|
): ConversationScrollAnchor {
|
|
const [anchor, setAnchor] = useState<AnchorState>({ key: resetKey, anchored: true });
|
|
const lastScrollTop = useRef(0);
|
|
const anchored = anchor.key === resetKey ? anchor.anchored : true;
|
|
|
|
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]);
|
|
|
|
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 });
|
|
},
|
|
};
|
|
}
|