diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5e19d683..da831aef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
+## 2026-09-17 — 回答从开头开始读:发送后问题钉在顶部,回答向下生长,长出视口时显示跳到最新
+
+主会话和生时校正发出新一轮后,视口停在这一轮的开头(你刚问的那一行,或校正里点选项后的新助手行),回答在下面往下长,不再跟着最后一个字往下跑。超过一屏时出现「跳到最新」,按下才跟到结尾。短回答下面会留一段空白,好让问题留在顶部。换到另一段对话仍先看到最新内容。Skill 版本不变。
+
## 2026-09-17 — 四个页面共用一份会话列表,切页不再重拉
「星盘」「星历」「我的报告」和对话页共用同一条侧栏。打开一次之后,在这四个页面之间切换不会再重新去拉会话列表,改名也会马上在各页看到。没说过话的「新对话」不再进列表。新开的生时校正和今日节奏标题改成「生时校正 · 9月14日」这种类别在前的写法,侧栏多一行创建时间。Skill 版本不变。
diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md
index bc26075f..22715f99 100644
--- a/docs/BUG_HISTORY.md
+++ b/docs/BUG_HISTORY.md
@@ -774,6 +774,7 @@
- 相关记录:BUG-039、BUG-040
- 复发自:无
- 修复版本:本次自动滚动修复提交
+- 2026-09-17 语义被 BUG-930 取代:生成中改为定位到本轮开头,不再贴底跟随。
## BUG-240 | 非评分回答绕过 Agent 并显示固定澄清模板
@@ -975,6 +976,7 @@
- 防复发:新增独立聊天滚动容器时必须同时提供末尾锚点,并覆盖乐观用户消息、生成状态和回答文本更新三类触发源。
- 相关记录:BUG-046
- 修复版本:待提交(本地可测)
+- 2026-09-17 语义被 BUG-930 取代:生成中改为定位到本轮开头,不再贴底跟随。
## BUG-049 | 生时校正 Agent 提问悬空截断且回答缺少消息操作
@@ -12130,3 +12132,19 @@
- 相关记录:BUG-699、BUG-557
- 复发自:无
- 修复版本:待发布
+
+## BUG-930 | 主会话和生时校正回答落在结尾,读不到开头
+
+- 状态:resolved
+- 首次发现:2026-09-17
+- 最近更新:2026-09-17
+- 影响面:`useConversationScrollAnchor`、主会话 `send()`、生时校正面提交 / 点选项 / 开场、`JumpToLatestButton`
+- 用户现象:问一个问题后视口钉在回答最后一个字上,要读回答得不断上滑找开头。生时校正长旁白同样把本轮开头推出视口。
+- 触发条件:主会话或生时校正发出一轮,模型输出超过一屏。
+- 根因:跟随语义是「贴底」。`anchored` 初值为真,`ResizeObserver` 在内容变高时把 `scrollTop` 设到 `scrollHeight`;`send()` 还会 `anchorToLatest()`。对短回答两者等价,对长回答则把开头推出视口。
+- 修复:同一 hook 改为新一轮把本轮开头(用户行,或没有用户行时的新助手行)钉在滚动容器顶部,流式期间不再跟随。回答长出视口时用已有的「跳到最新」贴底并恢复跟随。末尾动态留白让短回答也能把开头留在顶部。切换会话仍先落底。取代 BUG-041 / BUG-048 的贴底语义。
+- 验证:`chat-notice-and-scroll-contract.test.ts` 源码合同与行为测试;校正面 `pinLatestTurn` 三处源码断言。
+- 防复发:跟随只经 `useConversationScrollAnchor`,跳到最新只经 `JumpToLatestButton`;`send()` 必须调 `pinLatestTurn`;校正面新轮只在提交文字、点选项、开场/无用户行助手到达时各 pin 一次。
+- 相关记录:BUG-478、BUG-041、BUG-048
+- 复发自:无
+- 修复版本:待发布
diff --git a/docs/tasks/PROGRESS-consultation-answer-start-anchor-20260917.md b/docs/tasks/PROGRESS-consultation-answer-start-anchor-20260917.md
new file mode 100644
index 00000000..3a42ff16
--- /dev/null
+++ b/docs/tasks/PROGRESS-consultation-answer-start-anchor-20260917.md
@@ -0,0 +1,36 @@
+# PROGRESS · 回答定位到开头(2026-09-17)
+
+工作树:`worktree-green-harbor-5be3`
+分支:`worktree/green-harbor-5be3`
+基线:`origin/staging` @ `5203f9f0`(任务书扩到校正面后)
+本机 Windows。
+
+Skill **未 bump**。未改 Python、迁移、Skill。
+
+## 任务状态
+
+| 任务 | 状态 | 说明 |
+| --- | --- | --- |
+| T1 hook 改语义 `pinLatestTurn` BUG-930 | 完成 | 签名不变。新一轮钉本轮开头(用户行;无用户行则新助手行)。`send()` 改 `pinLatestTurn()`。校正面三处:文字提交、点选项、开场/采用新助手行。 |
+| T2 `latestBelowFold` | 完成 | 主会话 `jumpToLatestVisible` 与校正面按钮都改看 hook 的 `latestBelowFold`(末条底部超出视口 > 96px)。 |
+| T3 末尾动态留白 | 完成 | 最后一轮助手行 `min-height: calc(var(--conversation-viewport) - var(--latest-turn-head-height))`,变量由 hook 写到滚动容器 `style`。未改 `.conversation` 的 `padding-bottom`,无新增 sticky。 |
+| T4 思考块核实 | 完成 | 核实:`.consultation-thinking-report` 内 `ConsultationRunTimeline` 流式期间默认展开(`userOpen ?? live`),结算后折叠为一行;高度随步骤条数有界,不是逐 token 无界增长。未改。 |
+| T5 记录 | 完成 | BUG-930;BUG-041 / BUG-048 末尾各补一行;CHANGELOG;DESIGN Answer start anchor;真人清单。 |
+
+## 让步 / 偏离
+
+- `resetKey` 变化先落底;若当时就在底部 96px 内则保持跟随,直到 `pinLatestTurn` 或上滑。未在落底后强制置假,否则历史会话消息尚未 hydrate 时会停在顶部。与「打开历史落在底部」一致。
+- 本轮开头定位用 `getBoundingClientRect` 相对滚动容器,而不是字面 `row.offsetTop`(嵌套 `.message-entry` 时 `offsetTop` 不是容器坐标)。
+- 无 jsdom 依赖;行为测试用最小 fake scroller 行使导出函数。
+
+## 测试
+
+| 项 | 结果 |
+| --- | --- |
+| `tsc --noEmit` | 0 错 |
+| `npm run lint` | 0 error / 118 warning |
+| 定向 | chat-notice-and-scroll / a11y / rectification-agentic-entry / starter-questions / home-shell / chat-panel-scroll-guard / birth-time-mobile-scroll **92 passed / 0 failed**;rectification-answer-choice **34 passed / 0 failed** |
+| `page.tsx` 行数 | 开工 1830 → 现在 1830 |
+| `next build` | Compile + TypeScript 通过;collect page data 在本机 Windows 因 Skill 运行时 `symlink EPERM` 失败(与本轮无关的环境缺口),未核对 `/` Static 与 gzip |
+
+无登录态、无 Chrome:走查见 `docs/testing/consultation-answer-start-anchor-20260917.md`。
diff --git a/docs/tasks/README.md b/docs/tasks/README.md
index 933dfd3e..f05a5ad2 100644
--- a/docs/tasks/README.md
+++ b/docs/tasks/README.md
@@ -132,7 +132,7 @@
| `TASK-settings-dialog-size-and-nav-20260915.md` | — | **复发单**:设置弹窗四个分区尺寸仍随内容跳变(BUG-698,复发自 BUG-554——旧防复发只查「有没有写 height」,查不到「写了没生效」);首要嫌疑是 `.settings-modal` 的 `dvh` 没有 `vh` 回退,不支持时整条 `height` 作废退化成内容高度,需先复现确认。另按产品要求去掉分区菜单左侧强调条,并拆开与悬停共用的选中态 | 待领取 | `codex/settings-dialog-size-and-nav-20260915` |
| `TASK-consult-followup-tool-contract-20260917.md` | `PROGRESS-consult-followup-tool-contract-20260917.md` | 真机:申报时段会话连发「?」「你在说什么鬼」都 `run.failed runtime_contract_incomplete`,回执无任何 `tool` 步骤。根因是 Agent 系统指令写明「简单追问可复用已有 packet / context、不调工具」,而 `contractReady()` 要求每次请求恰好一次成功排盘调用;「已有 packet」跨请求并不存在(缓存只在单次请求内)。本命与窗口两个 Agent 同构。**产品拍板方案 1**:每轮必调工具(BUG-922 删例外句 + BUG-923 第 0 步 `toolChoice: required`);否决「没调工具就走不扣点纯对话」。第一轮正经问题为何失败留 T4 取证(回执只在 web 容器日志) | 待验收 | `codex/consult-followup-tool-contract-20260917` |
| `TASK-session-list-single-source-20260917.md` | `PROGRESS-session-list-single-source-20260917.md` | 会话列表一处数据源:本地 PG 兼容层 `order()` 只保留最后一键,`/api/sessions` 实际按 `id` 排、与游标不一致;`/` 与次级页两份数据源、`/` 每次回来重启动(产品拍板:首页与三个次级页进同一路由组,侧栏外壳与列表 provider 常驻 layout,不做 store);空「新对话」落库堆积(首页 50 条里 28 条);标题类别在后、同名靠墙钟 HH:MM。串行在 composer-guard 单之后。BUG 段 926 起 | 待验收(T1–T4;延迟落库让步) | `codex/session-list-single-source-20260917` |
-| `TASK-consultation-answer-start-anchor-20260917.md` | — | 主会话回答落在结尾:`useConversationScrollAnchor` 是贴底跟随,流式期间视口钉在最后一个字,回答开头滚出视口;改为发送后问题钉顶、回答向下长、长出视口显示「跳到最新」、末尾动态留白;产品追加拍板:校正面同一语义(推翻 BUG-041/048 贴底),本轮开头 = 用户行或新助手行。BUG 段 930 起 | 待领取 | — |
+| `TASK-consultation-answer-start-anchor-20260917.md` | `PROGRESS-consultation-answer-start-anchor-20260917.md` | 主会话回答落在结尾:`useConversationScrollAnchor` 是贴底跟随,流式期间视口钉在最后一个字,回答开头滚出视口;改为发送后问题钉顶、回答向下长、长出视口显示「跳到最新」、末尾动态留白;产品追加拍板:校正面同一语义(推翻 BUG-041/048 贴底),本轮开头 = 用户行或新助手行。BUG 段 930 起 | 待验收 | `worktree/green-harbor-5be3` |
### 个人报告
diff --git a/docs/testing/consultation-answer-start-anchor-20260917.md b/docs/testing/consultation-answer-start-anchor-20260917.md
new file mode 100644
index 00000000..31cedeb8
--- /dev/null
+++ b/docs/testing/consultation-answer-start-anchor-20260917.md
@@ -0,0 +1,10 @@
+# 真机清单 · 回答从开头开始读(2026-09-17)
+
+在 staging 上走。本机无登录态、无 Chrome。
+
+1. 主会话问一个会有长回答的问题(例如「详细分析我的事业格局」):发送后问题行在顶部,回答从它下面开始往下长,视口不跟着最后一个字跑。
+2. 回答超过一屏后出现「跳到最新」;按下贴底,并跟随到这一轮结束。
+3. 短回答(一两句)问题仍在顶部,下方有留白,不抖动。
+4. 切换到别的已有会话:落在底部(最新内容)。
+5. 生时校正:打字提交、点选项、开场三种情况下本轮开头都在顶部;长旁白时出现「跳到最新」,按下能看到选择卡。
+6. iPhone 键盘弹起再收起后位置不跳(配合 BUG-920 一起看)。
diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
index 37c5d0f7..1bc156c5 100644
--- a/frontend/DESIGN.md
+++ b/frontend/DESIGN.md
@@ -223,11 +223,21 @@ Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and
- **Accessibility:** persistent label where practical; composer has an explicit accessible label.
- **One composer:** the rectification surface renders the same `ChatComposer` as the main chat; there is no second composer. A surface that owns its own draft passes it as `value`; the main chat reads the draft store. Both count down from the same 500-character ceiling through `CharacterRemaining`, which the composer renders inline and only once `characterRemainingVisible()` is true — it no longer has a permanent container. Controls that belong to the composer go through the `toolbar` slot; nothing is stacked below it.
+### Answer start anchor
+
+Both chat surfaces pin a new turn at its head instead of following the last streamed token.
+
+- **Head:** the user row of this turn. When this turn has no user row (rectification choice tap, opening, auto-continue) the head is the new assistant row.
+- **Pin:** on send (and on those rectification new-turn entries) the scroller places that row at the top with a `space-4` gap and then holds still while the reply grows downward.
+- **Spacer:** the last assistant row's `min-height` is `calc(var(--conversation-viewport) - var(--latest-turn-head-height))`, both variables written onto the scroller by `useConversationScrollAnchor`. Not sticky, and `.conversation`'s `padding-bottom` is unchanged. The spacer lasts until the next send; switching sessions clears it.
+- **History:** opening another session still lands on the newest content once.
+- **Jump to latest:** if the growing turn (or the reader scrolling) leaves more than 96px below the fold, the shared chip appears; pressing it sticks to the bottom for the rest of the turn.
+
### Jump to latest
- **Structure:** one pill button (`JumpToLatestButton`) with a down arrow and the label “跳到最新”, shared by both chat surfaces.
- **Placement:** hangs off the composer wrap's top edge (`.jump-to-latest`, absolute, `bottom: 100%`) so it never reflows the transcript or the composer. It occupies a 56px band above the composer — the 44px target plus the `space-3` the overlay holds under itself — and the transcript has to reserve that band **plus** one more `space-3` below its last item, or the last tappable row reads as covered and its centre is where the chip's only `pointer-events: auto` area sits (BUG-919). On the rectification surface that reservation is `.message-list { padding-bottom: calc(var(--rectification-jump-clearance) + var(--space-3)) }`, and `.conversation` carries the matching `scroll-padding-block-end`. Full non-overlap at every scroll position would cost `conversationAnchorThreshold` (96px) on top of the band, i.e. 152px of trailing void; that was rejected — the chip only appears once the reader has scrolled up past that threshold.
-- **Visibility:** shown when the reader is not anchored — more than 96px from the bottom after scrolling up — and hidden again once they return or press it. Sending a question re-anchors.
+- **Visibility:** shown when the last turn extends more than 96px below the fold — either because the reader scrolled up, or because this turn grew past the viewport while pinned at its head — and hidden again once they return to the bottom or press it. Sending a question pins the turn head; pressing the control re-anchors to the bottom.
- **Surface:** canvas fill, hairline border, `--shadow-elevated`; hover uses the warm card surface. No utility-class shadows.
- **Accessibility:** a real button in document order with a visible label matching its accessible name, 44px target, and the focus ring; the icon is decorative.
diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx
index e7b328f0..d9ff7d34 100644
--- a/frontend/src/app/(app)/page.tsx
+++ b/frontend/src/app/(app)/page.tsx
@@ -783,7 +783,7 @@ export default function Home() {
});
const jumpToLatestVisible = !rectificationSurfaceOpen
&& !starterHomeVisible
- && !conversationAnchor.anchored
+ && conversationAnchor.latestBelowFold
&& Boolean(activeSession?.messages.length);
function setDraft(value: string) {
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 6b0f1a19..821303f2 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -1127,6 +1127,11 @@ button:disabled { cursor: default; opacity: .45; }
.message-content { min-width: 0; max-width: min(80%, 680px); }
.message-bubble { overflow: hidden; border: 0; padding: var(--space-3) var(--space-4); border-radius: var(--radius-lg); background: var(--color-canvas-muted); }
.message-assistant .message-bubble { border-radius: 0; background: transparent; padding: var(--space-3) 0; }
+/* Last turn fills the remaining viewport so a short reply can still pin its
+ head at the top. Variables are written by useConversationScrollAnchor. */
+.conversation .message-list > :last-child .message-assistant {
+ min-height: calc(var(--conversation-viewport, 0px) - var(--latest-turn-head-height, 0px));
+}
.message-thinking {
margin: 0 0 var(--space-3);
}
diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx
index fdb61611..5657f9f1 100644
--- a/frontend/src/components/rectification-agentic-chat.tsx
+++ b/frontend/src/components/rectification-agentic-chat.tsx
@@ -464,8 +464,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const boardId = useId();
const boardTitleId = useId();
const composerRemainingId = useId();
- // The same anchor-and-follow the consultation surface uses: streamed tokens and
- // new cards land the viewport on the bottom only while the reader is there.
+ // Same pin-the-turn-head hook as the consultation surface (BUG-930).
const conversationAnchor = useConversationScrollAnchor(conversation, true, caseId);
useLayoutEffect(() => {
@@ -717,6 +716,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
: []),
liveRow,
]));
+ if (!continuation) conversationAnchor.pinLatestTurn();
let raw = "";
let runOutcome: "succeeded" | "stopped" | "failed" = "failed";
let activityTrace: readonly AgentActivityTraceItem[] = emptyActivityTrace();
@@ -1079,6 +1079,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
},
},
]);
+ conversationAnchor.pinLatestTurn();
setPending(true);
const abortController = new AbortController();
runAbort.current = abortController;
@@ -1231,6 +1232,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
activity: { phase: "evidence-validation", label: adoptingLabel, startedAt: Date.now() },
},
]);
+ conversationAnchor.pinLatestTurn();
const abortController = new AbortController();
runAbort.current = abortController;
try {
@@ -1812,7 +1814,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
- {!conversationAnchor.anchored && (
+ {conversationAnchor.latestBelowFold && (
)}
void;
+ readonly pinLatestTurn: (target?: HTMLElement) => void;
};
export type ConsultationRunParams = {
@@ -657,7 +659,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
}
setOnboardingJustCompleted(false);
updateSession(sessionId, () => userSession);
- conversationAnchor.anchorToLatest();
+ conversationAnchor.pinLatestTurn();
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
diff --git a/frontend/src/hooks/use-conversation-scroll-anchor.ts b/frontend/src/hooks/use-conversation-scroll-anchor.ts
index 85c24a73..4c8a44c7 100644
--- a/frontend/src/hooks/use-conversation-scroll-anchor.ts
+++ b/frontend/src/hooks/use-conversation-scroll-anchor.ts
@@ -4,6 +4,8 @@ import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
export const conversationAnchorThreshold = 96;
+export const conversationPinSpaceToken = "--space-4";
+const pinSpaceFallbackPx = 16;
type AnchorState = {
readonly key: string;
@@ -12,7 +14,9 @@ type AnchorState = {
type ConversationScrollAnchor = {
readonly anchored: boolean;
+ readonly latestBelowFold: boolean;
readonly anchorToLatest: () => void;
+ readonly pinLatestTurn: (target?: HTMLElement) => void;
};
export function conversationDistanceFromBottom(container: HTMLElement) {
@@ -24,28 +28,114 @@ export function nextAnchorState(anchored: boolean, distanceFromBottom: number, s
return scrolledUp ? false : anchored;
}
+export function readPinSpacePx(element: HTMLElement): number {
+ if (typeof getComputedStyle !== "function") return pinSpaceFallbackPx;
+ const raw = getComputedStyle(element).getPropertyValue(conversationPinSpaceToken).trim();
+ const parsed = Number.parseFloat(raw);
+ return Number.isFinite(parsed) ? parsed : pinSpaceFallbackPx;
+}
+
+const DOCUMENT_POSITION_FOLLOWING = 4;
+
+export function isNodeAfter(earlier: Node, later: Node): boolean {
+ if (typeof earlier.compareDocumentPosition !== "function") return false;
+ return (earlier.compareDocumentPosition(later) & DOCUMENT_POSITION_FOLLOWING) !== 0;
+}
+
+/**
+ * Turn head is the latest user row, unless an assistant sits after it with
+ * another assistant in between (choice / opening / auto-continue: this turn
+ * has no user row) or there is no user row at all.
+ */
+export function resolveTurnHead(container: ParentNode, target?: HTMLElement | null): HTMLElement | null {
+ if (target) return target;
+ const users = Array.from(container.querySelectorAll(".message-user")) as HTMLElement[];
+ const assistants = Array.from(container.querySelectorAll(".message-assistant")) as HTMLElement[];
+ const lastUser = users.at(-1) ?? null;
+ const lastAssistant = assistants.at(-1) ?? null;
+ if (!lastUser) return lastAssistant;
+ if (!lastAssistant) return lastUser;
+ if (!isNodeAfter(lastUser, lastAssistant)) return lastUser;
+ const between = assistants.some((row) => isNodeAfter(lastUser, row) && isNodeAfter(row, lastAssistant));
+ return between ? lastAssistant : lastUser;
+}
+
+export function offsetWithinScroller(scroller: HTMLElement, node: HTMLElement): number {
+ return node.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop;
+}
+
+export function pinTurnScrollTop(scroller: HTMLElement, row: HTMLElement, spacePx: number): number {
+ return Math.max(0, offsetWithinScroller(scroller, row) - spacePx);
+}
+
+export function applyFollowBottom(
+ element: { scrollTop: number; scrollHeight: number },
+ anchored: boolean,
+): void {
+ if (!anchored) return;
+ element.scrollTop = element.scrollHeight;
+}
+
+export function applyTurnSpacer(container: HTMLElement, head: HTMLElement | null): void {
+ container.style.setProperty("--conversation-viewport", `${container.clientHeight}px`);
+ container.style.setProperty("--latest-turn-head-height", `${head?.offsetHeight ?? 0}px`);
+}
+
+export function clearTurnSpacer(container: HTMLElement): void {
+ container.style.removeProperty("--conversation-viewport");
+ container.style.removeProperty("--latest-turn-head-height");
+}
+
+export function lastTurnTail(container: HTMLElement): HTMLElement | null {
+ return (container.querySelector(".message-list > :last-child .message-assistant")
+ ?? container.querySelector(".message-assistant:last-of-type")
+ ?? container.querySelector(".message-user:last-of-type")) as HTMLElement | null;
+}
+
+export function turnTailOverflow(container: HTMLElement): number {
+ const last = lastTurnTail(container);
+ if (!last) return conversationDistanceFromBottom(container);
+ return last.offsetTop + last.offsetHeight - (container.scrollTop + container.clientHeight);
+}
+
+export function latestContentBelowFold(overflow: number): boolean {
+ return overflow > conversationAnchorThreshold;
+}
+
/**
* 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.
+ * children, one frame per change). Both chat surfaces use this one hook.
+ *
+ * A new turn pins its head (the user row, or the new assistant when this turn
+ * has no user row) at the top and does not follow streamed growth. Switching
+ * conversations still lands on the newest content once.
*/
export function useConversationScrollAnchor(
container: RefObject,
active: boolean,
resetKey: string,
): ConversationScrollAnchor {
- const [anchor, setAnchor] = useState({ key: resetKey, anchored: true });
+ const [anchor, setAnchor] = useState({ key: resetKey, anchored: false });
+ const [latestBelowFold, setLatestBelowFold] = useState(false);
const lastScrollTop = useRef(0);
- const anchoredRef = useRef(true);
+ const anchoredRef = useRef(false);
+ const shouldLand = useRef(true);
+ const holdUnpin = useRef(false);
+ const pinnedHeadRef = useRef(null);
const anchored = anchor.key === resetKey ? anchor.anchored : true;
useEffect(() => {
anchoredRef.current = anchored;
}, [anchored]);
+ useEffect(() => {
+ shouldLand.current = true;
+ holdUnpin.current = false;
+ pinnedHeadRef.current = null;
+ }, [resetKey]);
+
useEffect(() => {
const element = container.current;
if (!active || !element) return;
@@ -53,9 +143,20 @@ export function useConversationScrollAnchor(
let frame = 0;
const measure = () => {
frame = 0;
+ const overflow = turnTailOverflow(element);
const distance = conversationDistanceFromBottom(element);
const scrolledUp = element.scrollTop < lastScrollTop.current;
lastScrollTop.current = element.scrollTop;
+ setLatestBelowFold(latestContentBelowFold(overflow));
+ if (holdUnpin.current) {
+ if (scrolledUp) {
+ holdUnpin.current = false;
+ } else if (distance <= conversationAnchorThreshold) {
+ holdUnpin.current = false;
+ } else {
+ return;
+ }
+ }
setAnchor((current) => {
const currentAnchored = current.key === resetKey ? current.anchored : true;
const next = nextAnchorState(currentAnchored, distance, scrolledUp);
@@ -76,15 +177,31 @@ export function useConversationScrollAnchor(
// 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.
+ // (resetKey) lands there immediately. Unanchored turns only refresh the spacer.
useEffect(() => {
const element = container.current;
if (!active || !element) return;
let frame = 0;
const follow = () => {
frame = 0;
- if (!anchoredRef.current) return;
- element.scrollTop = element.scrollHeight;
+ if (shouldLand.current) {
+ element.scrollTop = element.scrollHeight;
+ shouldLand.current = false;
+ holdUnpin.current = false;
+ pinnedHeadRef.current = null;
+ clearTurnSpacer(element);
+ const atBottom = conversationDistanceFromBottom(element) <= conversationAnchorThreshold;
+ anchoredRef.current = atBottom;
+ setAnchor({ key: resetKey, anchored: atBottom });
+ setLatestBelowFold(false);
+ return;
+ }
+ if (anchoredRef.current) {
+ element.scrollTop = element.scrollHeight;
+ } else {
+ applyTurnSpacer(element, pinnedHeadRef.current ?? resolveTurnHead(element));
+ }
+ setLatestBelowFold(latestContentBelowFold(turnTailOverflow(element)));
};
const requestFollow = () => {
if (frame) return;
@@ -118,13 +235,45 @@ export function useConversationScrollAnchor(
return {
anchored,
+ latestBelowFold,
anchorToLatest: () => {
const element = container.current;
+ holdUnpin.current = false;
+ shouldLand.current = false;
if (element) {
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
element.scrollTo({ top: element.scrollHeight, behavior: reduceMotion ? "auto" : "smooth" });
+ setLatestBelowFold(false);
}
setAnchor({ key: resetKey, anchored: true });
},
+ pinLatestTurn: (target?: HTMLElement) => {
+ const element = container.current;
+ const run = () => {
+ shouldLand.current = false;
+ holdUnpin.current = true;
+ anchoredRef.current = false;
+ if (!element) {
+ setAnchor({ key: resetKey, anchored: false });
+ setLatestBelowFold(false);
+ return;
+ }
+ const head = resolveTurnHead(element, target);
+ pinnedHeadRef.current = head;
+ applyTurnSpacer(element, head);
+ const space = readPinSpacePx(element);
+ const top = head ? pinTurnScrollTop(element, head, space) : 0;
+ const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ element.scrollTo({ top, behavior: reduceMotion ? "auto" : "smooth" });
+ lastScrollTop.current = element.scrollTop;
+ setAnchor({ key: resetKey, anchored: false });
+ setLatestBelowFold(latestContentBelowFold(turnTailOverflow(element)));
+ };
+ if (typeof window === "undefined") {
+ run();
+ return;
+ }
+ window.requestAnimationFrame(run);
+ },
};
}
diff --git a/frontend/tests/chat-navigation-a11y-contract.test.ts b/frontend/tests/chat-navigation-a11y-contract.test.ts
index 9534f9ea..6e30ef35 100644
--- a/frontend/tests/chat-navigation-a11y-contract.test.ts
+++ b/frontend/tests/chat-navigation-a11y-contract.test.ts
@@ -61,7 +61,7 @@ test("both insufficient-credit paths are soft so the typed question is not throw
assert.match(creditGuard, /openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/);
assert.doesNotMatch(creditGuard, /window\.location/);
const creditPush = sendSource.indexOf('openAccountDialog("billing", { source: "insufficient-credits" })');
- const sendClearsComposer = sendSource.indexOf("conversationAnchor.anchorToLatest();\n setDraft(\"\");");
+ const sendClearsComposer = sendSource.indexOf("conversationAnchor.pinLatestTurn();\n setDraft(\"\");");
assert.ok(creditPush >= 0 && sendClearsComposer > creditPush);
assert.equal(pageSource.match(/openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/g)?.length, 2);
});
diff --git a/frontend/tests/chat-notice-and-scroll-contract.test.ts b/frontend/tests/chat-notice-and-scroll-contract.test.ts
index 1e6a3737..c7649fd1 100644
--- a/frontend/tests/chat-notice-and-scroll-contract.test.ts
+++ b/frontend/tests/chat-notice-and-scroll-contract.test.ts
@@ -3,7 +3,17 @@ 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";
+import {
+ applyFollowBottom,
+ applyTurnSpacer,
+ clearTurnSpacer,
+ conversationAnchorThreshold,
+ latestContentBelowFold,
+ nextAnchorState,
+ pinTurnScrollTop,
+ resolveTurnHead,
+ turnTailOverflow,
+} from "../src/hooks/use-conversation-scroll-anchor.ts";
import { homeSurface } from "./home-surface.ts";
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
@@ -72,12 +82,12 @@ test("anchors the streaming scroll instead of following every token", () => {
// `container.scrollTo` after an `anchored` guard. That effect ran once per token and added
// a second smooth scroll on settle; the follow now lives in the hook, driven by a
// ResizeObserver over the scroller's children, one frame per change (BUG-478).
+ // 原值: follow() 在 !anchored 时直接 return
+ // 新值: follow() 仅在 anchored 时写 scrollTop = scrollHeight,未钉住时只刷新 spacer
+ // 原因: BUG-930 流式期间视口钉在本轮开头,留白变量仍要随高度更新
assert.doesNotMatch(pageSource, /container\.scrollTo\(/);
- const follow = sourceBetween(anchorSource, "const follow = () => {", "};");
- assert.match(follow, /if \(!anchoredRef\.current\) return/);
- const scrollGuard = follow.indexOf("if (!anchoredRef.current) return");
- const scrollCall = follow.indexOf("element.scrollTop = element.scrollHeight");
- assert.ok(scrollGuard >= 0 && scrollGuard < scrollCall);
+ assert.match(anchorSource, /if \(anchoredRef\.current\) \{\s*element\.scrollTop = element\.scrollHeight;/);
+ assert.match(anchorSource, /applyTurnSpacer\(element,/);
assert.match(anchorSource, /new ResizeObserver\(requestFollow\)/);
assert.match(anchorSource, /frame = window\.requestAnimationFrame\(follow\)/);
assert.match(anchorSource, /observe\(element, \{ childList: true \}\)/);
@@ -88,9 +98,12 @@ test("scrolls to the newest turn on intentional jumps", () => {
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.
+ // 原值: send() 调 anchorToLatest(),乐观入列后贴底
+ // 新值: send() 调 pinLatestTurn(),下一帧把本轮开头钉到顶部
+ // 原因: BUG-930
const sendBlock = sourceBetween(homeSurface, " updateSession(sessionId, () => userSession);", " setDraft(\"\");");
- assert.match(sendBlock, /conversationAnchor\.anchorToLatest\(\)/);
+ assert.match(sendBlock, /conversationAnchor\.pinLatestTurn\(\)/);
+ assert.doesNotMatch(sendBlock, /conversationAnchor\.anchorToLatest\(\)/);
});
test("offers an accessible jump-to-latest control while reading history", () => {
@@ -98,11 +111,14 @@ test("offers an accessible jump-to-latest control while reading history", () =>
// `shadow-md`, `pointer-events-auto`, `absolute inset-x-0 bottom-full`). That button was one of
// two jump controls with two styles, and `shadow-md` bypassed the §7 shadow token (BUG-478).
// Both surfaces now render `JumpToLatestButton`; its semantics live in the component and CSS.
+ // 原值: jumpToLatestVisible 看 !conversationAnchor.anchored
+ // 新值: 看 conversationAnchor.latestBelowFold(底部距离 > 96px)
+ // 原因: BUG-930 回答长出视口时即使读者没主动上滑也要出现「跳到最新」
const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", ")}");
const jumpSource = readFileSync(new URL("../src/components/jump-to-latest-button.tsx", import.meta.url), "utf8");
const buttonRule = globalsSource.match(/\.jump-to-latest__button \{[^}]*\}/)?.[0] ?? "";
- assert.match(pageSource, /const jumpToLatestVisible = !rectificationSurfaceOpen[\s\S]*?&& !conversationAnchor\.anchored/);
+ assert.match(pageSource, /const jumpToLatestVisible = !rectificationSurfaceOpen[\s\S]*?&& conversationAnchor\.latestBelowFold/);
assert.match(jumpControl, //);
assert.match(jumpSource, /type="button"/);
assert.match(jumpSource, /aria-label="跳到最新"/);
@@ -143,3 +159,178 @@ test("re-anchors once the reader returns to the newest turn", () => {
assert.equal(nextAnchorState(false, 600, false), false);
assert.equal(nextAnchorState(false, 40, false), true);
});
+
+test("pins a new turn at the head instead of following streamed growth", () => {
+ const user = fakeRow("message-user", 16, 48);
+ const assistant = fakeRow("message-assistant", 80, 200);
+ const scroller = fakeScroller([user, assistant], 400);
+ const pinned = pinTurnScrollTop(asElement(scroller), asElement(user), 16);
+ scroller.scrollTop = pinned;
+ const start = scroller.scrollTop;
+ for (let i = 0; i < 5; i += 1) {
+ assistant.offsetHeight += 80;
+ scroller.scrollHeight += 80;
+ applyFollowBottom(scroller, false);
+ }
+ assert.equal(scroller.scrollTop, start);
+ assert.equal(start, user.offsetTop - 16);
+
+ applyFollowBottom(scroller, true);
+ assert.equal(scroller.scrollTop, scroller.scrollHeight);
+});
+
+test("latestBelowFold tracks overflow past the fold and clears at the bottom", () => {
+ const user = fakeRow("message-user", 16, 48);
+ const assistant = fakeRow("message-assistant", 80, 200);
+ const scroller = fakeScroller([user, assistant], 400);
+ scroller.scrollTop = pinTurnScrollTop(asElement(scroller), asElement(user), 16);
+ assert.equal(latestContentBelowFold(turnTailOverflow(asElement(scroller))), false);
+
+ assistant.offsetHeight = 1200;
+ scroller.scrollHeight = assistant.offsetTop + assistant.offsetHeight + 24;
+ assert.equal(latestContentBelowFold(turnTailOverflow(asElement(scroller))), true);
+ assert.ok(turnTailOverflow(asElement(scroller)) > conversationAnchorThreshold);
+
+ scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight;
+ assert.equal(latestContentBelowFold(turnTailOverflow(asElement(scroller))), false);
+});
+
+test("resolveTurnHead prefers the user row unless this turn has no user", () => {
+ const typedUser = fakeRow("message-user", 8, 40);
+ const typedAssistant = fakeRow("message-assistant", 64, 80);
+ const typed = fakeScroller([typedUser, typedAssistant], 400);
+ assert.equal(resolveTurnHead(asElement(typed)), asElement(typedUser));
+
+ const priorUser = fakeRow("message-user", 8, 40);
+ const priorAssistant = fakeRow("message-assistant", 64, 80);
+ const choiceAssistant = fakeRow("message-assistant", 160, 80);
+ const choice = fakeScroller([priorUser, priorAssistant, choiceAssistant], 400);
+ assert.equal(resolveTurnHead(asElement(choice)), asElement(choiceAssistant));
+
+ const opening = fakeRow("message-assistant", 0, 80);
+ const openingScroller = fakeScroller([opening], 400);
+ assert.equal(resolveTurnHead(asElement(openingScroller)), asElement(opening));
+});
+
+test("turn spacer uses conversation viewport variables and is not sticky", () => {
+ assert.match(
+ globalsSource,
+ /\.conversation \.message-list > :last-child \.message-assistant \{[^}]*min-height: calc\(var\(--conversation-viewport, 0px\) - var\(--latest-turn-head-height, 0px\)\)/,
+ );
+ const spacerRule = sourceBetween(
+ globalsSource,
+ ".conversation .message-list > :last-child .message-assistant {",
+ "}",
+ );
+ assert.doesNotMatch(spacerRule, /sticky/);
+ assert.doesNotMatch(anchorSource, /position:\s*sticky/);
+
+ const scroller = fakeScroller([], 400);
+ const head = fakeRow("message-user", 16, 48);
+ applyTurnSpacer(asElement(scroller), asElement(head));
+ assert.equal(scroller.style.getPropertyValue("--conversation-viewport"), "400px");
+ assert.equal(scroller.style.getPropertyValue("--latest-turn-head-height"), "48px");
+ clearTurnSpacer(asElement(scroller));
+ assert.equal(scroller.style.getPropertyValue("--conversation-viewport"), "");
+ assert.equal(scroller.style.getPropertyValue("--latest-turn-head-height"), "");
+});
+
+test("consultation send and rectification new turns call pinLatestTurn", () => {
+ const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
+ const pins = chat.match(/conversationAnchor\.pinLatestTurn\(\)/g) ?? [];
+ assert.ok(pins.length >= 3, `expected three pinLatestTurn calls, got ${pins.length}`);
+ assert.doesNotMatch(chat, /conversation\.current\?\.scrollTo|element\.scrollTo/);
+ assert.match(chat, /conversationAnchor\.latestBelowFold/);
+ assert.match(anchorSource, /window\.requestAnimationFrame\(run\)/);
+});
+
+type FakeRow = {
+ className: string;
+ offsetTop: number;
+ offsetHeight: number;
+ order: number;
+ compareDocumentPosition: (other: Node) => number;
+ getBoundingClientRect: () => DOMRect;
+};
+
+type FakeScroller = {
+ rows: FakeRow[];
+ scrollTop: number;
+ clientHeight: number;
+ scrollHeight: number;
+ children: unknown[];
+ style: {
+ setProperty: (name: string, value: string) => void;
+ getPropertyValue: (name: string) => string;
+ removeProperty: (name: string) => void;
+ };
+ querySelectorAll: (selector: string) => FakeRow[];
+ querySelector: (selector: string) => FakeRow | null;
+ getBoundingClientRect: () => DOMRect;
+};
+
+function asElement(value: FakeRow | FakeScroller): HTMLElement {
+ return value as unknown as HTMLElement;
+}
+
+function fakeRow(className: string, offsetTop: number, offsetHeight: number): FakeRow {
+ const row = {
+ className,
+ offsetTop,
+ offsetHeight,
+ order: 0,
+ compareDocumentPosition(other: Node) {
+ const theirOrder = (other as unknown as FakeRow).order ?? 0;
+ if (this.order < theirOrder) return 4;
+ if (this.order > theirOrder) return 2;
+ return 0;
+ },
+ getBoundingClientRect() {
+ return { top: this.offsetTop, left: 0, right: 0, bottom: this.offsetTop + this.offsetHeight, width: 0, height: this.offsetHeight, x: 0, y: this.offsetTop, toJSON() { return this; } };
+ },
+ };
+ return row;
+}
+
+function fakeScroller(rows: FakeRow[], clientHeight: number): FakeScroller {
+ rows.forEach((row, index) => {
+ row.order = index;
+ });
+ const last = rows.at(-1);
+ const styleValues = new Map();
+ const scroller = {
+ rows,
+ scrollTop: 0,
+ clientHeight,
+ scrollHeight: last ? last.offsetTop + last.offsetHeight + 24 : clientHeight,
+ children: [],
+ style: {
+ setProperty(name: string, value: string) {
+ styleValues.set(name, value);
+ },
+ getPropertyValue(name: string) {
+ return styleValues.get(name) ?? "";
+ },
+ removeProperty(name: string) {
+ styleValues.delete(name);
+ },
+ },
+ querySelectorAll(selector: string) {
+ const token = selector.replace(".", "");
+ return rows.filter((row) => row.className === token);
+ },
+ querySelector(selector: string) {
+ if (selector.includes(":last-child .message-assistant") || selector.includes(".message-assistant:last-of-type")) {
+ return rows.filter((row) => row.className === "message-assistant").at(-1) ?? null;
+ }
+ if (selector.includes(".message-user:last-of-type")) {
+ return rows.filter((row) => row.className === "message-user").at(-1) ?? null;
+ }
+ return null;
+ },
+ getBoundingClientRect() {
+ return { top: 0, left: 0, right: 0, bottom: clientHeight, width: 0, height: clientHeight, x: 0, y: 0, toJSON() { return this; } };
+ },
+ };
+ return scroller;
+}
diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts
index 1c1d7e21..7bf72abf 100644
--- a/frontend/tests/rectification-agentic-entry.test.ts
+++ b/frontend/tests/rectification-agentic-entry.test.ts
@@ -341,7 +341,10 @@ test("Agentic rectification follows the conversation tail only while the reader
assert.match(chat, /const conversation = useRef\(null\)/);
assert.match(chat, //);
+ // 原值: !conversationAnchor.anchored
+ // 新值: conversationAnchor.latestBelowFold
+ // 原因: BUG-930 两个面同一套「本轮开头钉顶」,跳到最新看内容是否长出视口
+ assert.match(chat, /\{conversationAnchor\.latestBelowFold && \(\s*/);
assert.doesNotMatch(chat, /followTailRef|isNearBottom|shouldShowJumpToLatest|choiceCardsOpen|followLatestContent|updateFollowState|回到最新|onScroll=/);
assert.doesNotMatch(styles, /\.rectification-jump-latest/);
assert.match(styles, /\.jump-to-latest \{[^}]*justify-content: center/);
diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts
index e02fe3b4..a0e45481 100644
--- a/frontend/tests/rectification-answer-choice.test.ts
+++ b/frontend/tests/rectification-answer-choice.test.ts
@@ -1457,7 +1457,10 @@ test("the public agent route treats structured choice as a non-model command", (
// Former locks: `回到最新` and `followTailRef.current` — the rectification-only chip and follow
// flag (BUG-478). The shared control and anchor are locked instead.
assert.match(chat, //);
- assert.match(chat, /conversationAnchor\.anchored/);
+ // 原值: conversationAnchor.anchored
+ // 新值: conversationAnchor.latestBelowFold
+ // 原因: BUG-930
+ assert.match(chat, /conversationAnchor\.latestBelowFold/);
});
test("rectification attempt timeout stays under the agent route budget", () => {