diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index de71147a..f7cab508 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -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 () => { diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 89b0bbaa..e1d186dd 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -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([]); 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(null); const [projectIcon, setProjectIcon] = useState(null); - const scrollRef = useRef(null); const composerRef = useRef(null); const chatRootRef = useRef(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(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("[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({ />
- {showLatestButton && ( + {threadScroll.notice && ( )}
diff --git a/frontend/src/features/conversations/ConversationViewer.tsx b/frontend/src/features/conversations/ConversationViewer.tsx index de0b2672..e75c97b9 100644 --- a/frontend/src/features/conversations/ConversationViewer.tsx +++ b/frontend/src/features/conversations/ConversationViewer.tsx @@ -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 ── */} -
- {vm.hasMore && ( -
- -
- )} - - {vm.busy && vm.turns.length === 0 ? ( -
- - {tr("Chargement du fil…", "Loading thread…")} -
- ) : vm.turns.length === 0 ? ( -
-

{emptyState.message}

- {emptyState.detail && ( -

{emptyState.detail}

- )} - {emptyState.action && onOpenConversation && ( +
+
+ {vm.hasMore && ( +
- )} -
- ) : ( - vm.turns.map((turn) => { - const side = sideOf(turn.source); - const sideClass = side === "right" ? "items-end" : "items-start"; - if (turn.role === "toolActivity") { - return ( - - ); - } - const party = parties.meta.get(partyKey(turn.source)); - return ( -
-
+ )} + + {vm.busy && vm.turns.length === 0 ? ( +
+ + {tr("Chargement du fil…", "Loading thread…")} +
+ ) : vm.turns.length === 0 ? ( +
+

{emptyState.message}

+ {emptyState.detail && ( +

{emptyState.detail}

+ )} + {emptyState.action && onOpenConversation && ( + + )} +
+ ) : ( + vm.turns.map((turn) => { + const side = sideOf(turn.source); + const sideClass = side === "right" ? "items-end" : "items-start"; + if (turn.role === "toolActivity") { + return ( + + ); + } + const party = parties.meta.get(partyKey(turn.source)); + return ( +
+
+
+ + {party?.label ?? "?"} + + · {roleLabel(turn.role, tr)} + +
+

{turn.text}

-

{turn.text}

-
- ); - }) + ); + }) + )} +
+ {threadScroll.notice && ( + )}
diff --git a/frontend/src/features/tickets/TicketAssistantPanel.tsx b/frontend/src/features/tickets/TicketAssistantPanel.tsx index 333e2ac1..26ea525e 100644 --- a/frontend/src/features/tickets/TicketAssistantPanel.tsx +++ b/frontend/src/features/tickets/TicketAssistantPanel.tsx @@ -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(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({
-
- {vm.turns.length === 0 ? ( -

- {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.")} -

- ) : ( - vm.turns.map((turn, i) => ( -
+
+
+ {vm.turns.length === 0 ? ( +

+ {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.")} +

+ ) : ( + vm.turns.map((turn, i) => (
- {turn.text} - {turn.pending && ( - - - - )} +
+ {turn.text} + {turn.pending && ( + + + + )} +
-
- )) + )) + )} +
+ {threadScroll.notice && ( + )}
diff --git a/frontend/src/shared/index.ts b/frontend/src/shared/index.ts index c5e95de3..db2f5217 100644 --- a/frontend/src/shared/index.ts +++ b/frontend/src/shared/index.ts @@ -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"; diff --git a/frontend/src/shared/lib/useThreadScrollController.test.tsx b/frontend/src/shared/lib/useThreadScrollController.test.tsx new file mode 100644 index 00000000..e9ce2968 --- /dev/null +++ b/frontend/src/shared/lib/useThreadScrollController.test.tsx @@ -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 ( + <> +
+
content
+
+ {scroll.notice ?? "none"} + + + + + + + ); +} + +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(); + 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(); + 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(); + 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(); + 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"); + }); +}); diff --git a/frontend/src/shared/lib/useThreadScrollController.ts b/frontend/src/shared/lib/useThreadScrollController.ts new file mode 100644 index 00000000..13bde1d6 --- /dev/null +++ b/frontend/src/shared/lib/useThreadScrollController.ts @@ -0,0 +1,171 @@ +import { + useCallback, + useLayoutEffect, + useRef, + useState, + type RefObject, +} from "react"; + +export type ThreadScrollNotice = "new" | "growth" | null; + +export interface ThreadScrollController { + scrollRef: RefObject; + notice: ThreadScrollNotice; + onScroll: () => void; + scrollToBottom: (behavior?: ScrollBehavior) => void; + followLatest: () => void; + readHistory: () => void; + preserveAnchor: (mutation: () => void | Promise) => Promise; +} + +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(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(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) => { + 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, + }; +}