diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md
index ca7265f1..91fe209b 100644
--- a/docs/BUG_HISTORY.md
+++ b/docs/BUG_HISTORY.md
@@ -3618,3 +3618,73 @@
- 防复发:被多处断言复用的测试辅助函数必须有自己的回归,不得以副本形式散落在各测试文件。以字符串匹配近似 CSS 语义时,必须按规则解析并覆盖同名选择器的全部声明;`indexOf` 式首个匹配不可用于可能重复出现的选择器。
- 相关记录:BUG-214
- 修复版本:本地未提交候选
+
+## BUG-216 | 咨询中断、取消、归档等 44 条提示文案计算后从未显示
+
+- 状态:resolved(本地修复,待提交与发布)
+- 首次发现:2026-08-17
+- 最近更新:2026-08-17
+- 影响面:`/` 主对话页的断线恢复、取消回答、模型下线切换、会话重命名与删除失败等全部提示通路。
+- 用户现象:网络中断、刷新后恢复、点击停止、删除会话失败时,界面只有一个转圈或静默无反应,用户无法知道回答仍在后台生成、是否已取消、失败原因是什么。
+- 根因:`frontend/src/app/page.tsx` 将提示状态声明为 `const [, setComposerNotice] = useState("")`,解构时丢弃了状态值,JSX 中也没有任何渲染点;44 处 `setComposerNotice(...)` 调用的文案全部写入一个永不读取的 state。恢复轮询逻辑本身正确,缺的只是出口。
+- 修复:新增 `frontend/src/lib/chat-notice.ts`,把提示按语义分派到既有 sonner `toast.success` / `toast.error` / `toast`;根布局早已挂载 ``,无需改动。以固定 `id` 复用同一条 toast,避免 1750ms 恢复轮询把同一句话堆成几十条;空字符串走 dismiss 而不弹空 toast。44 处调用点与全部中文文案逐字未改。
+- 验证:新增 `frontend/tests/chat-notice-and-scroll-contract.test.ts` 锁定状态不再被丢弃、提示进入 toast、空串不弹窗、轮询防刷屏与分级语义;`tsc --noEmit`、`eslint` 清洁;与改动文件相关的 43 个测试文件共 386 条断言全绿。
+- 防复发:禁止以 `const [, setX]` 形式声明用户可见文案状态;任何面向用户的提示必须有可断言的渲染出口,合同测试需覆盖“文案确实可达 UI”而不仅是“文案存在”。
+- 相关记录:BUG-211
+- 修复版本:本地未提交候选
+
+## BUG-217 | 个人报告页轮询 120 秒后静默停止,界面仍显示“生成完成后页面会自动显示”
+
+- 状态:resolved(本地修复,待提交与发布)
+- 首次发现:2026-08-17
+- 最近更新:2026-08-17
+- 影响面:`/reports/[reportId]` 报告详情页的生成等待态。
+- 用户现象:报告生成超过两分钟后,页面永远停在“报告正在生成中,请稍候…”的转圈上,即使报告已经在后台完成也不会刷新;页面同时声称“生成完成后页面会自动显示”,与实际行为矛盾。
+- 根因:`personal-report-page.tsx` 以 `MAX_POLLS = 40` × `POLL_INTERVAL_MS = 3000` 限制轮询次数,达到上限后 effect 直接 `return` 停止轮询,但 `state.phase` 仍保持 `"generating"`,UI 继续渲染等待分支,没有任何超时态或失败态承接。
+- 修复:在 `ReportLoadState` 中新增客户端专用的 `timed-out` 相位(`classifyReportEnvelope` 不产出该值,服务端语义未变)。轮询改为 8 分钟墙钟预算配阶梯退避(首分钟 3s,之后 6s/10s/15s,总请求数由 160 降到约 48)。预算耗尽后进入 `timed-out` 屏:说明生成仍在后台继续,提供“继续等待”重置时钟并立即重取,保留“返回报告中心”。等待中与超时后均显示“已等待 X 分 Y 秒”。
+- 验证:新增 `frontend/tests/report-polling-contract.test.ts`,除文本合同外还对导出的 `pollIntervalForElapsed` / `formatWaitedDuration` 做真实单元断言(退避边界、单调性、请求数上限);既有 `personal-report-*` 测试全部通过。
+- 防复发:任何有次数或时间上限的轮询,达到上限时必须切换到显式终态并给出用户可执行的下一步;等待文案承诺“自动刷新”时,必须由测试保证该承诺在整个等待窗口内成立。
+- 相关记录:BUG-217 无前序同类记录
+- 修复版本:本地未提交候选
+
+## BUG-218 | 流式回答期间强制滚到底部,用户无法向上翻阅历史
+
+- 状态:resolved(本地修复,待提交与发布)
+- 首次发现:2026-08-17
+- 最近更新:2026-08-17
+- 影响面:`/` 主对话页的会话滚动容器。
+- 用户现象:长回答生成过程中向上滚动查看此前内容,会被立即拽回底部,无法停留;也没有任何“回到最新”的入口。
+- 根因:`page.tsx` 的自动滚动 effect 依赖数组包含 `activeStreamingText`,每个流式 token 都会触发一次无条件 `scrollTo(scrollHeight)`,未判断用户当前是否已在底部附近。
+- 修复:新增 `frontend/src/hooks/use-conversation-scroll-anchor.ts`,以 rAF 合并的 passive 滚动监听维护锚定状态:用户向上越过约 96px 阈值即解除锚定,回到阈值内自动恢复;仅在锚定时执行自动滚动。切换会话与用户自己发送消息仍强制滚到底部。解除锚定时显示“跳到最新”按钮(真实 `
{generating && (
<>
+ 已等待 {formatWaitedDuration(waitedMs)}
生成完成后页面会自动显示;你也可以返回报告中心,后台会继续处理。
@@ -179,6 +223,27 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
);
}
+ if (state.phase === "timed-out") {
+ return (
+
+
+ 生成时间超出预期
+ 已等待 {formatWaitedDuration(waitedMs)},页面已暂停自动刷新。
+
+ 报告仍在后台生成,不会因为你离开而中断。你可以继续等待,也可以稍后回到报告中心查看结果。
+
+
+ keepWaiting()}>
+ 继续等待
+
+ } nativeButton={false} variant="outline">
+ 返回报告中心
+
+
+
+ );
+ }
+
if (state.phase === "unauthorized") {
return (
diff --git a/frontend/src/hooks/use-conversation-scroll-anchor.ts b/frontend/src/hooks/use-conversation-scroll-anchor.ts
new file mode 100644
index 00000000..37f8fbf6
--- /dev/null
+++ b/frontend/src/hooks/use-conversation-scroll-anchor.ts
@@ -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,
+ active: boolean,
+ resetKey: string,
+): ConversationScrollAnchor {
+ const [anchor, setAnchor] = useState({ 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 });
+ },
+ };
+}
diff --git a/frontend/src/hooks/use-visibility-aware-poll.ts b/frontend/src/hooks/use-visibility-aware-poll.ts
new file mode 100644
index 00000000..3dce0774
--- /dev/null
+++ b/frontend/src/hooks/use-visibility-aware-poll.ts
@@ -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]);
+}
diff --git a/frontend/src/lib/chat-notice.ts b/frontend/src/lib/chat-notice.ts
new file mode 100644
index 00000000..c51c03c4
--- /dev/null
+++ b/frontend/src/lib/chat-notice.ts
@@ -0,0 +1,39 @@
+import { toast } from "sonner";
+
+export const chatNoticeToastId = "chat-notice";
+
+export type NoticeTone = "info" | "success" | "error";
+
+const ongoingNotice = /正在|请稍候|请先|联网后/;
+const failedNotice = /失败|无法|不可用|未找到/;
+const settledNotice = /^已|^回答已恢复/;
+
+export function noticeTone(message: string): NoticeTone {
+ if (ongoingNotice.test(message)) return "info";
+ if (failedNotice.test(message)) return "error";
+ if (settledNotice.test(message)) return "success";
+ return "info";
+}
+
+let lastNotice = "";
+
+export function showChatNotice(message: string) {
+ if (!message.trim()) {
+ if (!lastNotice) return;
+ lastNotice = "";
+ toast.dismiss(chatNoticeToastId);
+ return;
+ }
+ if (lastNotice === message) return;
+ lastNotice = message;
+ const tone = noticeTone(message);
+ if (tone === "success") {
+ toast.success(message, { id: chatNoticeToastId });
+ return;
+ }
+ if (tone === "error") {
+ toast.error(message, { id: chatNoticeToastId });
+ return;
+ }
+ toast(message, { id: chatNoticeToastId });
+}
diff --git a/frontend/tests/admin-antd-ssr-contract.test.ts b/frontend/tests/admin-antd-ssr-contract.test.ts
new file mode 100644
index 00000000..7d309e70
--- /dev/null
+++ b/frontend/tests/admin-antd-ssr-contract.test.ts
@@ -0,0 +1,69 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+const registry = readFileSync(new URL("../src/components/admin/admin-antd-registry.tsx", import.meta.url), "utf8");
+const adminLayout = readFileSync(new URL("../src/app/admin/layout.tsx", import.meta.url), "utf8");
+const rootLayout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
+const globalsCss = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
+const adminCss = readFileSync(new URL("../src/app/admin/admin.css", import.meta.url), "utf8");
+const nextConfig = readFileSync(new URL("../next.config.ts", import.meta.url), "utf8");
+const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
+ dependencies: Record;
+};
+
+test("admin antd styles are extracted during server rendering", () => {
+ assert.match(registry, /^"use client";/);
+ assert.match(registry, /import \{ StyleProvider, createCache, extractStyle \} from "@ant-design\/cssinjs";/);
+ assert.match(registry, /useServerInsertedHTML\(\(\) => \{/);
+ assert.match(registry, /extractStyle\(cache, \{ plain: true, once: true \}\)/);
+ assert.match(registry, /\{children\}<\/StyleProvider>/);
+ assert.match(registry, /id="antd-cssinjs"/);
+ assert.match(registry, /data-rc-order="prepend"/);
+ assert.ok("@ant-design/cssinjs" in packageJson.dependencies, "cssinjs must be a declared dependency");
+});
+
+test("the antd registry wraps the admin subtree only", () => {
+ assert.match(adminLayout, /import \{ AdminAntdRegistry \} from "@\/components\/admin\/admin-antd-registry";/);
+ assert.match(adminLayout, /\s*\{children\}<\/AdminApp>\s*<\/AdminAntdRegistry>/);
+ assert.doesNotMatch(rootLayout, /AdminAntdRegistry|cssinjs|antd/);
+});
+
+test("antd runs with the React 19 render patch inside admin", () => {
+ assert.match(registry, /import \{ unstableSetRender \} from "antd";/);
+ assert.match(registry, /import \{ createRoot, type Root \} from "react-dom\/client";/);
+ assert.match(registry, /unstableSetRender\(\(node, container\) => \{/);
+ assert.match(registry, /target\._reactRoot \?\?= createRoot\(target\);/);
+ assert.match(registry, /root\.render\(node\);/);
+ assert.match(registry, /root\.unmount\(\);/);
+});
+
+test("admin antd overrides ship only on admin routes", () => {
+ assert.doesNotMatch(globalsCss, /\.admin-app-shell \.ant-layout-content/);
+ assert.doesNotMatch(globalsCss, /\.admin-app-shell \.ant-table-wrapper/);
+ assert.doesNotMatch(globalsCss, /\.admin-text-list|\.admin-loading/);
+ assert.match(adminCss, /\.admin-app-shell \.ant-layout-content \{ padding: 22px; \}/);
+ assert.match(adminCss, /\.admin-app-shell \.ant-table-wrapper \.ant-table \{ background: #fbfaf7; \}/);
+ assert.match(adminCss, /\.admin-text-list \{ max-width: 680px;/);
+ assert.match(adminCss, /\.admin-loading \{ min-height: 100dvh;/);
+ assert.match(adminLayout, /import "\.\/admin\.css";/);
+ assert.doesNotMatch(rootLayout, /admin\.css/);
+});
+
+test("the admin shell keeps one antd reset stylesheet", () => {
+ assert.match(adminLayout, /import "antd\/dist\/reset\.css";/);
+ assert.doesNotMatch(adminLayout, /@refinedev\/antd\/dist\/reset\.css/);
+});
+
+test("barrel-heavy packages are optimized at build time", () => {
+ assert.match(nextConfig, /optimizePackageImports: \[/);
+ for (const pkg of ["@ant-design/icons", "@refinedev/antd", "@refinedev/core", "antd", "date-fns", "lucide-react"]) {
+ assert.ok(nextConfig.includes(`"${pkg}"`), `${pkg} must be listed in optimizePackageImports`);
+ assert.ok(pkg in packageJson.dependencies, `${pkg} must be a declared dependency`);
+ }
+ assert.match(nextConfig, /authInterrupts: true/);
+ assert.match(nextConfig, /outputFileTracingIncludes: \{/);
+ assert.match(nextConfig, /"\/api\/consult": \[/);
+ assert.match(nextConfig, /turbopack: \{ root: repositoryRoot \}/);
+ assert.match(nextConfig, /output: "standalone"/);
+});
diff --git a/frontend/tests/chat-notice-and-scroll-contract.test.ts b/frontend/tests/chat-notice-and-scroll-contract.test.ts
new file mode 100644
index 00000000..f0c2aae0
--- /dev/null
+++ b/frontend/tests/chat-notice-and-scroll-contract.test.ts
@@ -0,0 +1,115 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+import { noticeTone } from "../src/lib/chat-notice.ts";
+import { nextAnchorState } from "../src/hooks/use-conversation-scroll-anchor.ts";
+
+const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
+const noticeSource = readFileSync(new URL("../src/lib/chat-notice.ts", import.meta.url), "utf8");
+const anchorSource = readFileSync(new URL("../src/hooks/use-conversation-scroll-anchor.ts", import.meta.url), "utf8");
+
+function sourceBetween(source: string, startMarker: string, endMarker: string) {
+ const start = source.indexOf(startMarker);
+ const end = source.indexOf(endMarker, start);
+ assert.notEqual(start, -1);
+ assert.notEqual(end, -1);
+ return source.slice(start, end);
+}
+
+test("routes composer notices to the user instead of discarding them", () => {
+ // Given: the page no longer keeps the notice in write-only state.
+ assert.doesNotMatch(pageSource, /const \[, setComposerNotice\] = useState/);
+ assert.match(pageSource, /import \{ showChatNotice as setComposerNotice \} from "@\/lib\/chat-notice"/);
+
+ // Then: every notice reaches the mounted sonner toaster.
+ assert.match(noticeSource, /import \{ toast \} from "sonner"/);
+ assert.match(noticeSource, /toast\.success\(message, \{ id: chatNoticeToastId \}\)/);
+ assert.match(noticeSource, /toast\.error\(message, \{ id: chatNoticeToastId \}\)/);
+ assert.match(noticeSource, /toast\(message, \{ id: chatNoticeToastId \}\)/);
+});
+
+test("clearing a notice dismisses instead of showing an empty toast", () => {
+ const clearBranch = sourceBetween(noticeSource, "if (!message.trim())", "if (lastNotice === message) return;");
+
+ assert.match(clearBranch, /toast\.dismiss\(chatNoticeToastId\)/);
+ assert.doesNotMatch(clearBranch, /toast\(|toast\.success|toast\.error/);
+ assert.match(pageSource, /setComposerNotice\(""\)/);
+});
+
+test("keeps the recovery poll from stacking repeated notices", () => {
+ // Given: the recovery loop repeats the same message every 1750ms.
+ assert.match(pageSource, /timer = window\.setTimeout\(\(\) => void poll\(\), 1_750\)/);
+
+ // Then: a stable toast id plus last-message dedupe replaces instead of accumulating.
+ assert.match(noticeSource, /export const chatNoticeToastId = "chat-notice"/);
+ assert.match(noticeSource, /if \(lastNotice === message\) return;/);
+});
+
+test("assigns notice severity by message intent", () => {
+ assert.equal(noticeTone("网络已断开,回答仍在后台生成;联网后会自动恢复。"), "info");
+ assert.equal(noticeTone("回答仍在后台生成,正在自动恢复。"), "info");
+ assert.equal(noticeTone("正在确认本次咨询请求是否已开始…"), "info");
+ assert.equal(noticeTone("此前选择的模型已下线,已切换为默认模型。"), "info");
+ assert.equal(noticeTone("回答已取消;问题仍保留在聊天记录中,可重新发送。"), "info");
+ assert.equal(noticeTone("回答已恢复。"), "success");
+ assert.equal(noticeTone("已归档,可在左侧归档中恢复。"), "success");
+ assert.equal(noticeTone("已停止回答,现有内容已保留,本次点数已退回。"), "success");
+ assert.equal(noticeTone("删除失败:网络异常"), "error");
+ assert.equal(noticeTone("重命名同步失败"), "error");
+ assert.equal(noticeTone("模型服务暂时不可用,当前无法发送问题。"), "error");
+ assert.equal(noticeTone("后台未找到本次咨询请求,已停止恢复,请重新发送。"), "error");
+});
+
+test("anchors the streaming scroll instead of following every token", () => {
+ const autoScrollEffect = sourceBetween(
+ pageSource,
+ "useEffect(() => {\n if (starterHomeVisible) return;",
+ "profileComplete, starterHomeVisible]);",
+ );
+
+ // Then: streamed tokens only move the viewport while the reader stays anchored.
+ assert.match(autoScrollEffect, /if \(!conversationAnchor\.anchored\) return/);
+ const scrollGuard = autoScrollEffect.indexOf("if (!conversationAnchor.anchored) return");
+ const scrollCall = autoScrollEffect.indexOf("container.scrollTo(");
+ assert.ok(scrollGuard >= 0 && scrollGuard < scrollCall);
+});
+
+test("scrolls to the newest turn on intentional jumps", () => {
+ // Given: switching sessions resets the anchor through the hook reset key.
+ assert.match(pageSource, /useConversationScrollAnchor\(\n\s*conversation,\n\s*!rectificationSurfaceOpen && !starterHomeVisible,\n\s*activeSessionId,\n\s*\)/);
+ assert.match(anchorSource, /const anchored = anchor\.key === resetKey \? anchor\.anchored : true/);
+
+ // And: sending a question re-anchors before the optimistic turn renders.
+ const sendBlock = sourceBetween(pageSource, " updateSession(sessionId, () => userSession);", " setDraft(\"\");");
+ assert.match(sendBlock, /conversationAnchor\.anchorToLatest\(\)/);
+});
+
+test("offers an accessible jump-to-latest control while reading history", () => {
+ const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", "\n )}");
+
+ assert.match(pageSource, /const jumpToLatestVisible = !rectificationSurfaceOpen[\s\S]*?&& !conversationAnchor\.anchored/);
+ assert.match(jumpControl, /type="button"/);
+ assert.match(jumpControl, /aria-label="跳到最新"/);
+ assert.match(jumpControl, /focus-visible:ring-3/);
+ assert.match(jumpControl, /min-h-11/);
+ assert.match(jumpControl, /min-w-11/);
+ assert.match(jumpControl, /onClick=\{conversationAnchor\.anchorToLatest\}/);
+});
+
+test("keeps the scroll listener passive and reduced-motion aware", () => {
+ assert.match(anchorSource, /addEventListener\("scroll", onScroll, \{ passive: true \}\)/);
+ assert.match(anchorSource, /frame = window\.requestAnimationFrame\(measure\)/);
+ assert.match(anchorSource, /window\.matchMedia\("\(prefers-reduced-motion: reduce\)"\)\.matches/);
+ assert.match(anchorSource, /behavior: reduceMotion \? "auto" : "smooth"/);
+});
+
+test("re-anchors once the reader returns to the newest turn", () => {
+ // Given: the reader is following the stream and scrolls up mid-answer.
+ assert.equal(nextAnchorState(true, 0, false), true);
+ assert.equal(nextAnchorState(true, 600, true), false);
+
+ // Then: growing content alone never re-anchors, but scrolling back does.
+ assert.equal(nextAnchorState(false, 600, false), false);
+ assert.equal(nextAnchorState(false, 40, false), true);
+});
diff --git a/frontend/tests/membership-navigation-contract.test.ts b/frontend/tests/membership-navigation-contract.test.ts
new file mode 100644
index 00000000..6d06368d
--- /dev/null
+++ b/frontend/tests/membership-navigation-contract.test.ts
@@ -0,0 +1,63 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+const membershipPage = readFileSync(new URL("../src/app/membership/page.tsx", import.meta.url), "utf8");
+const ordersPage = readFileSync(new URL("../src/app/membership/orders/page.tsx", import.meta.url), "utf8");
+
+function countMatches(source: string, pattern: RegExp) {
+ return source.match(pattern)?.length ?? 0;
+}
+
+test("account and packages are requested concurrently on mount", () => {
+ assert.match(membershipPage, /await Promise\.allSettled\(\[fetchAccountData\(\), fetchPackages\(\)\]\);/);
+ assert.doesNotMatch(membershipPage, /await fetchAccountData\(\);\s*await fetchPackages\(\)/);
+ assert.doesNotMatch(membershipPage, /Promise\.all\(\[/);
+});
+
+test("a failing bootstrap request cannot hide the other section", () => {
+ assert.match(membershipPage, /setAccountError\(caught instanceof Error/);
+ assert.match(membershipPage, /setPackagesError\("套餐支付暂时不可用,请稍后重试"\)/);
+ assert.match(membershipPage, /\{account \? `\$\{account\.credits\} 点` : accountError \|\| "正在读取…"\}/);
+ assert.match(membershipPage, /\{packagesError && \{packagesError\}<\/p>\}/);
+});
+
+test("membership and orders navigate through next/link instead of a document reload", () => {
+ assert.match(membershipPage, /import Link from "next\/link"/);
+ assert.match(membershipPage, //);
+ assert.match(ordersPage, /import Link from "next\/link"/);
+ assert.match(ordersPage, / {
+ assert.match(membershipPage, /if \(response\.status === 401\) \{\s*window\.location\.assign\("\/login"\);/);
+ assert.match(ordersPage, /if \(response\.status === 401\) \{\s*window\.location\.assign\("\/login"\);/);
+ assert.equal(countMatches(membershipPage, /window\.location\.assign\("\/login"\)/g), 3);
+ assert.equal(countMatches(ordersPage, /window\.location\.assign\("\/login"\)/g), 1);
+});
+
+test("browser back and the external cashier keep their native behaviour", () => {
+ assert.match(membershipPage, /if \(window\.history\.length > 1\) window\.history\.back\(\);/);
+ assert.match(membershipPage, /else window\.location\.assign\("\/"\);/);
+ assert.match(membershipPage, /window\.open\(payload\.payUrl, "_blank", "noopener,noreferrer"\)/);
+});
+
+test("the hard navigation inventory does not grow", () => {
+ assert.equal(countMatches(membershipPage, /window\.location\./g), 4);
+ assert.equal(countMatches(ordersPage, /window\.location\./g), 1);
+});
+
+test("payment polling pauses on a hidden tab and refreshes when it returns", () => {
+ assert.match(membershipPage, /if \(!document\.hidden\) startPolling\(\);/);
+ assert.match(membershipPage, /document\.addEventListener\("visibilitychange", onVisibilityChange\);/);
+ assert.match(membershipPage, /if \(document\.hidden\) \{\s*stopPolling\(\);\s*return;\s*\}/);
+ assert.match(membershipPage, /void checkPaymentStatus\(\);\s*startPolling\(\);/);
+ assert.match(membershipPage, /window\.setInterval\(\(\) => void checkPaymentStatus\(\), 3000\);/);
+});
+
+test("polling cleanup releases both the timer and the visibility listener", () => {
+ assert.match(membershipPage, /if \(timer\) window\.clearInterval\(timer\);/);
+ assert.match(membershipPage, /stopPolling\(\);\s*document\.removeEventListener\("visibilitychange", onVisibilityChange\);/);
+});
diff --git a/frontend/tests/report-polling-contract.test.ts b/frontend/tests/report-polling-contract.test.ts
new file mode 100644
index 00000000..c2ca991e
--- /dev/null
+++ b/frontend/tests/report-polling-contract.test.ts
@@ -0,0 +1,147 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+import {
+ formatWaitedDuration,
+ POLL_BUDGET_MS,
+ pollIntervalForElapsed,
+} from "../src/components/personal-report/personal-report-page.tsx";
+
+const pageSource = readFileSync(
+ new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url),
+ "utf8",
+);
+const centerSource = readFileSync(
+ new URL("../src/components/personal-report/personal-report-center.tsx", import.meta.url),
+ "utf8",
+);
+const hookSource = readFileSync(
+ new URL("../src/hooks/use-visibility-aware-poll.ts", import.meta.url),
+ "utf8",
+);
+
+test("an exhausted poll budget becomes an explicit state instead of an endless spinner", () => {
+ assert.match(pageSource, /\| \{ phase: "timed-out" \}/, "ReportLoadState carries an explicit timed-out phase");
+ assert.match(pageSource, /setState\(\(current\) => \(current\.phase === "generating" \? \{ phase: "timed-out" \} : current\)\)/);
+ assert.match(pageSource, /if \(state\.phase === "timed-out"\)/, "the timed-out phase has its own render branch");
+ // The old silent give-up must be gone.
+ assert.doesNotMatch(pageSource, /MAX_POLLS/);
+ assert.doesNotMatch(pageSource, /polls >= /);
+});
+
+test("the timed-out branch is honest and offers both resume and the report center", () => {
+ const branchAt = pageSource.indexOf('if (state.phase === "timed-out")');
+ assert.ok(branchAt >= 0, "timed-out branch must exist");
+ const branchEnd = pageSource.indexOf('if (state.phase === "unauthorized")');
+ assert.ok(branchEnd > branchAt, "the timed-out branch must be self-contained");
+ const branch = pageSource.slice(branchAt, branchEnd);
+ assert.match(branch, /生成时间超出预期/);
+ assert.match(branch, /已等待 \{formatWaitedDuration\(waitedMs\)\}/);
+ assert.match(branch, /页面已暂停自动刷新。/, "we must say polling stopped rather than imply it continues");
+ assert.match(branch, /报告仍在后台生成/);
+ assert.match(branch, /继续等待/);
+ assert.match(branch, /onClick=\{\(\) => keepWaiting\(\)\}/);
+ assert.match(branch, /href="\/reports"/);
+ assert.match(branch, /返回报告中心/);
+ assert.doesNotMatch(branch, /[!!]/, "copy carries no exclamation marks");
+});
+
+test("keepWaiting restarts the wait clock and immediately refetches", () => {
+ const resumeAt = pageSource.indexOf("const keepWaiting = useCallback(");
+ assert.ok(resumeAt >= 0);
+ const resume = pageSource.slice(resumeAt, resumeAt + 320);
+ assert.match(resume, /setWaitStartedAt\(Date\.now\(\)\)/);
+ assert.match(resume, /setWaitedMs\(0\)/);
+ assert.match(resume, /setState\(\{ phase: "generating" \}\)/);
+ assert.match(resume, /void load\(\)/);
+});
+
+test("the wait budget is an explicit wall clock well beyond the old 120s", () => {
+ assert.equal(POLL_BUDGET_MS, 8 * 60 * 1000);
+ assert.ok(POLL_BUDGET_MS > 120_000, "120s was too short for report generation");
+ assert.match(pageSource, /export const POLL_BUDGET_MS = 8 \* 60 \* 1000;/);
+});
+
+test("polling backs off without slowing the first minute", () => {
+ assert.equal(pollIntervalForElapsed(0), 3000);
+ assert.equal(pollIntervalForElapsed(59_999), 3000);
+ assert.equal(pollIntervalForElapsed(60_000), 6000);
+ assert.equal(pollIntervalForElapsed(179_999), 6000);
+ assert.equal(pollIntervalForElapsed(180_000), 10_000);
+ assert.equal(pollIntervalForElapsed(360_000), 15_000);
+ assert.equal(pollIntervalForElapsed(POLL_BUDGET_MS), 15_000);
+
+ let previous = 0;
+ for (let elapsed = 0; elapsed <= POLL_BUDGET_MS; elapsed += 1000) {
+ const interval = pollIntervalForElapsed(elapsed);
+ assert.ok(interval >= previous, `interval must never shrink at ${elapsed}ms`);
+ assert.ok(interval >= 3000 && interval <= 15_000, `interval stays within 3s..15s at ${elapsed}ms`);
+ previous = interval;
+ }
+
+ // A full budget must cost far fewer requests than a flat 3s interval would.
+ let requests = 0;
+ for (let elapsed = 0; elapsed < POLL_BUDGET_MS; elapsed += pollIntervalForElapsed(elapsed)) {
+ requests += 1;
+ }
+ assert.ok(requests < POLL_BUDGET_MS / 3000, "backoff must reduce the request count");
+ assert.ok(requests < 80, `a full wait should stay under 80 requests, got ${requests}`);
+
+ assert.match(pageSource, /intervalMs: pollIntervalForElapsed\(waitedMs\)/);
+});
+
+test("elapsed wait is rendered in Simplified Chinese minutes and seconds", () => {
+ assert.equal(formatWaitedDuration(0), "0 秒");
+ assert.equal(formatWaitedDuration(9_400), "9 秒");
+ assert.equal(formatWaitedDuration(60_000), "1 分 0 秒");
+ assert.equal(formatWaitedDuration(130_000), "2 分 10 秒");
+ assert.equal(formatWaitedDuration(-5), "0 秒");
+ assert.match(pageSource, /已等待 \{formatWaitedDuration\(waitedMs\)\}/);
+ const generatingAt = pageSource.indexOf('{generating ? "报告正在生成中,请稍候…"');
+ assert.ok(generatingAt >= 0, "the generating spinner still exists");
+ assert.match(pageSource.slice(generatingAt, generatingAt + 700), /已等待 \{formatWaitedDuration\(waitedMs\)\}/);
+});
+
+test("the shared hook pauses on hidden, refreshes on visible and always cleans up", () => {
+ assert.match(hookSource, /export function useVisibilityAwarePoll/);
+ assert.match(hookSource, /document\.hidden/);
+ assert.match(hookSource, /document\.addEventListener\("visibilitychange", handleVisibilityChange\)/);
+ assert.match(hookSource, /document\.removeEventListener\("visibilitychange", handleVisibilityChange\)/);
+ assert.match(hookSource, /if \(document\.hidden\) \{\s*stop\(\);\s*return;\s*\}/);
+ assert.match(hookSource, /if \(refreshOnVisible\) \{\s*pollRef\.current\(\);\s*\}/);
+ assert.match(hookSource, /window\.clearInterval\(timer\)/);
+ // The callback lives in a ref so an inline closure cannot restart the interval every render.
+ assert.match(hookSource, /const pollRef = useRef\(onPoll\)/);
+ assert.match(hookSource, /pollRef\.current = onPoll;/);
+ assert.match(hookSource, /\}, \[enabled, intervalMs, refreshOnVisible\]\);/);
+ // Reusable: no report-specific imports.
+ assert.doesNotMatch(hookSource, /personal-report|\/api\/reports/);
+});
+
+test("both report surfaces poll through the visibility-aware hook and nothing else", () => {
+ for (const [name, source] of [["page", pageSource], ["center", centerSource]] as const) {
+ assert.match(
+ source,
+ /import \{ useVisibilityAwarePoll \} from "@\/hooks\/use-visibility-aware-poll";/,
+ `${name} imports the shared hook`,
+ );
+ assert.match(source, /useVisibilityAwarePoll\(\{/, `${name} calls the shared hook`);
+ assert.doesNotMatch(source, /setInterval\(/, `${name} must not hand-roll an interval poll`);
+ assert.doesNotMatch(source, /addEventListener\("visibilitychange"/, `${name} must not duplicate the listener`);
+ }
+ assert.match(centerSource, /enabled: hasGenerating,/);
+ assert.match(centerSource, /intervalMs: LIST_POLL_INTERVAL_MS,/);
+ assert.match(centerSource, /const LIST_POLL_INTERVAL_MS = 3000;/);
+ assert.match(pageSource, /enabled: generating,/);
+});
+
+test("cancellation guards survive the rewrite and no poll runs outside the generating phase", () => {
+ assert.match(pageSource, /const cancelledRef = useRef\(false\)/);
+ assert.match(pageSource, /if \(cancelledRef\.current\) \{\s*return;\s*\}/);
+ assert.match(pageSource, /cancelledRef\.current = true;/);
+ assert.match(centerSource, /const cancelled = useRef\(false\)/);
+ assert.match(centerSource, /if \(cancelled\.current\) return;/);
+ assert.match(centerSource, /cancelled\.current = true;/);
+ assert.match(pageSource, /const generating = state\.phase === "generating";/);
+});
diff --git a/frontend/tests/root-error-boundaries-contract.test.ts b/frontend/tests/root-error-boundaries-contract.test.ts
new file mode 100644
index 00000000..790c74c8
--- /dev/null
+++ b/frontend/tests/root-error-boundaries-contract.test.ts
@@ -0,0 +1,69 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+const read = (relative: string) => readFileSync(new URL(relative, import.meta.url), "utf8");
+
+const error = read("../src/app/error.tsx");
+const globalError = read("../src/app/global-error.tsx");
+const notFound = read("../src/app/not-found.tsx");
+
+const chinese = /[\u4e00-\u9fff]/;
+
+test("the app root ships error, global-error and not-found boundaries", () => {
+ for (const source of [error, globalError, notFound]) {
+ assert.ok(source.length > 0);
+ assert.match(source, /export default function \w+\(/);
+ }
+});
+
+test("root error boundary is a client component that can reset and route home", () => {
+ assert.match(error, /^"use client";/);
+ assert.match(error, /reset,\s*\}: \{[\s\S]*reset: \(\) => void;/);
+ assert.match(error, /onClick=\{\(\) => reset\(\)\}/);
+ assert.match(error, /href="\/"/);
+ assert.match(error, /error\.digest/);
+ assert.doesNotMatch(error, /error\.stack/);
+});
+
+test("global error boundary replaces the root layout without external styling", () => {
+ assert.match(globalError, /^"use client";/);
+ assert.match(globalError, //);
+ assert.match(globalError, /
/);
+ assert.match(globalError, /<\/html>/);
+ assert.doesNotMatch(globalError, /^import /m);
+ assert.doesNotMatch(globalError, /className="[a-z-]*(flex|text-|bg-|min-h-)/);
+ assert.match(globalError, /minHeight: "44px"/);
+ assert.match(globalError, /onClick=\{\(\) => reset\(\)\}/);
+ assert.match(globalError, /window\.location\.assign\("\/"\)/);
+});
+
+test("root not found stays a server component and links back to the chat home", () => {
+ assert.doesNotMatch(notFound, /"use client"/);
+ assert.doesNotMatch(notFound, /useState|useEffect|onClick/);
+ assert.match(notFound, /404/);
+ assert.match(notFound, /render=\{\}/);
+});
+
+test("every boundary announces itself accessibly with one heading", () => {
+ for (const source of [error, globalError]) {
+ assert.match(source, /role="alert"/);
+ }
+ for (const source of [error, globalError, notFound]) {
+ assert.equal(source.match(/ {
+ for (const source of [error, globalError, notFound]) {
+ const copy = source.match(/>\s*([^<>{}]*[\u4e00-\u9fff][^<>{}]*)\s*= 2);
+ for (const line of copy) {
+ assert.match(line, chinese);
+ assert.doesNotMatch(line, /[!!]/);
+ assert.doesNotMatch(line, /\p{Extended_Pictographic}/u);
+ }
+ }
+});