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:
@ -6169,11 +6169,12 @@ describe("CustomAgentChatView", () => {
|
||||
const scrollTo = vi.fn();
|
||||
Object.defineProperty(scroll, "scrollTo", { value: scrollTo, configurable: true });
|
||||
fireEvent.scroll(scroll);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Aller au dernier message" }));
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "smooth" });
|
||||
expect(screen.queryByRole("button", { name: "Aller au dernier message" })).toBeNull();
|
||||
|
||||
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 () => {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -18,7 +18,13 @@ import type {
|
||||
TurnSource,
|
||||
TurnView,
|
||||
} from "@/domain";
|
||||
import { Button, EntryTimestamp, Spinner, cn } from "@/shared";
|
||||
import {
|
||||
Button,
|
||||
EntryTimestamp,
|
||||
Spinner,
|
||||
cn,
|
||||
useThreadScrollController,
|
||||
} from "@/shared";
|
||||
import { useProjectWorkState } from "@/features/workstate";
|
||||
import { useConversationThread } from "./useConversationThread";
|
||||
import { useI18n } from "@/i18n";
|
||||
@ -205,6 +211,13 @@ export function ConversationViewer({
|
||||
}: ConversationViewerProps) {
|
||||
const { tr } = useI18n();
|
||||
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.
|
||||
const work = useProjectWorkState(projectId);
|
||||
|
||||
@ -300,76 +313,96 @@ export function ConversationViewer({
|
||||
)}
|
||||
|
||||
{/* ── Thread ── */}
|
||||
<div className="flex flex-1 flex-col gap-2 overflow-auto px-4 py-3">
|
||||
{vm.hasMore && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void vm.loadOlder()}
|
||||
loading={vm.busy}
|
||||
>
|
||||
{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 && (
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<div
|
||||
ref={threadScroll.scrollRef}
|
||||
data-testid="conversation-viewer-scroll"
|
||||
className="flex min-h-0 flex-1 flex-col gap-2 overflow-auto px-4 py-3"
|
||||
onScroll={threadScroll.onScroll}
|
||||
>
|
||||
{vm.hasMore && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="mt-2"
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
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>
|
||||
)}
|
||||
|
||||
{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
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="mt-2"
|
||||
onClick={() =>
|
||||
onOpenConversation(emptyState.action!.conversationId)
|
||||
}
|
||||
>
|
||||
<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} />
|
||||
{emptyState.action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
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>
|
||||
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
|
||||
</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>
|
||||
|
||||
@ -8,9 +8,9 @@
|
||||
* 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 { TicketViewportSelect } from "./TicketViewportSelect";
|
||||
import { useTicketAssistant } from "./useTicketAssistant";
|
||||
@ -29,18 +29,22 @@ export function TicketAssistantPanel({
|
||||
const [profileId, setProfileId] = useState("");
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
// Auto-scroll the transcript to the newest content as it streams in.
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [vm.turns]);
|
||||
const threadScroll = useThreadScrollController({
|
||||
itemCount: vm.turns.length,
|
||||
completedItemCount: vm.turns.filter(
|
||||
(turn) => turn.role === "assistant" && !turn.pending,
|
||||
).length,
|
||||
contentVersion: vm.turns
|
||||
.map((turn) => `${turn.role}:${turn.text.length}:${Boolean(turn.pending)}`)
|
||||
.join("|"),
|
||||
});
|
||||
|
||||
const open = vm.sessionId !== null;
|
||||
|
||||
function submit() {
|
||||
const text = draft.trim();
|
||||
if (!text || vm.busy) return;
|
||||
threadScroll.followLatest();
|
||||
void vm.send(text);
|
||||
setDraft("");
|
||||
}
|
||||
@ -95,40 +99,55 @@ export function TicketAssistantPanel({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex max-h-96 min-h-32 flex-col gap-2 overflow-auto rounded-md border border-border bg-raised/40 p-3"
|
||||
>
|
||||
{vm.turns.length === 0 ? (
|
||||
<p className="m-auto text-xs text-muted">
|
||||
{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.map((turn, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex",
|
||||
turn.role === "user" ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={threadScroll.scrollRef}
|
||||
data-testid="ticket-assistant-scroll"
|
||||
className="flex max-h-96 min-h-32 flex-col gap-2 overflow-auto rounded-md border border-border bg-raised/40 p-3"
|
||||
onScroll={threadScroll.onScroll}
|
||||
>
|
||||
{vm.turns.length === 0 ? (
|
||||
<p className="m-auto text-xs text-muted">
|
||||
{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.map((turn, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"max-w-[85%] whitespace-pre-wrap break-words rounded-lg px-3 py-2 text-sm",
|
||||
turn.role === "user"
|
||||
? "bg-primary/15 text-content"
|
||||
: "bg-surface text-content",
|
||||
"flex",
|
||||
turn.role === "user" ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
{turn.text}
|
||||
{turn.pending && (
|
||||
<span className="ml-1 inline-flex items-center align-middle text-muted">
|
||||
<Spinner size={12} />
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[85%] whitespace-pre-wrap break-words rounded-lg px-3 py-2 text-sm",
|
||||
turn.role === "user"
|
||||
? "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>
|
||||
{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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user