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:
2026-09-14 11:15:10 +02:00
parent 7eb501e013
commit ec941d1af6
7 changed files with 512 additions and 144 deletions

View File

@ -43,7 +43,14 @@ import {
markProjectCliInteractionStarted,
markProjectCliInteractionStopped,
} 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 type {
ChatAttachmentInput,
@ -1054,7 +1061,6 @@ export function CustomAgentChatView({
} | null>(null);
const [queueAnnouncement, setQueueAnnouncement] = useState("");
const [queueDrainReady, setQueueDrainReady] = useState(false);
const [showLatestButton, setShowLatestButton] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [slashMenuOpen, setSlashMenuOpen] = useState(false);
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
@ -1071,7 +1077,6 @@ export function CustomAgentChatView({
const [stopTerminalOverride, setStopTerminalOverride] = useState(false);
const [error, setError] = useState<ChatErrorState | null>(null);
const [projectIcon, setProjectIcon] = useState<ProjectIconRef | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const chatRootRef = useRef<HTMLDivElement | null>(null);
const turnsRef = useRef(turns);
@ -1111,7 +1116,6 @@ export function CustomAgentChatView({
const queuedMessagesRef = useRef(queuedMessages);
queuedMessagesRef.current = queuedMessages;
const messageQueueKeyRef = useRef(initialMessageQueueKey);
const followsLatestRef = useRef(true);
const imageTokenSeqRef = useRef(0);
const mountedRef = useRef(false);
const openOrAttachCountRef = useRef(0);
@ -1252,6 +1256,19 @@ export function CustomAgentChatView({
const [composerHistoryIndex, setComposerHistoryIndex] =
useState<number | null>(null);
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 sessionLabel = structuredSessionLabel(profile.structuredAdapter);
const [requesterDelegation, setRequesterDelegation] =
@ -1976,32 +1993,11 @@ export function CustomAgentChatView({
);
const scrollToLatest = useCallback(() => {
const element = scrollRef.current;
if (!element) return;
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);
}, []);
threadScroll.scrollToBottom(prefersReducedMotion() ? "auto" : "smooth");
}, [prefersReducedMotion, threadScroll]);
const scrollToPreviousFinal = useCallback(() => {
const element = scrollRef.current;
const element = threadScroll.scrollRef.current;
if (!element) return;
const finals = Array.from(
element.querySelectorAll<HTMLElement>("[data-chat-final]"),
@ -2020,16 +2016,8 @@ export function CustomAgentChatView({
} else {
element.scrollTop = top;
}
followsLatestRef.current = false;
setShowLatestButton(true);
}, [prefersReducedMotion]);
useEffect(() => {
const element = scrollRef.current;
if (!element || !followsLatestRef.current) return;
element.scrollTop = element.scrollHeight;
setShowLatestButton(false);
}, [displayTurns]);
threadScroll.readHistory();
}, [prefersReducedMotion, threadScroll]);
useEffect(() => {
if (selfEmittedSessionIdRef.current === sessionId) {
@ -2523,6 +2511,7 @@ export function CustomAgentChatView({
markProjectCliInteractionFinished(projectId, interactionId);
activeInteractionIdRef.current = null;
};
threadScroll.followLatest();
setBusy(true);
setError(null);
updateChatViewState((prev) => ({
@ -2959,10 +2948,10 @@ export function CustomAgentChatView({
/>
</div>
<div
ref={scrollRef}
ref={threadScroll.scrollRef}
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"
onScroll={updateLatestVisibility}
onScroll={threadScroll.onScroll}
>
<div className="shrink-0">
<AnnouncementsPreview
@ -3038,14 +3027,16 @@ export function CustomAgentChatView({
mobile && "pb-[max(0.5rem,env(safe-area-inset-bottom))]",
)}
>
{showLatestButton && (
{threadScroll.notice && (
<button
type="button"
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}
>
↓
{threadScroll.notice === "new"
? tr("Nouveaux messages ↓", "New messages ↓")
: tr("Revenir en bas ↓", "Back to bottom ↓")}
</button>
)}
<div className="sr-only" aria-live="polite" aria-atomic="true">