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:
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user