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,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