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:
co-authored by
Claude Fable 5.1
parent
eeb82dfdc4
commit
f542c02490
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user