fix(ux): surface hidden notices, bound report waits, add root boundaries
Independent Staging Quality Gate / validate (push) Successful in 12m8s
Independent Staging Quality Gate / publish (push) Successful in 14m29s

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>
This commit is contained in:
Jesse_Chen
2026-08-17 12:52:24 +08:00
parent 2d370f2e9d
commit 9c296f1e3f
23 changed files with 1144 additions and 88 deletions
@@ -0,0 +1,75 @@
"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 });
},
};
}
@@ -0,0 +1,71 @@
/**
* Interval polling that stops while the tab is hidden.
*
* While `document.hidden` is true the interval is torn down entirely, so a
* backgrounded tab or a locked phone issues no requests. Becoming visible
* again fires one immediate poll (unless `refreshOnVisible` is false) and then
* restarts the interval, so a returning user sees fresh state without waiting
* out a full tick. The callback is held in a ref, so a caller may pass an
* inline closure without restarting the interval on every render.
*/
"use client";
import { useEffect, useRef } from "react";
export type VisibilityAwarePollOptions = {
readonly enabled: boolean;
readonly intervalMs: number;
readonly onPoll: () => void;
readonly refreshOnVisible?: boolean;
};
export function useVisibilityAwarePoll(options: VisibilityAwarePollOptions): void {
const { enabled, intervalMs, onPoll, refreshOnVisible = true } = options;
const pollRef = useRef(onPoll);
useEffect(() => {
pollRef.current = onPoll;
}, [onPoll]);
useEffect(() => {
if (!enabled || typeof document === "undefined" || intervalMs <= 0) {
return;
}
let timer: number | null = null;
const stop = () => {
if (timer !== null) {
window.clearInterval(timer);
timer = null;
}
};
const start = () => {
stop();
timer = window.setInterval(() => pollRef.current(), intervalMs);
};
const handleVisibilityChange = () => {
if (document.hidden) {
stop();
return;
}
if (refreshOnVisible) {
pollRef.current();
}
start();
};
if (!document.hidden) {
start();
}
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
stop();
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [enabled, intervalMs, refreshOnVisible]);
}