merge(frontend): fix #356 — restaure le suivi automatique du scroll des fils de messages (QA verte)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:15:19 +02:00
7 changed files with 512 additions and 144 deletions

View File

@ -6169,11 +6169,12 @@ describe("CustomAgentChatView", () => {
const scrollTo = vi.fn(); const scrollTo = vi.fn();
Object.defineProperty(scroll, "scrollTo", { value: scrollTo, configurable: true }); Object.defineProperty(scroll, "scrollTo", { value: scrollTo, configurable: true });
fireEvent.scroll(scroll); fireEvent.scroll(scroll);
fireEvent.click(screen.getByRole("button", { name: "Aller au dernier message" })); expect(screen.queryByRole("button", { name: "Aller au dernier message" })).toBeNull();
expect(scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "smooth" });
fireEvent.click(screen.getByRole("button", { name: "Aller au Final précédent" })); fireEvent.click(screen.getByRole("button", { name: "Aller au Final précédent" }));
expect(scrollTo).toHaveBeenLastCalledWith({ top: 0, behavior: "smooth" }); expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: "smooth" });
fireEvent.click(screen.getByRole("button", { name: "Aller au dernier message" }));
expect(scrollTo).toHaveBeenLastCalledWith({ top: 1000, behavior: "smooth" });
}); });
it("surfaces non-NOT_FOUND reattach errors instead of launching a new session", async () => { it("surfaces non-NOT_FOUND reattach errors instead of launching a new session", async () => {

View File

@ -43,7 +43,14 @@ import {
markProjectCliInteractionStarted, markProjectCliInteractionStarted,
markProjectCliInteractionStopped, markProjectCliInteractionStopped,
} from "@/features/projects/projectCliInteractionActivity"; } from "@/features/projects/projectCliInteractionActivity";
import { Button, EntryTimestamp, Spinner, cn, normalizeTimestamp } from "@/shared"; import {
Button,
EntryTimestamp,
Spinner,
cn,
normalizeTimestamp,
useThreadScrollController,
} from "@/shared";
import { onProjectIconChanged, ProjectIcon } from "@/features/projects/projectIcons"; import { onProjectIconChanged, ProjectIcon } from "@/features/projects/projectIcons";
import type { import type {
ChatAttachmentInput, ChatAttachmentInput,
@ -1054,7 +1061,6 @@ export function CustomAgentChatView({
} | null>(null); } | null>(null);
const [queueAnnouncement, setQueueAnnouncement] = useState(""); const [queueAnnouncement, setQueueAnnouncement] = useState("");
const [queueDrainReady, setQueueDrainReady] = useState(false); const [queueDrainReady, setQueueDrainReady] = useState(false);
const [showLatestButton, setShowLatestButton] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]); const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [slashMenuOpen, setSlashMenuOpen] = useState(false); const [slashMenuOpen, setSlashMenuOpen] = useState(false);
const [slashActiveIndex, setSlashActiveIndex] = useState(0); const [slashActiveIndex, setSlashActiveIndex] = useState(0);
@ -1071,7 +1077,6 @@ export function CustomAgentChatView({
const [stopTerminalOverride, setStopTerminalOverride] = useState(false); const [stopTerminalOverride, setStopTerminalOverride] = useState(false);
const [error, setError] = useState<ChatErrorState | null>(null); const [error, setError] = useState<ChatErrorState | null>(null);
const [projectIcon, setProjectIcon] = useState<ProjectIconRef | null>(null); const [projectIcon, setProjectIcon] = useState<ProjectIconRef | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<HTMLTextAreaElement | null>(null); const composerRef = useRef<HTMLTextAreaElement | null>(null);
const chatRootRef = useRef<HTMLDivElement | null>(null); const chatRootRef = useRef<HTMLDivElement | null>(null);
const turnsRef = useRef(turns); const turnsRef = useRef(turns);
@ -1111,7 +1116,6 @@ export function CustomAgentChatView({
const queuedMessagesRef = useRef(queuedMessages); const queuedMessagesRef = useRef(queuedMessages);
queuedMessagesRef.current = queuedMessages; queuedMessagesRef.current = queuedMessages;
const messageQueueKeyRef = useRef(initialMessageQueueKey); const messageQueueKeyRef = useRef(initialMessageQueueKey);
const followsLatestRef = useRef(true);
const imageTokenSeqRef = useRef(0); const imageTokenSeqRef = useRef(0);
const mountedRef = useRef(false); const mountedRef = useRef(false);
const openOrAttachCountRef = useRef(0); const openOrAttachCountRef = useRef(0);
@ -1252,6 +1256,19 @@ export function CustomAgentChatView({
const [composerHistoryIndex, setComposerHistoryIndex] = const [composerHistoryIndex, setComposerHistoryIndex] =
useState<number | null>(null); useState<number | null>(null);
const displayTurns = turns; const displayTurns = turns;
const threadScroll = useThreadScrollController({
itemCount: displayTurns.length,
completedItemCount: displayTurns.filter(
(turn) => turn.role === "final" || turn.role === "error",
).length,
contentVersion: displayTurns
.map((turn) => {
if ("text" in turn) return `${turn.role}:${turn.text.length}`;
if (turn.role === "tool") return `${turn.role}:${turn.label.length}`;
return `${turn.role}:${JSON.stringify(turn.progress).length}:${Boolean(turn.settled)}`;
})
.join("|"),
});
const hasPreviousFinal = displayTurns.some((turn) => turn.role === "final"); const hasPreviousFinal = displayTurns.some((turn) => turn.role === "final");
const sessionLabel = structuredSessionLabel(profile.structuredAdapter); const sessionLabel = structuredSessionLabel(profile.structuredAdapter);
const [requesterDelegation, setRequesterDelegation] = const [requesterDelegation, setRequesterDelegation] =
@ -1976,32 +1993,11 @@ export function CustomAgentChatView({
); );
const scrollToLatest = useCallback(() => { const scrollToLatest = useCallback(() => {
const element = scrollRef.current; threadScroll.scrollToBottom(prefersReducedMotion() ? "auto" : "smooth");
if (!element) return; }, [prefersReducedMotion, threadScroll]);
const top = element.scrollHeight;
if (element.scrollTo) {
element.scrollTo({
top,
behavior: prefersReducedMotion() ? "auto" : "smooth",
});
} else {
element.scrollTop = top;
}
followsLatestRef.current = true;
setShowLatestButton(false);
}, [prefersReducedMotion]);
const updateLatestVisibility = useCallback(() => {
const element = scrollRef.current;
if (!element) return;
const atBottom =
element.scrollHeight - element.scrollTop - element.clientHeight <= 24;
followsLatestRef.current = atBottom;
setShowLatestButton(!atBottom);
}, []);
const scrollToPreviousFinal = useCallback(() => { const scrollToPreviousFinal = useCallback(() => {
const element = scrollRef.current; const element = threadScroll.scrollRef.current;
if (!element) return; if (!element) return;
const finals = Array.from( const finals = Array.from(
element.querySelectorAll<HTMLElement>("[data-chat-final]"), element.querySelectorAll<HTMLElement>("[data-chat-final]"),
@ -2020,16 +2016,8 @@ export function CustomAgentChatView({
} else { } else {
element.scrollTop = top; element.scrollTop = top;
} }
followsLatestRef.current = false; threadScroll.readHistory();
setShowLatestButton(true); }, [prefersReducedMotion, threadScroll]);
}, [prefersReducedMotion]);
useEffect(() => {
const element = scrollRef.current;
if (!element || !followsLatestRef.current) return;
element.scrollTop = element.scrollHeight;
setShowLatestButton(false);
}, [displayTurns]);
useEffect(() => { useEffect(() => {
if (selfEmittedSessionIdRef.current === sessionId) { if (selfEmittedSessionIdRef.current === sessionId) {
@ -2523,6 +2511,7 @@ export function CustomAgentChatView({
markProjectCliInteractionFinished(projectId, interactionId); markProjectCliInteractionFinished(projectId, interactionId);
activeInteractionIdRef.current = null; activeInteractionIdRef.current = null;
}; };
threadScroll.followLatest();
setBusy(true); setBusy(true);
setError(null); setError(null);
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
@ -2959,10 +2948,10 @@ export function CustomAgentChatView({
/> />
</div> </div>
<div <div
ref={scrollRef} ref={threadScroll.scrollRef}
data-testid="custom-agent-chat-scroll" data-testid="custom-agent-chat-scroll"
className="relative z-10 flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3" className="relative z-10 flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3"
onScroll={updateLatestVisibility} onScroll={threadScroll.onScroll}
> >
<div className="shrink-0"> <div className="shrink-0">
<AnnouncementsPreview <AnnouncementsPreview
@ -3038,14 +3027,16 @@ export function CustomAgentChatView({
mobile && "pb-[max(0.5rem,env(safe-area-inset-bottom))]", mobile && "pb-[max(0.5rem,env(safe-area-inset-bottom))]",
)} )}
> >
{showLatestButton && ( {threadScroll.notice && (
<button <button
type="button" type="button"
aria-label={tr("Aller au dernier message", "Go to latest message")} aria-label={tr("Aller au dernier message", "Go to latest message")}
className="absolute -top-12 left-1/2 z-20 flex h-11 w-11 -translate-x-1/2 items-center justify-center rounded-full border border-border bg-raised/95 text-lg text-content shadow-md hover:border-border-strong" className="absolute -top-10 left-1/2 z-20 -translate-x-1/2 rounded-full border border-border bg-raised/95 px-3 py-1 text-xs text-content shadow-md hover:border-border-strong"
onClick={scrollToLatest} onClick={scrollToLatest}
> >
↓ {threadScroll.notice === "new"
? tr("Nouveaux messages ↓", "New messages ↓")
: tr("Revenir en bas ↓", "Back to bottom ↓")}
</button> </button>
)} )}
<div className="sr-only" aria-live="polite" aria-atomic="true"> <div className="sr-only" aria-live="polite" aria-atomic="true">

View File

@ -18,7 +18,13 @@ import type {
TurnSource, TurnSource,
TurnView, TurnView,
} from "@/domain"; } from "@/domain";
import { Button, EntryTimestamp, Spinner, cn } from "@/shared"; import {
Button,
EntryTimestamp,
Spinner,
cn,
useThreadScrollController,
} from "@/shared";
import { useProjectWorkState } from "@/features/workstate"; import { useProjectWorkState } from "@/features/workstate";
import { useConversationThread } from "./useConversationThread"; import { useConversationThread } from "./useConversationThread";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
@ -205,6 +211,13 @@ export function ConversationViewer({
}: ConversationViewerProps) { }: ConversationViewerProps) {
const { tr } = useI18n(); const { tr } = useI18n();
const vm = useConversationThread(projectId, conversationId); const vm = useConversationThread(projectId, conversationId);
const threadScroll = useThreadScrollController({
itemCount: vm.turns.length,
completedItemCount: vm.turns.length,
contentVersion: vm.turns
.map((turn) => `${turn.id}:${turn.textLen}:${turn.text.length}`)
.join("|"),
});
// Reuse the already-loaded work-state inventory to resolve agent names. // Reuse the already-loaded work-state inventory to resolve agent names.
const work = useProjectWorkState(projectId); const work = useProjectWorkState(projectId);
@ -300,76 +313,96 @@ export function ConversationViewer({
)} )}
{/* ── Thread ── */} {/* ── Thread ── */}
<div className="flex flex-1 flex-col gap-2 overflow-auto px-4 py-3"> <div className="relative flex min-h-0 flex-1">
{vm.hasMore && ( <div
<div className="flex justify-center"> ref={threadScroll.scrollRef}
<Button data-testid="conversation-viewer-scroll"
size="sm" className="flex min-h-0 flex-1 flex-col gap-2 overflow-auto px-4 py-3"
variant="ghost" onScroll={threadScroll.onScroll}
onClick={() => void vm.loadOlder()} >
loading={vm.busy} {vm.hasMore && (
> <div className="flex justify-center">
{tr("Charger les tours précédents", "Load previous turns")}
</Button>
</div>
)}
{vm.busy && vm.turns.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>{tr("Chargement du fil…", "Loading thread…")}</span>
</div>
) : vm.turns.length === 0 ? (
<div className="max-w-xl text-sm text-muted">
<p>{emptyState.message}</p>
{emptyState.detail && (
<p className="mt-1 text-xs text-faint">{emptyState.detail}</p>
)}
{emptyState.action && onOpenConversation && (
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="mt-2"
onClick={() => onClick={() =>
onOpenConversation(emptyState.action!.conversationId) void threadScroll.preserveAnchor(() => vm.loadOlder())
} }
loading={vm.busy}
> >
{emptyState.action.label} {tr("Charger les tours précédents", "Load previous turns")}
</Button> </Button>
)} </div>
</div> )}
) : (
vm.turns.map((turn) => { {vm.busy && vm.turns.length === 0 ? (
const side = sideOf(turn.source); <div className="flex items-center gap-2 text-sm text-muted">
const sideClass = side === "right" ? "items-end" : "items-start"; <Spinner size={14} />
if (turn.role === "toolActivity") { <span>{tr("Chargement du fil…", "Loading thread…")}</span>
return ( </div>
<ToolTurn key={turn.id} turn={turn} sideClass={sideClass} /> ) : vm.turns.length === 0 ? (
); <div className="max-w-xl text-sm text-muted">
} <p>{emptyState.message}</p>
const party = parties.meta.get(partyKey(turn.source)); {emptyState.detail && (
return ( <p className="mt-1 text-xs text-faint">{emptyState.detail}</p>
<div key={turn.id} className={cn("flex flex-col", sideClass)}> )}
<div {emptyState.action && onOpenConversation && (
className={cn( <Button
"max-w-[80%] rounded-lg border px-3 py-2 text-sm", size="sm"
side === "right" variant="ghost"
? "border-primary/20 bg-primary/10 text-content" className="mt-2"
: "border-border bg-raised text-content", onClick={() =>
)} onOpenConversation(emptyState.action!.conversationId)
}
> >
<div className="mb-1 flex items-baseline gap-2 text-xs text-muted"> {emptyState.action.label}
<span className="font-medium text-content"> </Button>
{party?.label ?? "?"} )}
</span> </div>
<span>· {roleLabel(turn.role, tr)}</span> ) : (
<EntryTimestamp atMs={turn.atMs} /> vm.turns.map((turn) => {
const side = sideOf(turn.source);
const sideClass = side === "right" ? "items-end" : "items-start";
if (turn.role === "toolActivity") {
return (
<ToolTurn key={turn.id} turn={turn} sideClass={sideClass} />
);
}
const party = parties.meta.get(partyKey(turn.source));
return (
<div key={turn.id} className={cn("flex flex-col", sideClass)}>
<div
className={cn(
"max-w-[80%] rounded-lg border px-3 py-2 text-sm",
side === "right"
? "border-primary/20 bg-primary/10 text-content"
: "border-border bg-raised text-content",
)}
>
<div className="mb-1 flex items-baseline gap-2 text-xs text-muted">
<span className="font-medium text-content">
{party?.label ?? "?"}
</span>
<span>· {roleLabel(turn.role, tr)}</span>
<EntryTimestamp atMs={turn.atMs} />
</div>
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
</div> </div>
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
</div> </div>
</div> );
); })
}) )}
</div>
{threadScroll.notice && (
<button
type="button"
className="absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full border border-border bg-raised/95 px-3 py-1 text-xs text-content shadow-md hover:border-border-strong"
onClick={() => threadScroll.scrollToBottom()}
>
{threadScroll.notice === "new"
? tr("Nouveaux messages ↓", "New messages ↓")
: tr("Revenir en bas ↓", "Back to bottom ↓")}
</button>
)} )}
</div> </div>
</div> </div>

View File

@ -8,9 +8,9 @@
* all session/streaming logic lives in the hook. * all session/streaming logic lives in the hook.
*/ */
import { useEffect, useRef, useState } from "react"; import { useState } from "react";
import { Button, Spinner, cn } from "@/shared"; import { Button, Spinner, cn, useThreadScrollController } from "@/shared";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { TicketViewportSelect } from "./TicketViewportSelect"; import { TicketViewportSelect } from "./TicketViewportSelect";
import { useTicketAssistant } from "./useTicketAssistant"; import { useTicketAssistant } from "./useTicketAssistant";
@ -29,18 +29,22 @@ export function TicketAssistantPanel({
const [profileId, setProfileId] = useState(""); const [profileId, setProfileId] = useState("");
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
// Auto-scroll the transcript to the newest content as it streams in. const threadScroll = useThreadScrollController({
const scrollRef = useRef<HTMLDivElement>(null); itemCount: vm.turns.length,
useEffect(() => { completedItemCount: vm.turns.filter(
const el = scrollRef.current; (turn) => turn.role === "assistant" && !turn.pending,
if (el) el.scrollTop = el.scrollHeight; ).length,
}, [vm.turns]); contentVersion: vm.turns
.map((turn) => `${turn.role}:${turn.text.length}:${Boolean(turn.pending)}`)
.join("|"),
});
const open = vm.sessionId !== null; const open = vm.sessionId !== null;
function submit() { function submit() {
const text = draft.trim(); const text = draft.trim();
if (!text || vm.busy) return; if (!text || vm.busy) return;
threadScroll.followLatest();
void vm.send(text); void vm.send(text);
setDraft(""); setDraft("");
} }
@ -95,40 +99,55 @@ export function TicketAssistantPanel({
</Button> </Button>
</div> </div>
<div <div className="relative">
ref={scrollRef} <div
className="flex max-h-96 min-h-32 flex-col gap-2 overflow-auto rounded-md border border-border bg-raised/40 p-3" ref={threadScroll.scrollRef}
> data-testid="ticket-assistant-scroll"
{vm.turns.length === 0 ? ( className="flex max-h-96 min-h-32 flex-col gap-2 overflow-auto rounded-md border border-border bg-raised/40 p-3"
<p className="m-auto text-xs text-muted"> onScroll={threadScroll.onScroll}
{tr("Démarrez la conversation en décrivant ce que l'assistant doit faire sur ce ticket.", "Start the conversation by describing what the assistant should do with this ticket.")} >
</p> {vm.turns.length === 0 ? (
) : ( <p className="m-auto text-xs text-muted">
vm.turns.map((turn, i) => ( {tr("Démarrez la conversation en décrivant ce que l'assistant doit faire sur ce ticket.", "Start the conversation by describing what the assistant should do with this ticket.")}
<div </p>
key={i} ) : (
className={cn( vm.turns.map((turn, i) => (
"flex",
turn.role === "user" ? "justify-end" : "justify-start",
)}
>
<div <div
key={i}
className={cn( className={cn(
"max-w-[85%] whitespace-pre-wrap break-words rounded-lg px-3 py-2 text-sm", "flex",
turn.role === "user" turn.role === "user" ? "justify-end" : "justify-start",
? "bg-primary/15 text-content"
: "bg-surface text-content",
)} )}
> >
{turn.text} <div
{turn.pending && ( className={cn(
<span className="ml-1 inline-flex items-center align-middle text-muted"> "max-w-[85%] whitespace-pre-wrap break-words rounded-lg px-3 py-2 text-sm",
<Spinner size={12} /> turn.role === "user"
</span> ? "bg-primary/15 text-content"
)} : "bg-surface text-content",
)}
>
{turn.text}
{turn.pending && (
<span className="ml-1 inline-flex items-center align-middle text-muted">
<Spinner size={12} />
</span>
)}
</div>
</div> </div>
</div> ))
)) )}
</div>
{threadScroll.notice && (
<button
type="button"
className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full border border-border bg-raised/95 px-3 py-1 text-xs text-content shadow-md hover:border-border-strong"
onClick={() => threadScroll.scrollToBottom()}
>
{threadScroll.notice === "new"
? tr("Nouveaux messages ↓", "New messages ↓")
: tr("Revenir en bas ↓", "Back to bottom ↓")}
</button>
)} )}
</div> </div>

View File

@ -7,6 +7,11 @@
export { cn } from "./lib/cn"; export { cn } from "./lib/cn";
export type { ClassValue } from "./lib/cn"; export type { ClassValue } from "./lib/cn";
export { useThreadScrollController } from "./lib/useThreadScrollController";
export type {
ThreadScrollController,
ThreadScrollNotice,
} from "./lib/useThreadScrollController";
export { Button } from "./ui/Button"; export { Button } from "./ui/Button";
export type { ButtonProps, ButtonVariant, ButtonSize } from "./ui/Button"; export type { ButtonProps, ButtonVariant, ButtonSize } from "./ui/Button";

View File

@ -0,0 +1,148 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useThreadScrollController } from "./useThreadScrollController";
function setMetrics(
element: HTMLElement,
metrics: { scrollHeight: number; clientHeight: number; scrollTop: number },
) {
Object.defineProperty(element, "scrollHeight", {
configurable: true,
value: metrics.scrollHeight,
});
Object.defineProperty(element, "clientHeight", {
configurable: true,
value: metrics.clientHeight,
});
element.scrollTop = metrics.scrollTop;
}
function Harness() {
const [items, setItems] = useState(1);
const [completed, setCompleted] = useState(1);
const [version, setVersion] = useState(1);
const scroll = useThreadScrollController({
itemCount: items,
completedItemCount: completed,
contentVersion: version,
});
return (
<>
<div ref={scroll.scrollRef} data-testid="scroll" onScroll={scroll.onScroll}>
<div>content</div>
</div>
<span data-testid="notice">{scroll.notice ?? "none"}</span>
<button onClick={() => setVersion((value) => value + 1)}>stream</button>
<button
onClick={() => {
setItems((value) => value + 1);
setCompleted((value) => value + 1);
setVersion((value) => value + 1);
}}
>
complete
</button>
<button onClick={scroll.followLatest}>send</button>
<button onClick={() => scroll.scrollToBottom("auto")}>bottom</button>
<button
onClick={() =>
void scroll.preserveAnchor(() => {
const element = scroll.scrollRef.current!;
Object.defineProperty(element, "scrollHeight", {
configurable: true,
value: 1200,
});
})
}
>
prepend
</button>
</>
);
}
describe("useThreadScrollController", () => {
let resize: ResizeObserverCallback;
beforeEach(() => {
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: ResizeObserverCallback) {
resize = callback;
}
observe() {}
disconnect() {}
},
);
});
afterEach(() => vi.unstubAllGlobals());
it("does not move historical reading and distinguishes growth from a completed message", () => {
render(<Harness />);
const element = screen.getByTestId("scroll");
setMetrics(element, { scrollHeight: 1000, clientHeight: 300, scrollTop: 400 });
fireEvent.scroll(element);
fireEvent.click(screen.getByText("stream"));
expect(element.scrollTop).toBe(400);
expect(screen.getByTestId("notice").textContent).toBe("growth");
fireEvent.click(screen.getByText("complete"));
expect(element.scrollTop).toBe(400);
expect(screen.getByTestId("notice").textContent).toBe("new");
});
it("reactivates follow mode on send and on a manual return to bottom", () => {
render(<Harness />);
const element = screen.getByTestId("scroll");
setMetrics(element, { scrollHeight: 1000, clientHeight: 300, scrollTop: 400 });
fireEvent.scroll(element);
fireEvent.click(screen.getByText("send"));
expect(element.scrollTop).toBe(1000);
expect(screen.getByTestId("notice").textContent).toBe("none");
element.scrollTop = 400;
fireEvent.scroll(element);
const scrollTo = vi.fn();
Object.defineProperty(element, "scrollTo", { configurable: true, value: scrollTo });
fireEvent.click(screen.getByText("bottom"));
expect(scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "auto" });
expect(screen.getByTestId("notice").textContent).toBe("none");
});
it("follows near the bottom across late resizes but leaves history fixed", () => {
render(<Harness />);
const element = screen.getByTestId("scroll");
setMetrics(element, { scrollHeight: 1000, clientHeight: 300, scrollTop: 680 });
fireEvent.scroll(element);
Object.defineProperty(element, "scrollHeight", { configurable: true, value: 1100 });
resize([], {} as ResizeObserver);
expect(element.scrollTop).toBe(1100);
element.scrollTop = 400;
fireEvent.scroll(element);
Object.defineProperty(element, "scrollHeight", { configurable: true, value: 1200 });
resize([], {} as ResizeObserver);
expect(element.scrollTop).toBe(400);
});
it("preserves the visible anchor when older content is prepended", async () => {
render(<Harness />);
const element = screen.getByTestId("scroll");
setMetrics(element, { scrollHeight: 1000, clientHeight: 300, scrollTop: 100 });
fireEvent.scroll(element);
fireEvent.click(screen.getByText("prepend"));
await waitFor(() => expect(element.scrollTop).toBe(300));
expect(screen.getByTestId("notice").textContent).toBe("none");
});
});

View File

@ -0,0 +1,171 @@
import {
useCallback,
useLayoutEffect,
useRef,
useState,
type RefObject,
} from "react";
export type ThreadScrollNotice = "new" | "growth" | null;
export interface ThreadScrollController {
scrollRef: RefObject<HTMLDivElement>;
notice: ThreadScrollNotice;
onScroll: () => void;
scrollToBottom: (behavior?: ScrollBehavior) => void;
followLatest: () => void;
readHistory: () => void;
preserveAnchor: (mutation: () => void | Promise<void>) => Promise<void>;
}
interface ThreadScrollOptions {
itemCount: number;
completedItemCount: number;
contentVersion: string | number;
bottomThreshold?: number;
}
function nextFrame(callback: FrameRequestCallback): number {
if (typeof requestAnimationFrame === "function") {
return requestAnimationFrame(callback);
}
return window.setTimeout(() => callback(performance.now()), 0);
}
/**
* Owns the local follow/history state of one message viewport.
*
* Content mutations and late layout changes only pin the viewport while follow
* mode is active. Prepending can be wrapped with `preserveAnchor` so the same
* visible content remains under the reader's eyes.
*/
export function useThreadScrollController({
itemCount,
completedItemCount,
contentVersion,
bottomThreshold = 32,
}: ThreadScrollOptions): ThreadScrollController {
const scrollRef = useRef<HTMLDivElement>(null);
const followsLatestRef = useRef(true);
const preservingRef = useRef(false);
const previousRef = useRef<{
itemCount: number;
completedItemCount: number;
contentVersion: string | number;
} | null>(null);
const [notice, setNotice] = useState<ThreadScrollNotice>(null);
const pinToBottom = useCallback(() => {
const element = scrollRef.current;
if (element) element.scrollTop = element.scrollHeight;
}, []);
const onScroll = useCallback(() => {
const element = scrollRef.current;
if (!element || preservingRef.current) return;
const atBottom =
element.scrollHeight - element.scrollTop - element.clientHeight <=
bottomThreshold;
followsLatestRef.current = atBottom;
if (atBottom) setNotice(null);
}, [bottomThreshold]);
const scrollToBottom = useCallback(
(behavior: ScrollBehavior = "smooth") => {
const element = scrollRef.current;
followsLatestRef.current = true;
setNotice(null);
if (!element) return;
if (element.scrollTo) {
element.scrollTo({ top: element.scrollHeight, behavior });
} else {
element.scrollTop = element.scrollHeight;
}
},
[],
);
const followLatest = useCallback(() => {
followsLatestRef.current = true;
setNotice(null);
pinToBottom();
nextFrame(pinToBottom);
}, [pinToBottom]);
const preserveAnchor = useCallback(
async (mutation: () => void | Promise<void>) => {
const element = scrollRef.current;
if (!element) {
await mutation();
return;
}
const height = element.scrollHeight;
const top = element.scrollTop;
const wasFollowing = followsLatestRef.current;
preservingRef.current = true;
try {
await mutation();
} finally {
nextFrame(() => {
nextFrame(() => {
element.scrollTop = top + (element.scrollHeight - height);
followsLatestRef.current = wasFollowing;
preservingRef.current = false;
});
});
}
},
[],
);
useLayoutEffect(() => {
const previous = previousRef.current;
previousRef.current = { itemCount, completedItemCount, contentVersion };
if (
!previous ||
(previous.itemCount === itemCount &&
previous.completedItemCount === completedItemCount &&
previous.contentVersion === contentVersion) ||
preservingRef.current
) {
return;
}
if (followsLatestRef.current) {
pinToBottom();
nextFrame(pinToBottom);
setNotice(null);
return;
}
setNotice((current) =>
completedItemCount > previous.completedItemCount || current === "new"
? "new"
: "growth",
);
}, [completedItemCount, contentVersion, itemCount, pinToBottom]);
useLayoutEffect(() => {
const element = scrollRef.current;
if (!element || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
if (followsLatestRef.current && !preservingRef.current) pinToBottom();
});
observer.observe(element);
for (const child of element.children) observer.observe(child);
return () => observer.disconnect();
}, [contentVersion, itemCount, pinToBottom]);
const readHistory = useCallback(() => {
followsLatestRef.current = false;
setNotice((current) => current ?? "growth");
}, []);
return {
scrollRef,
notice,
onScroll,
scrollToBottom,
followLatest,
readHistory,
preserveAnchor,
};
}