fix(frontend): restaure le suivi automatique du scroll des fils de messages — hook partagé useThreadScrollController — #356 (QA verte)
Cause: la logique de suivi du bas de fil était dupliquée et ad-hoc dans chaque surface (scrollRef, followsLatestRef, showLatestButton dans CustomAgentChatView), et dérivait dans certains cas — le fil ne suivait plus les nouveaux messages. Fix: nouveau hook partagé shared/lib/useThreadScrollController (resté collé au bas quand l'utilisateur est déjà en bas, position préservée pendant la consultation de l'historique, notice latest sinon) câblé dans CustomAgentChatView, ConversationViewer et TicketAssistantPanel. QA verte: npm run typecheck exit 0; tests ciblés useThreadScrollController, CustomAgentChatView, ConversationViewer/useConversationThread et tickets verts; npm test 1400/1400 verts (137 fichiers); git diff --check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -7,6 +7,11 @@
|
||||
|
||||
export { cn } 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 type { ButtonProps, ButtonVariant, ButtonSize } from "./ui/Button";
|
||||
|
||||
148
frontend/src/shared/lib/useThreadScrollController.test.tsx
Normal file
148
frontend/src/shared/lib/useThreadScrollController.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
171
frontend/src/shared/lib/useThreadScrollController.ts
Normal file
171
frontend/src/shared/lib/useThreadScrollController.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user