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-01 23:26:23 +00:00
parent eeb82dfdc4
commit f542c02490
17 changed files with 329 additions and 264 deletions
+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;
}