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:
Jesse_Chen
2026-09-02 04:29:01 +00:00
co-authored by Claude Fable 5.1
parent eeb82dfdc4
commit f542c02490
17 changed files with 329 additions and 264 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
## Agent 聊天流式体验与双会话面统一(2026-09-01,分支 `codex/streaming-ux-20260901`)
- **任务 3(校正面复用 `ChatComposer` + 统一滚动跟随 + `JumpToLatestButton` + 720→900 宽度)未做,等待 `TASK-home-split-batch3-20260901.md` 合入。** 任务书红线 7:任务 3 要动 `page.tsx` 两处(按 `activeStreamingText` 触发的滚动 effect、内联 Tailwind 的「跳到最新」按钮),与第三批拆页冲突。开工时 `git log origin/staging -- frontend/src/app/page.tsx` 最新为 `551d6317`(第二批),第三批尚未合入,故按红线登记。随之延后的还有任务 4 里与任务 3 对应的 DESIGN.md 条目(§4 阅读宽度、§5 composer 共用一句、§5 新增 Jump to latest、§7 `--shadow-soft`)与 BUG-476/475。任务 0/1/2 未碰 `page.tsx`。
- **~~任务 3 未做,等待第三批拆页合入~~ 已解除并完成(2026-09-02)。** `origin/staging` 合入 `bf6989ec`(第三批)、`124d3990`、`058e5db9` 后,本分支 rebase 到其上,任务 3 按任务书原文完成:`page.tsx` 的滚动 effect 与内联「跳到最新」按钮删除,跟随并入 `useConversationScrollAnchor`,校正面接入同一 hook 与 `JumpToLatestButton`,复用 `ChatComposer`(`value` 受控、500 字上限),`rectification-sticky-scroll.ts` 删除,720px 覆写删除。对应 DESIGN.md 四条与 BUG-477/476 一并落地。
- **浏览器级手工验收未做:执行环境无登录态、无 Chrome。** 任务书里的 Performance 录制(3k 字回答无 >50ms 长任务)、结算不闪录屏、流式期间折叠时间线、校正/普通会话并排截图,全部留给有真实会话的人按 `docs/testing/staging-manual-walkthrough-20260901.md` 的方式补。本轮的替代证据是 `tests/home-streaming-render-split.test.ts` 的按帧驱动渲染计数与 `tests/stream-frame-buffer.test.ts` 的释放节奏断言。
- **`LatestAssistantEntry` 的「一次会话只 mount 一次」探针无法在测试里驱动。** 仓库没有 jsdom / happy-dom,`renderToString` 不跑 effect,所以 `latestEntryMounts` 只是留给浏览器里手动读的探针;测试改用纯函数 `latestAssistantView` 的 key 一致性 + 源码锁(只有一处 `<LatestAssistantEntry`、无 `StreamingMessageEntry`/`SettledMessageEntry`)代替。
+32
View File
@@ -7330,3 +7330,35 @@
- 相关记录:BUG-474、BUG-475
- 复发自:无
- 修复版本:待发布
## BUG-477 | 生时校正会话的输入框没有字数上限,也没有接近上限的计数
- 状态:resolved
- 首次发现:2026-09-01
- 最近更新:2026-09-02
- 影响面:`rectification-agentic-chat.tsx`、`chat-composer.tsx`
- 用户现象:校正会话里可以无限输入;主对话在剩余 50 字时会出现计数并在到顶时变红(BUG-447),校正会话什么都没有。停止按钮、发送按钮、textarea 也是另一份手写的。
- 触发条件:任何一次校正会话输入。
- 根因:校正面用裸 `<Textarea>` + 两个 `<Button>` 自己拼输入框,没有 `maxLength`,也没有接 `CharacterRemaining`;`ChatComposer` 只会读主会话的 `composer-draft` 外部 store,校正面有自己的 `draft` state,所以一直没复用。
- 修复:`ChatComposer` 新增可选受控 `value` 与 `remainingId`:有 `value` 时以其为准(store 订阅仍在组件内,不改主会话行为),校正面传 `value={draft}`,不写主会话 store。校正面渲染 `ChatComposer`(`maxLength=500`,与主对话同上限)并在 `composer-footer` 放 `CharacterRemaining`;三段 placeholder、Enter/Shift+Enter/`isComposing` 处理、停止按钮文案原样保留。
- 验证:`composer-isolation-contract`(校正传 `value`、不 import `composer-draft`)、`character-remaining-contract`、`composer-ime-contract`、`rectification-agentic-entry`。
- 防复发:不得再手写第二个聊天输入框;新增会话面一律渲染 `ChatComposer`,自有草稿走 `value`。
- 相关记录:BUG-447、BUG-478
- 复发自:无
- 修复版本:待发布
## BUG-478 | 滚动跟随与「跳到最新」在两个会话面各写一份,主会话每个 token 都触发一次 scrollTo
- 状态:resolved
- 首次发现:2026-09-01
- 最近更新:2026-09-02
- 影响面:`use-conversation-scroll-anchor.ts`、`page.tsx`、`rectification-agentic-chat.tsx`、`rectification-sticky-scroll.ts`(删除)、`globals.css`
- 用户现象:主会话流式期间每个 token 触发一次 `scrollTo`,回答结束瞬间再补一次 smooth 滚动,看起来"先跳到底再滑一下";校正面是另一套 rAF 跟随;「跳到最新」按钮两份不同样式(主会话内联 Tailwind `shadow-md`、校正面 `--shadow-soft`,文案一个「跳到最新」一个「回到最新」)。
- 触发条件:任意流式回答;向上翻阅历史。
- 根因:`page.tsx` 的滚动 effect 以 `activeStreamingText` 为依赖;校正面自带 `followTailRef` / `followLatestContent` / `rectification-sticky-scroll.ts`;按钮各写各的。
- 修复:跟随并入 `useConversationScrollAnchor`:`ResizeObserver` 观察滚动容器的子元素、`MutationObserver` 跟踪子元素增删,anchored 时下一帧 `scrollTop = scrollHeight`;`resetKey` 变化立即落底;不再在结算时补 smooth 滚动。`page.tsx` 的滚动 effect 删除;校正面接入同一 hook(`resetKey = caseId`),删除 `rectification-sticky-scroll.ts` 及其全部胶水。新增 `JumpToLatestButton`(`.jump-to-latest`,`--shadow-elevated`、44px、focus ring),两面共用,文案统一「跳到最新」带箭头;`page.tsx` 内联 Tailwind 版与 `.rectification-jump-latest` 删除。校正 `.message-list` 720px 覆写删除,与主会话同 900px。
- 验证:`chat-notice-and-scroll-contract`(follow 在 hook 内、anchored 守卫在赋值前、`page.tsx` 无 `scrollTo`)、`chat-navigation-a11y-contract`、`chat-stream-layout`、`rectification-agentic-entry`、`rectification-answer-choice`。
- 防复发:任何会话面不得自行监听 scroll 或按内容变化调 `scrollTo`;跟随只经 `useConversationScrollAnchor`,跳到最新只经 `JumpToLatestButton`。
- 相关记录:BUG-477
- 复发自:无
- 修复版本:待发布
+11 -2
View File
@@ -162,7 +162,7 @@ One documented exception: the thinking text inside a timeline step (`.consultati
The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3: 12px`, `--space-4: 16px`, `--space-5: 20px`, `--space-6: 24px`, `--space-8: 32px`, `--space-10: 40px`, `--space-12: 48px`, `--space-16: 64px`, and `--space-24: 96px`.
- Chat reading width: 760px for the welcome/composer and 900px for long answers.
- Chat reading width: 760px for the welcome/composer and 900px for long answers. Both chat surfaces share the 900px transcript width; the rectification session no longer narrows it to 720px.
- Admin content width: 1200px, centered.
- Desktop shell: 288px sidebar plus flexible reading panel.
- Layout breakpoints: mobile below 768px, tablet 768–1023px, desktop 1024px and above. These three decide the shell — sidebar mode, grid columns, drawer versus rail.
@@ -190,6 +190,15 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3:
- **Structure:** warm canvas field, hairline, typed value, optional icon action.
- **States:** default, hover, focus with deep-brown ring, disabled, invalid, loading.
- **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`.
### 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.
- **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.
- **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.
### Birth time intake
@@ -370,7 +379,7 @@ Only `transform`, `opacity`, and color/filter transitions animate. Reduced-motio
## 7. Depth & Surface
Strategy: warm tonal shifts, restrained translucency, and fine hairlines. Most depth comes from the page floor, main canvas, warm cards, and selected white glass. Standard UI uses no drop shadow. Modal and raised-sheet depth use one restrained two-stage token: `--shadow-elevated: 0 1px 2px rgba(29, 29, 31, .07), 0 12px 28px -16px rgba(29, 29, 31, .18)`. Interactive rings use `0 0 0 1px` or the focus ring; no generic card shadows or atmospheric gradients. When transparency is reduced, frosted surfaces fall back to opaque warm neutrals.
Strategy: warm tonal shifts, restrained translucency, and fine hairlines. Most depth comes from the page floor, main canvas, warm cards, and selected white glass. Standard UI uses no drop shadow. Modal, popover, floating-control and raised-sheet depth use one restrained two-stage token: `--shadow-elevated: 0 1px 2px rgba(29, 29, 31, .07), 0 12px 28px -16px rgba(29, 29, 31, .18)`. A second, quieter token, `--shadow-soft: 0 1px 2px rgba(29, 29, 31, .06), 0 8px 20px -18px rgba(29, 29, 31, .2)`, belongs to surfaces inside the reading column — the conversational narrative card, the composer field within a session, the starter theme card's focus state and the auth mode tab. Floating controls and overlays (jump to latest, popovers, sheets) always take `--shadow-elevated`. Interactive rings use `0 0 0 1px` or the focus ring; no generic card shadows, no utility-class shadows, no atmospheric gradients. When transparency is reduced, frosted surfaces fall back to opaque warm neutrals.
## 8. Agent wording and accuracy boundary
+36 -27
View File
@@ -1485,6 +1485,42 @@ button:disabled { cursor: default; opacity: .45; }
.error-message { margin: 12px 0 0; padding: 12px 14px; border-left: 3px solid var(--color-danger); background: var(--color-danger-muted); color: var(--color-danger); font-size: 13px; line-height: 1.6; border-color: var(--color-danger); border-radius: 0 var(--radius-md) var(--radius-md) 0; }
.composer-wrap { position: sticky; bottom: 0; z-index: 2; min-width: 0; border-top: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); padding: var(--space-3) var(--space-6) var(--space-4); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); }
/* Jump to latest: shared by both chat surfaces. Hangs off the composer wrap's top
edge so it never reflows the transcript or the composer. */
.jump-to-latest {
position: absolute;
inset-inline: 0;
bottom: 100%;
z-index: 3;
display: flex;
justify-content: center;
padding-bottom: var(--space-3);
pointer-events: none;
}
.jump-to-latest__button {
pointer-events: auto;
min-height: 44px;
min-width: 44px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 var(--space-4);
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-canvas);
color: var(--color-ink);
box-shadow: var(--shadow-elevated);
font: inherit;
font-size: var(--type-body-sm);
cursor: pointer;
transition: background-color 120ms ease-out;
}
.jump-to-latest__button svg { width: 16px; height: 16px; flex: 0 0 auto; }
.jump-to-latest__button:hover { background: var(--color-canvas-muted); }
.jump-to-latest__button:focus-visible {
outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent);
outline-offset: 3px;
}
.composer { width: min(760px, 100%); display: flex; align-items: flex-end; gap: 10px; margin: 0 auto; border: 1px solid var(--color-border); transition: border-color 140ms ease-out; min-height: 60px; padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4); border-color: var(--color-border-strong); border-radius: var(--radius-lg); background: var(--color-canvas); box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-border) 42%, transparent); }
.composer:focus-within { border-color: var(--color-action); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-action) 15%, transparent); }
.composer textarea { min-width: 0; min-height: 44px; max-height: 128px; flex: 1; resize: none; padding: 11px 0 8px; border: 0; outline: 0; background: transparent; color: var(--color-ink); line-height: 1.5; font-size: 16px; }
@@ -2608,7 +2644,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
}
.rectification-workspace__chat .message-list {
width: 100%;
max-width: 720px;
padding-bottom: var(--rectification-jump-clearance);
}
.conversation.is-rectification .message-actions {
@@ -2618,32 +2653,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
position: relative;
z-index: 2;
}
.rectification-jump-latest {
position: absolute;
inset-inline: 0;
bottom: 100%;
z-index: 3;
display: flex;
justify-content: center;
padding-bottom: var(--space-2);
pointer-events: none;
}
.rectification-jump-latest button {
pointer-events: auto;
min-height: 44px;
min-width: 44px;
padding: 0 var(--space-4);
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-canvas);
color: var(--color-ink);
box-shadow: var(--shadow-soft);
font-size: var(--type-caption);
}
.rectification-jump-latest button:focus-visible {
outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent);
outline-offset: 3px;
}
.rectification-workspace.is-compact.is-board-open .rectification-workspace__chat .composer-wrap {
z-index: 0;
visibility: hidden;
+3 -21
View File
@@ -4,7 +4,7 @@ import "@/app/site-styles";
import Link from "next/link";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { ArrowDown, Sparkles } from "lucide-react";
import { Sparkles } from "lucide-react";
import { InlineSpinner } from "@/components/inline-spinner";
import { useEffect, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
@@ -33,6 +33,7 @@ import { ChatTranscript, type ChatTranscriptActions } from "@/components/chat-tr
import { ModelSelector } from "@/components/model-selector";
import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall";
import { ChatComposer } from "@/components/chat-composer";
import { JumpToLatestButton } from "@/components/jump-to-latest-button";
import { ComposerCharacterRemaining } from "@/components/composer-character-remaining";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
@@ -1240,15 +1241,6 @@ export default function Home() {
setStarterGreetingSelection(Math.random());
}, [starterHomeVisible]);
useEffect(() => {
if (starterHomeVisible) return;
const container = conversation.current;
if (!container) return;
if (!conversationAnchor.anchored) return;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
container.scrollTo({ top: container.scrollHeight, behavior: isLoading || reduceMotion ? "auto" : "smooth" });
}, [activeSessionId, activeSession?.messages.length, activeStreamingText, conversationAnchor.anchored, isLoading, onboardingPending, onboardingStep, presetMessageFinished, profileComplete, starterHomeVisible]);
useEffect(() => {
if (hydrated && accountId && !profileComplete && onboardingStep === "name" && presetMessageFinished && activeAccountDialog === null) {
composerInput.current?.focus();
@@ -1924,17 +1916,7 @@ export default function Home() {
{!rectificationSurfaceOpen && !onboardingFormActive && <div className={`composer-wrap ${starterHomeVisible ? "composer-wrap-starter" : ""}`}>
{jumpToLatestVisible && (
<div className="pointer-events-none absolute inset-x-0 bottom-full flex justify-center pb-3">
<button
className="pointer-events-auto inline-flex min-h-11 min-w-11 items-center gap-1.5 rounded-full border border-border bg-canvas px-4 text-sm text-ink shadow-md transition-colors outline-none hover:bg-canvas-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
type="button"
aria-label="跳到最新"
onClick={conversationAnchor.anchorToLatest}
>
<ArrowDown aria-hidden="true" className="size-4" />
跳到最新
</button>
</div>
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
)}
<ChatComposer
inputRef={composerInput}
+15 -2
View File
@@ -24,6 +24,14 @@ export function useComposerDraft(): string {
type ChatComposerProps = {
readonly inputRef: RefObject<HTMLTextAreaElement | null>;
/**
* A surface that owns its own draft (the rectification session) passes it here;
* the main chat leaves it undefined and the composer reads the draft store.
*/
readonly value?: string;
readonly remainingId?: string;
/** Extra description (a question stem shown above the field) merged with the count id. */
readonly describedBy?: string;
readonly inputLabel: string;
readonly placeholder: string;
readonly maxLength: number;
@@ -41,6 +49,9 @@ type ChatComposerProps = {
export function ChatComposer({
inputRef,
value,
remainingId = composerRemainingId,
describedBy,
inputLabel,
placeholder,
maxLength,
@@ -55,15 +66,17 @@ export function ChatComposer({
onKeyDown,
onStop,
}: ChatComposerProps) {
const draft = useComposerDraft();
const storeDraft = useComposerDraft();
const draft = value ?? storeDraft;
const showRemaining = characterRemainingVisible(draft.length, maxLength);
const describedByIds = [describedBy, showRemaining ? remainingId : undefined].filter(Boolean).join(" ") || undefined;
return (
<form className="composer" onSubmit={onSubmit}>
<Textarea
ref={inputRef}
aria-label={inputLabel}
aria-describedby={showRemaining ? composerRemainingId : undefined}
aria-describedby={describedByIds}
placeholder={placeholder}
rows={1}
maxLength={maxLength}
@@ -0,0 +1,25 @@
"use client";
import { ArrowDown } from "lucide-react";
/**
* The "back to the newest turn" control both chat surfaces show once the
* reader has scrolled away from the bottom. It hangs off the composer's top
* edge (the composer wrap is its containing block) so it never reflows the
* transcript or the composer, and it is a real button in document order.
*/
export function JumpToLatestButton({ onClick }: Readonly<{ onClick: () => void }>) {
return (
<div className="jump-to-latest">
<button
type="button"
className="jump-to-latest__button"
aria-label="跳到最新"
onClick={onClick}
>
<ArrowDown aria-hidden="true" />
跳到最新
</button>
</div>
);
}
@@ -1,6 +1,5 @@
"use client";
import { ArrowUp, Square } from "lucide-react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { parseAgentReply } from "@/lib/agent-reply";
@@ -49,7 +48,6 @@ import {
isPublicRectificationTool,
} from "@/lib/rectification-agentic/v9/public-receipt";
import { userFacingRunFailure, isIncompleteRunBanner } from "@/lib/rectification-agentic/v9/run-diagnostic";
import { isNearBottom, shouldShowJumpToLatest } from "@/lib/rectification-sticky-scroll";
import {
CHOICE_ACTION,
STOP_ACTION,
@@ -66,7 +64,11 @@ import {
type RectificationChoiceCard as ChoiceCardModel,
} from "@/lib/rectification-agentic/v9/choice-card";
import type { PublicLanguageModel } from "@/lib/public-models";
import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor";
import { CharacterRemaining } from "./character-remaining";
import { ChatComposer } from "./chat-composer";
import { ChatMessageRow } from "./chat-message-row";
import { JumpToLatestButton } from "./jump-to-latest-button";
import {
ChatMessageActions,
toggleChatMessageFeedback,
@@ -75,7 +77,6 @@ import { ModelSelector } from "./model-selector";
import { RectificationBoard, RectificationBoardPeek } from "./rectification-board";
import { RectificationChoiceCard } from "./rectification-choice-card";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
type PersistedTurn = Readonly<{
id: string;
@@ -97,6 +98,9 @@ type PersistedTurn = Readonly<{
}> | null;
}>;
/** Same ceiling as the main chat composer, so both surfaces count down the same way. */
const RECTIFICATION_COMPOSER_MAX_LENGTH = 500;
type CandidateResult = RectificationCandidateResult | null;
type CurrentQuestionModel = Readonly<{
kind: "choice" | "collect_spoken";
@@ -317,17 +321,17 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const openingStarted = useRef(false);
const previousBoardResult = useRef<CandidateResult>(null);
const runAbort = useRef<AbortController | null>(null);
const followTailRef = useRef(true);
const scrollFrameRef = useRef<number | null>(null);
const choiceActionIds = useRef(new Map<string, string>());
const choiceCardsOpen = useRef(false);
const [showJumpToLatest, setShowJumpToLatest] = useState(false);
const [compactBoard, setCompactBoard] = useState(false);
const [boardOpen, setBoardOpen] = useState(false);
const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null));
const boardId = useId();
const boardTitleId = useId();
const collectSpokenPromptId = 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.
const conversationAnchor = useConversationScrollAnchor(conversation, true, caseId);
useLayoutEffect(() => {
const query = window.matchMedia(`(max-width: ${RECTIFICATION_BOARD_SPLIT_MIN_PX - 1}px)`);
@@ -358,50 +362,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onPendingChange?.(value);
}, [onPendingChange]);
const updateFollowState = useCallback(() => {
const viewport = conversation.current;
if (!viewport) return;
const nearBottom = isNearBottom(viewport.scrollHeight, viewport.scrollTop, viewport.clientHeight);
followTailRef.current = nearBottom;
setShowJumpToLatest(shouldShowJumpToLatest(
viewport.scrollHeight,
viewport.scrollTop,
viewport.clientHeight,
choiceCardsOpen.current,
));
}, []);
const scrollToLatest = useCallback((behavior: ScrollBehavior = "smooth") => {
const viewport = conversation.current;
if (!viewport) return;
followTailRef.current = true;
setShowJumpToLatest(false);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: reduceMotion ? "auto" : behavior,
});
}, []);
const followLatestContent = useCallback(() => {
if (!followTailRef.current) return;
if (scrollFrameRef.current !== null) {
cancelAnimationFrame(scrollFrameRef.current);
}
scrollFrameRef.current = requestAnimationFrame(() => {
const container = conversation.current;
if (!container || !followTailRef.current) return;
container.scrollTop = container.scrollHeight;
});
}, []);
useLayoutEffect(() => {
followLatestContent();
return () => {
if (scrollFrameRef.current !== null) cancelAnimationFrame(scrollFrameRef.current);
};
}, [busy, candidateResult, choiceCard, error, messages, savedTime, followLatestContent]);
// Candidate snapshot comes from the persisted Candidate Snapshot API, never
// from parsing agent text or hidden sentinels.
const applyCaseSnapshot = useCallback((payload: {
@@ -1071,10 +1031,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
&& !readonly
&& regeneratingMessageKey === null,
);
useLayoutEffect(() => {
choiceCardsOpen.current = showLiveChoiceCard;
updateFollowState();
}, [showLiveChoiceCard, updateFollowState]);
const showSelectionCards = Boolean(
candidateResult?.selectionAllowed
&& candidateResult?.canAdopt
@@ -1141,7 +1097,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
className="conversation is-rectification"
aria-label="生时校正对话"
aria-busy={busy || regeneratingMessageKey !== null}
onScroll={updateFollowState}
>
<div className="message-list">
{pendingConsultationQuestion?.trim() && (
@@ -1284,26 +1239,17 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</section>
<div className="composer-wrap">
{showJumpToLatest && (
<div className="rectification-jump-latest">
<button
type="button"
aria-label="回到最新"
onClick={() => scrollToLatest("smooth")}
>
回到最新
</button>
</div>
{!conversationAnchor.anchored && (
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
)}
<form className="composer" onSubmit={submit}>
<Textarea
ref={composer}
aria-label={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
aria-describedby={collectSpokenPrompt && !showLiveChoiceCard && !readonly
<ChatComposer
inputRef={composer}
describedBy={collectSpokenPrompt && !showLiveChoiceCard && !readonly
? collectSpokenPromptId
: undefined}
value={draft}
disabled={!canSend}
remainingId={composerRemainingId}
inputLabel={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
placeholder={readonly
? "该校正已结束,只能查看历史;需要再次校正请新建。"
: showLiveChoiceCard
@@ -1311,6 +1257,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
: collectSpokenPrompt
? "请回答上面的问题…"
: "继续说你记得的人生经历,或回答刚才的问题…"}
maxLength={RECTIFICATION_COMPOSER_MAX_LENGTH}
inputDisabled={!canSend}
submitLabel="发送"
submitBlocked={!canSend}
stopVisible={busy}
stopLabel="停止回答"
stopTitle="停止当前推理,已生成内容会保留"
onSubmit={submit}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
@@ -1318,33 +1272,22 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
event.currentTarget.form?.requestSubmit();
}
}}
onStop={stopRun}
/>
{busy ? (
<Button
className="composer-stop"
aria-label="停止回答"
title="停止当前推理,已生成内容会保留"
size="icon"
type="button"
onClick={stopRun}
>
<Square aria-hidden="true" />
</Button>
) : (
<Button aria-label="发送" disabled={!draft.trim() || !canSend} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
)}
</form>
<div className="composer-footer">
<ModelSelector
models={models}
selectedModelId={selectedModelId}
disabled={busy || readonly}
onSelect={onSelectModel}
/>
</div>
<div className="composer-footer">
<ModelSelector
models={models}
selectedModelId={selectedModelId}
disabled={busy || readonly}
onSelect={onSelectModel}
/>
<CharacterRemaining
id={composerRemainingId}
length={draft.length}
maxLength={RECTIFICATION_COMPOSER_MAX_LENGTH}
/>
</div>
</div>
</div>
<RectificationBoard
@@ -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: () => {
@@ -1,43 +0,0 @@
/**
* Sticky follow for a streaming message viewport.
*
* Auto-follow only when the user is already near the bottom. Streaming
* updates must not steal scrollTop after the user moves upward.
*/
export const RECTIFICATION_NEAR_BOTTOM_PX = 96;
/** Keep the jump chip hidden while a choice card still occupies the composer overlay band. */
export const RECTIFICATION_CHOICE_NEAR_BOTTOM_PX = 360;
export function distanceFromBottom(
scrollHeight: number,
scrollTop: number,
clientHeight: number,
): number {
return scrollHeight - scrollTop - clientHeight;
}
export function isNearBottom(
scrollHeight: number,
scrollTop: number,
clientHeight: number,
thresholdPx = RECTIFICATION_NEAR_BOTTOM_PX,
): boolean {
return distanceFromBottom(scrollHeight, scrollTop, clientHeight) <= thresholdPx;
}
export function shouldShowJumpToLatest(
scrollHeight: number,
scrollTop: number,
clientHeight: number,
choiceCardOpen = false,
): boolean {
const distance = distanceFromBottom(scrollHeight, scrollTop, clientHeight);
if (distance <= RECTIFICATION_NEAR_BOTTOM_PX) return false;
if (choiceCardOpen && distance <= RECTIFICATION_CHOICE_NEAR_BOTTOM_PX) return false;
return true;
}
export function shouldFollowLatest(followTail: boolean): boolean {
return followTail === true;
}
@@ -43,7 +43,11 @@ test("the count region is polite, atomic, and only mounted inside the threshold"
});
test("the four capped fields wire describedby to a count that lives in existing chrome", () => {
assert.match(composer, /aria-describedby=\{showRemaining \? composerRemainingId : undefined\}/);
// Former lock: `aria-describedby={showRemaining ? composerRemainingId : undefined}`. The composer
// is now shared with the rectification surface, which passes its own count id (BUG-477).
assert.match(composer, /remainingId = composerRemainingId/);
assert.match(composer, /showRemaining \? remainingId : undefined\]\.filter\(Boolean\)\.join\(" "\)/);
assert.match(composer, /aria-describedby=\{describedByIds\}/);
assert.match(page, /<ComposerCharacterRemaining maxLength=\{!profileComplete && onboardingStep === "name" \? 80 : 500\} \/>/);
assert.match(page, /className="composer-footer"/);
assert.match(page, /aria-describedby=\{characterRemainingVisible\(value\.name\.length, 80\) \? nameRemainingId : undefined\}/);
@@ -204,25 +204,36 @@ test("aria-busy marks the updating message list without swallowing the status re
});
test("the jump-to-latest control stays keyboard reachable and labelled", () => {
const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", "</button>");
const buttonTag = sourceBetween(jumpControl, "<button", ">");
// Former locks pinned the inline Tailwind button in page.tsx (`min-h-11`, `focus-visible:ring-3`,
// `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.
const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", ")}");
assert.match(jumpControl, /<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
const jumpSource = readFileSync(new URL("../src/components/jump-to-latest-button.tsx", import.meta.url), "utf8");
const buttonTag = sourceBetween(jumpSource, "<button", ">");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const buttonRule = styles.match(/\.jump-to-latest__button \{[^}]*\}/)?.[0] ?? "";
const wrapRule = styles.match(/\.jump-to-latest \{[^}]*\}/)?.[0] ?? "";
// Given: a real button in document order, never removed from the tab sequence.
assert.match(jumpControl, /<button\b/);
assert.match(jumpSource, /<button\b/);
assert.match(buttonTag, /type="button"/);
assert.doesNotMatch(buttonTag, /tabIndex/);
assert.doesNotMatch(buttonTag, /aria-hidden/);
assert.doesNotMatch(buttonTag, /disabled/);
// And: a visible label, a matching accessible name, a focus ring and a 44px target.
assert.match(jumpControl, /aria-label="跳到最新"/);
assert.match(jumpControl, /\n\s*跳到最新\n/);
assert.match(jumpControl, /focus-visible:ring-3/);
assert.match(jumpControl, /min-h-11/);
assert.match(jumpControl, /min-w-11/);
assert.match(jumpSource, /aria-label="跳到最新"/);
assert.match(jumpSource, /\n\s*跳到最新\n/);
assert.match(styles, /\.jump-to-latest__button:focus-visible \{[^}]*outline: 3px solid/);
assert.match(buttonRule, /min-height: 44px/);
assert.match(buttonRule, /min-width: 44px/);
// And: the icon is decorative, and pointer-events never gate keyboard activation.
assert.match(jumpControl, /<ArrowDown aria-hidden="true"/);
assert.match(jumpControl, /pointer-events-auto/);
assert.match(jumpControl, /onClick=\{conversationAnchor\.anchorToLatest\}/);
assert.match(jumpSource, /<ArrowDown aria-hidden="true"/);
assert.match(wrapRule, /pointer-events: none/);
assert.match(buttonRule, /pointer-events: auto/);
assert.match(jumpSource, /onClick=\{onClick\}/);
});
@@ -68,17 +68,19 @@ test("assigns notice severity by message intent", () => {
});
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(");
// Former lock: a page-level effect keyed on `activeStreamingText` that called
// `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).
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, /new ResizeObserver\(requestFollow\)/);
assert.match(anchorSource, /frame = window\.requestAnimationFrame\(follow\)/);
assert.match(anchorSource, /observe\(element, \{ childList: true \}\)/);
});
test("scrolls to the newest turn on intentional jumps", () => {
@@ -92,15 +94,22 @@ test("scrolls to the newest turn on intentional jumps", () => {
});
test("offers an accessible jump-to-latest control while reading history", () => {
const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", "</button>");
// Former locks pinned the inline Tailwind button in page.tsx (`min-h-11`, `focus-visible:ring-3`,
// `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.
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(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\}/);
assert.match(jumpControl, /<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
assert.match(jumpSource, /type="button"/);
assert.match(jumpSource, /aria-label="跳到最新"/);
assert.match(globalsSource, /\.jump-to-latest__button:focus-visible \{[^}]*outline: 3px solid/);
assert.match(buttonRule, /min-height: 44px/);
assert.match(buttonRule, /min-width: 44px/);
assert.match(buttonRule, /box-shadow: var\(--shadow-elevated\)/);
});
test("rests the jump-to-latest control on the composer instead of the padded scroller", () => {
@@ -111,9 +120,11 @@ test("rests the jump-to-latest control on the composer instead of the padded scr
// Then: the control hangs off the composer's own top edge, whatever that reserve is.
const composerWrap = sourceBetween(pageSource, "className={`composer-wrap ", "<ChatComposer");
const jumpControl = sourceBetween(composerWrap, "{jumpToLatestVisible && (", "</button>");
assert.match(jumpControl, /absolute inset-x-0 bottom-full/);
assert.doesNotMatch(jumpControl, /sticky/);
assert.match(composerWrap, /<JumpToLatestButton/);
const wrapRule = globalsSource.match(/\.jump-to-latest \{[^}]*\}/)?.[0] ?? "";
assert.match(wrapRule, /position: absolute/);
assert.match(wrapRule, /bottom: 100%/);
assert.doesNotMatch(wrapRule, /sticky/);
});
test("keeps the scroll listener passive and reduced-motion aware", () => {
+4 -1
View File
@@ -152,7 +152,10 @@ test("nothing sits between the transcript and the composer to shift height while
pageSource.indexOf("<ChatComposer"));
assert.doesNotMatch(composerWrap, /composer-suggestions/);
assert.match(composerWrap, /pointer-events-none absolute inset-x-0 bottom-full/);
// Former lock: the inline `pointer-events-none absolute inset-x-0 bottom-full` wrapper. The
// control is now the shared JumpToLatestButton, positioned by `.jump-to-latest` (BUG-478).
assert.match(composerWrap, /<JumpToLatestButton/);
assert.match(globalStyles, /\.jump-to-latest \{[^}]*position: absolute[^}]*bottom: 100%/);
});
test("docks the composer inside the chat panel instead of floating over content", () => {
@@ -42,7 +42,16 @@ test("the textarea lives in an isolated composer that owns the draft subscriptio
// When: the composer resolves the draft itself.
assert.match(composerSource, /useSyncExternalStore\(\n\s*subscribeComposerDraft,\n\s*composerDraftSnapshot,\n\s*serverComposerDraftSnapshot,\n\s*\)/);
assert.match(composerSource, /const draft = useComposerDraft\(\)/);
// Former lock: `const draft = useComposerDraft()`. The composer still owns the store
// subscription; a surface with its own draft (rectification) may pass `value` instead, and
// must never write the main chat's store (BUG-477).
assert.match(composerSource, /const storeDraft = useComposerDraft\(\);\n\s*const draft = value \?\? storeDraft;/);
const rectificationSource = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
assert.match(rectificationSource, /<ChatComposer[\s\S]*?value=\{draft\}/);
assert.doesNotMatch(rectificationSource, /composer-draft|setComposerDraft|useComposerDraft/);
// Then: only the composer subtree reads the value that changes on every keystroke.
assert.match(composerSource, /<Textarea[\s\S]*?value=\{draft\}/);
@@ -277,27 +277,19 @@ test("rectification uses one case-level entitlement and the session-pinned model
});
test("Agentic rectification follows the conversation tail only while the reader stays near the bottom", () => {
// Former locks pinned the rectification-only follow (`followTailRef`, `isNearBottom`,
// `shouldShowJumpToLatest`, `choiceCardsOpen`, `followLatestContent`, the 「回到最新」 chip and
// `.rectification-jump-latest`). That was the second scroll implementation beside the
// consultation hook (BUG-478); both surfaces now share `useConversationScrollAnchor` and
// `JumpToLatestButton`. The choice-card band is gone with it: the card sits inside the
// transcript, so a reader looking at it is near the bottom and anchored.
assert.match(chat, /const conversation = useRef<HTMLElement>\(null\)/);
assert.match(chat, /<section[\s\S]*ref=\{conversation\}[\s\S]*className="conversation is-rectification"/);
assert.match(chat, /followTailRef/);
assert.match(chat, /isNearBottom/);
assert.match(chat, /showJumpToLatest/);
assert.match(chat, /回到最新/);
assert.match(chat, /followLatestContent/);
assert.match(chat, /const container = conversation\.current/);
assert.match(chat, /top: viewport\.scrollHeight/);
assert.match(chat, /shouldShowJumpToLatest/);
assert.match(chat, /choiceCardsOpen/);
assert.match(
chat,
/useLayoutEffect\(\(\) => \{\s*choiceCardsOpen\.current = showLiveChoiceCard;\s*updateFollowState\(\);\s*\}, \[showLiveChoiceCard, updateFollowState\]\)/,
);
assert.doesNotMatch(chat, /choiceCardsOpen\.current = showLiveChoiceCard;\s*useLayoutEffect/);
assert.match(chat, /updateFollowState/);
assert.match(chat, /\}, \[busy, candidateResult, choiceCard, error, messages, savedTime, followLatestContent\]\);/);
const jumpLatestRule = styles.match(/\.rectification-jump-latest \{[^}]+\}/)?.[0] ?? "";
assert.match(jumpLatestRule, /justify-content: center/);
assert.doesNotMatch(jumpLatestRule, /flex-end/);
assert.match(chat, /const conversationAnchor = useConversationScrollAnchor\(conversation, true, caseId\)/);
assert.match(chat, /\{!conversationAnchor\.anchored && \(\s*<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
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/);
assert.match(styles, /--rectification-jump-clearance/);
assert.match(styles, /\.message-stage-and-answer \{[\s\S]*gap: var\(--space-2\)/);
assert.doesNotMatch(chat, /conversationEnd|scrollIntoView/);
@@ -515,8 +507,13 @@ test("compact board overlays chat as a bottom sheet above the composer", () => {
test("rectification composer can stop a live agent run", () => {
assert.match(chat, /signal: abortController\.signal/);
assert.match(chat, /runAbort\.current\?\.abort\(\)/);
assert.match(chat, /className="composer-stop"/);
assert.match(chat, /aria-label="停止回答"/);
// Former locks: `className="composer-stop"` and `aria-label="停止回答"` inside the chat file —
// the rectification-only composer. The stop control now comes from the shared ChatComposer
// (BUG-477); the chat passes the label and the handler.
assert.match(chat, /stopVisible=\{busy\}/);
assert.match(chat, /stopLabel="停止回答"/);
assert.match(chat, /onStop=\{stopRun\}/);
assert.match(readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8"), /className="composer-stop"/);
assert.match(chat, /event\.type === "attempt\.reset"/);
assert.match(chat, /caught\.name === "AbortError"/);
assert.match(chat, /showActivity=\{displayedMessage\.state !== "settled"\}/);
@@ -576,7 +573,12 @@ test("time-selection cards use server adoption state and stay mutually exclusive
});
test("rectification keeps the composer but never renders generated suggestion chips", () => {
assert.match(chat, /<form className="composer"/);
// Former lock: `<form className="composer"` written inline in the chat. The form now comes
// from the shared ChatComposer, with the same 500-character ceiling as the main chat (BUG-477).
assert.match(chat, /<ChatComposer/);
assert.match(chat, /maxLength=\{RECTIFICATION_COMPOSER_MAX_LENGTH\}/);
assert.match(chat, /const RECTIFICATION_COMPOSER_MAX_LENGTH = 500/);
assert.match(chat, /<CharacterRemaining/);
assert.doesNotMatch(chat, /composer-suggestions/);
assert.doesNotMatch(chat, /setSuggestions/);
assert.doesNotMatch(chat, /suggestions/);
@@ -13,7 +13,7 @@ import {
shouldContinueAfterStructuredChoice,
userVisibleChoiceLine,
} from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { isNearBottom, shouldFollowLatest, shouldShowJumpToLatest } from "../src/lib/rectification-sticky-scroll.ts";
import { conversationAnchorThreshold, nextAnchorState } from "../src/hooks/use-conversation-scroll-anchor.ts";
import { stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import {
spokenCollectFallbackFollowup,
@@ -542,17 +542,15 @@ function choiceAccounting(overrides: Parameters<typeof fakeAccounting>[0] = {})
}
test("does not force-follow when the reader has scrolled upward", () => {
assert.equal(isNearBottom(1_000, 100, 400), false);
assert.equal(shouldFollowLatest(false), false);
assert.equal(isNearBottom(1_000, 520, 400), true);
assert.equal(shouldFollowLatest(true), true);
});
test("jump-to-latest stays hidden while a choice card still sits in the overlay band", () => {
assert.equal(shouldShowJumpToLatest(1_000, 100, 400), true);
assert.equal(shouldShowJumpToLatest(1_000, 520, 400), false);
assert.equal(shouldShowJumpToLatest(1_000, 500, 400, true), false);
assert.equal(shouldShowJumpToLatest(1_000, 200, 400, true), true);
// Former locks exercised `rectification-sticky-scroll.ts` (`isNearBottom`, `shouldFollowLatest`,
// `shouldShowJumpToLatest` with a 360px choice-card band). That module was the second scroll
// implementation (BUG-478); the shared anchor keeps the same 96px near-bottom rule, and the
// jump control shows exactly when the reader is not anchored.
assert.equal(conversationAnchorThreshold, 96);
assert.equal(nextAnchorState(true, 1_000 - 100 - 400, true), false);
assert.equal(nextAnchorState(true, 1_000 - 520 - 400, false), true);
assert.equal(nextAnchorState(false, 600, false), false);
assert.equal(nextAnchorState(false, 40, false), true);
});
test("choice quotes come from the option label, not the assistant question year", () => {
@@ -1125,8 +1123,10 @@ test("the public agent route treats structured choice as a non-model command", (
assert.match(chat, /send\("read_only", ""\)/);
assert.match(chat, /willContinue/);
assert.match(chat, /current\.filter\(\(message\) => message\.renderKey !== assistantRenderKey\)/);
assert.match(chat, /回到最新/);
assert.match(chat, /followTailRef\.current/);
// 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, /<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
assert.match(chat, /conversationAnchor\.anchored/);
});
test("rectification attempt timeout stays under the agent route budget", () => {