diff --git a/.changeset/fn-7918-chat-inline-icons.md b/.changeset/fn-7918-chat-inline-icons.md new file mode 100644 index 0000000000..3293ac3839 --- /dev/null +++ b/.changeset/fn-7918-chat-inline-icons.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Make Chat go-to-top contextual and inline, with the edit pencil compact beside timestamps. +category: feature +dev: StandardChatMessageItem gains an isTopClipped prop; ChatView measures clipped message tops on scroll to gate go-to-top visibility. Edit pencil moved from a standalone row into the timestamp footer. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index c8973d378c..c5c4f1e8c4 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -548,8 +548,10 @@ Chat view provides project-scoped conversations with agents. - Agent-backed chat sessions now expose the same mailbox messaging tools (`fn_send_message`, `fn_read_messages`) used by runtime execution/heartbeat flows whenever the engine `MessageStore` is available; model-only chats continue to run without mailbox tools. - Chat attachments are included in agent-visible prompts for both direct sessions and rooms: supported text attachments are appended under an `Attachments` prompt section, and supported images (`png`, `jpeg`, `gif`, `webp`) are passed as image inputs to the model. - Chat attachments can be sent without accompanying text in both Quick Chat and Main Chat; fully empty sends with no text and no attachments are still blocked. - -- Your own messages in a **direct (model-loop) chat** can be edited: hover/tap a user message and use the **Edit message** (pencil) action to swap it for an inline textarea, then **Save** (or Cmd/Ctrl+Enter) or **Cancel** (or Escape). Saving an edit **resumes the conversation from that point** — the edited turn and every turn after it are discarded from both the visible transcript and the model's memory, so the agent responds fresh from the edited content with no bias from what was removed. This is the only way to correct or steer an earlier turn without leaving a stale, misleading message in the thread. + +- Your own messages in a **direct (model-loop) chat** can be edited: hover/tap the compact **Edit message** (pencil) action inline with the message timestamp to swap it for an inline textarea, then **Save** (or Cmd/Ctrl+Enter) or **Cancel** (or Escape). Saving an edit **resumes the conversation from that point** — the edited turn and every turn after it are discarded from both the visible transcript and the model's memory, so the agent responds fresh from the edited content with no bias from what was removed. This is the only way to correct or steer an earlier turn without leaving a stale, misleading message in the thread. + +- Assistant messages expose a **Scroll message to top** up-arrow only after that message's top has moved above the visible chat viewport. When visible, the control sits inline with the message's **Thinking** row (or the same footer action row when no thinking details are present) instead of adding a separate action line. - Message editing applies to direct/model-loop chat sessions, **including task-detail Planner Chat** (the synthetic `task-planner:` session, a model-loop session under the hood). It is **not** available in **Chat Rooms** (multi-agent, different persistence) or in **CLI-agent-backed sessions** (the transcript is owned by a live terminal, not a rewindable model session). The edit action is also disabled while a response is actively streaming, to avoid racing a live generation, and never renders on optimistic/in-flight rows that have no persisted message id yet. - Editing is truncate-and-resend, not append: the edited message and everything after it are removed first, then the edited text is sent as a new turn through the normal streaming path — so the resulting transcript looks the same as if you had deleted the old messages and typed the correction from scratch, but in one action. diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index e3d5c0a4d2..0c54080b86 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -1292,12 +1292,24 @@ Narrow chat hosts need full-width bubbles for prose, code, tool output, failures overflow-wrap: anywhere; } +.chat-message-thinking-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); + margin-top: var(--space-xs); +} + +.chat-message-thinking-row--collapsed { + display: none; +} + .chat-message-actions { display: flex; align-items: center; justify-content: flex-end; gap: var(--space-xs); - margin-top: var(--space-xs); + margin-left: auto; } .chat-message-copy-action, @@ -1305,8 +1317,6 @@ Narrow chat hosts need full-width bubbles for prose, code, tool output, failures display: flex; align-items: center; justify-content: center; - margin-top: var(--space-xs); - margin-left: auto; width: calc(var(--space-lg) * 2); height: calc(var(--space-lg) * 2); min-width: calc(var(--space-lg) * 2); @@ -1321,10 +1331,8 @@ Narrow chat hosts need full-width bubbles for prose, code, tool output, failures transition: opacity var(--transition-fast), color var(--transition-fast), background var(--transition-fast); } -.chat-message-actions .chat-message-copy-action, -.chat-message-actions .chat-message-scroll-to-top-action { - margin-top: 0; - margin-left: 0; +.chat-message-scroll-to-top-action--hidden { + display: none; } .chat-message-copy-action:hover, @@ -1350,16 +1358,19 @@ Narrow chat hosts need full-width bubbles for prose, code, tool output, failures } /* -FNXC:ChatMessageEdit 2026-07-07-09:00: -User messages in direct (model-loop) chat can carry an inline edit affordance mirroring the -assistant copy/scroll-to-top action row pattern above, plus an inline textarea editor. Editing -an earlier message resumes the conversation from that point, forgetting everything after it, so -keep the affordance visually consistent with existing chat-message-actions styling rather than a -one-off treatment. Rendered only for user messages on direct/model-loop sessions (never rooms, -CLI-agent sessions, or while streaming) — see StandardChatSurface.tsx `showEditAction`. +FNXC:ChatMessageEdit 2026-07-12-23:13: +User messages in direct (model-loop) chat carry a compact edit affordance inline with the timestamp footer instead of a standalone action row. Editing an earlier message still resumes the conversation from that point, forgetting everything after it; the footer placement keeps the affordance discoverable without adding vertical space. Rendered only for user messages on direct/model-loop sessions (never rooms, CLI-agent sessions, or while streaming) — see StandardChatSurface.tsx `showEditAction`. */ -.chat-message-actions--user { - justify-content: flex-start; +.chat-message-time-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-xs); + margin-top: var(--space-2xs); +} + +.chat-message-time-row .chat-message-time { + margin-top: 0; } .chat-message-edit-action { @@ -1380,6 +1391,13 @@ CLI-agent sessions, or while streaming) — see StandardChatSurface.tsx `showEdi transition: opacity var(--transition-fast), background var(--transition-fast); } +.chat-message-edit-action--inline { + width: calc(var(--space-lg) * 1.5); + height: calc(var(--space-lg) * 1.5); + min-width: calc(var(--space-lg) * 1.5); + min-height: calc(var(--space-lg) * 1.5); +} + .chat-message-edit-action:hover { opacity: 1; background: color-mix(in srgb, var(--accent-text) 25%, transparent); @@ -1415,11 +1433,15 @@ CLI-agent sessions, or while streaming) — see StandardChatSurface.tsx `showEdi } @media (max-width: 768px) { - .chat-message-edit-action { - width: calc(var(--space-lg) * 2.25); - height: calc(var(--space-lg) * 2.25); - min-width: calc(var(--space-lg) * 2.25); - min-height: calc(var(--space-lg) * 2.25); + .chat-message-time-row { + align-items: center; + } + + .chat-message-edit-action--inline { + width: calc(var(--space-lg) * 1.75); + height: calc(var(--space-lg) * 1.75); + min-width: calc(var(--space-lg) * 1.75); + min-height: calc(var(--space-lg) * 1.75); } .chat-message-edit-textarea { @@ -1485,13 +1507,15 @@ CLI-agent sessions, or while streaming) — see StandardChatSurface.tsx `showEdi } .chat-message-time { - font-size: 11px; + font-size: var(--font-size-xs); color: var(--text-dim); - margin-top: 4px; + margin-top: var(--space-2xs); } .chat-message-thinking { - margin-top: 6px; + flex: 1 1 auto; + min-width: 0; + margin: 0; } .chat-message-thinking summary { @@ -2504,10 +2528,18 @@ Queued-message banners stack above the composer input with a capped scroll area, opacity: 1; } + .chat-message-thinking-row { + gap: var(--space-xs); + } + .chat-message-scroll-to-top-action { opacity: 1; } + .chat-message-scroll-to-top-action--hidden { + display: none; + } + .chat-tool-calls-group-summary, .chat-tool-call summary { flex-wrap: nowrap; diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 6e4258b1f8..84612ab0dd 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -704,6 +704,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout const hideSkillMenuTimeoutRef = useRef(null); const messagesContainerRef = useRef(null); const chatThreadRef = useRef(null); + const clippedMessageFrameRef = useRef(null); + const [topClippedMessageIds, setTopClippedMessageIds] = useState>(() => new Set()); // FN-5365: mirror QuickChat's mid-dismiss suppress gate so transient // visualViewport shrink samples do not jerk the chat thread/composer. const suppressVvShrinkRef = useRef(false); @@ -978,6 +980,36 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout return container.querySelector(`.chat-message[data-message-id="${messageId.replace(/"/g, "\\\"")}"]`); }, []); + const updateTopClippedMessages = useCallback(() => { + const messagesContainer = messagesContainerRef.current; + if (!messagesContainer) return; + + const containerTop = messagesContainer.getBoundingClientRect().top; + const nextIds = new Set(); + messagesContainer.querySelectorAll(".chat-message--assistant:not(.chat-message--failure)[data-message-id]").forEach((element) => { + const messageId = element.getAttribute("data-message-id"); + if (!messageId) return; + if (element.getBoundingClientRect().top < containerTop) { + nextIds.add(messageId); + } + }); + + setTopClippedMessageIds((previousIds) => { + if (previousIds.size === nextIds.size && Array.from(previousIds).every((id) => nextIds.has(id))) { + return previousIds; + } + return nextIds; + }); + }, []); + + const scheduleTopClippedMessageUpdate = useCallback(() => { + if (!messagesContainerRef.current || clippedMessageFrameRef.current !== null) return; + clippedMessageFrameRef.current = window.requestAnimationFrame(() => { + clippedMessageFrameRef.current = null; + updateTopClippedMessages(); + }); + }, [updateTopClippedMessages]); + const captureScrollSnapshot = useCallback(() => { const messagesContainer = messagesContainerRef.current; const threadId = getActiveThreadId(); @@ -1012,7 +1044,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout setIsUserScrolling(!atBottom); isUserScrollingRef.current = !atBottom; captureScrollSnapshot(); - }, [captureScrollSnapshot]); + scheduleTopClippedMessageUpdate(); + }, [captureScrollSnapshot, scheduleTopClippedMessageUpdate]); const anchorToBottom = useCallback((container: HTMLElement, options?: { force?: boolean }) => { if (!container.isConnected) return; @@ -1054,6 +1087,20 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout const activeThreadMessages = roomThreadActive ? rooms.messages : messages; + /* + FNXC:ChatMessageScrollToTop 2026-07-12-23:16: + ChatView owns the `.chat-messages` viewport, so it measures assistant message tops against the container's visible top on scroll/message changes and passes clipped membership down. The go-to-top control remains DOM-mounted by StandardChatSurface but becomes visually available only after the message's top has moved above this container edge. + */ + useLayoutEffect(() => { + scheduleTopClippedMessageUpdate(); + return () => { + if (clippedMessageFrameRef.current !== null) { + window.cancelAnimationFrame(clippedMessageFrameRef.current); + clippedMessageFrameRef.current = null; + } + }; + }, [activeThreadMessages, scheduleTopClippedMessageUpdate]); + useLayoutEffect(() => { const messagesContainer = messagesContainerRef.current; const threadId = getActiveThreadId(); @@ -2577,6 +2624,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout roomContext={null} copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined} onScrollToTop={handleScrollMessageToTop} + isTopClipped={topClippedMessageIds.has(message.id)} isAwaitingQuestionAnswer={message.role === "assistant" && index === messages.length - 1 && !isStreaming} submittedQuestionAnswer={findSubmittedQuestionAnswer(messages, index)} onQuestionSubmit={handleQuestionSubmit} @@ -2621,6 +2669,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout roomContext={null} copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined} onScrollToTop={handleScrollMessageToTop} + isTopClipped={topClippedMessageIds.has(message.id)} isAwaitingQuestionAnswer={message.role === "assistant" && index === messages.length - 1 && !isStreaming} submittedQuestionAnswer={findSubmittedQuestionAnswer(messages, index)} onQuestionSubmit={handleQuestionSubmit} @@ -3574,6 +3623,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout mentionAgentsByName={mentionAgentsByName} roomContext={roomContext} onScrollToTop={handleScrollMessageToTop} + isTopClipped={topClippedMessageIds.has(message.id)} isAwaitingQuestionAnswer={false} onQuestionSubmit={handleQuestionSubmit} /> diff --git a/packages/dashboard/app/components/StandardChatSurface.tsx b/packages/dashboard/app/components/StandardChatSurface.tsx index 66346e8280..5b11c09e2e 100644 --- a/packages/dashboard/app/components/StandardChatSurface.tsx +++ b/packages/dashboard/app/components/StandardChatSurface.tsx @@ -29,6 +29,11 @@ export interface StandardChatMessageItemProps { roomContext?: StandardRoomContext | null; copyAction?: ReactNode; onScrollToTop?: (messageId: string) => void; + /** + * FNXC:ChatMessageScrollToTop 2026-07-12-23:09: + * ChatView owns scroll-container measurement and sets this when the message top is clipped above the visible container top. StandardChatSurface keeps eligible go-to-top controls mounted for tests/accessibility wiring but hides them until this state is true, and renders the control inline with the Thinking row instead of a standalone action line. + */ + isTopClipped?: boolean; isAwaitingQuestionAnswer?: boolean; submittedQuestionAnswer?: string; onQuestionSubmit?: (answerText: string, structured: Record) => void; @@ -351,6 +356,7 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({ toolCallRenderer, onEditMessage, canEdit = false, + isTopClipped = false, }: StandardChatMessageItemProps) { const { t } = useTranslation("app"); const isAssistantMessage = message.role === "assistant"; @@ -360,7 +366,8 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({ * Edit affordance is scoped strictly to user messages on surfaces that opt in via both * `canEdit` and `onEditMessage`; absent either, `showEditAction` is false and nothing renders * (no dead button, no empty shell) — e.g. assistant/system messages, Rooms, CLI-agent chat, or - * while a generation is streaming. + * while a generation is streaming. The compact pencil renders in the timestamp footer so user + * bubbles do not grow an extra action row above their time metadata. */ const showEditAction = isUserMessage && canEdit && Boolean(onEditMessage); const [isEditing, setIsEditing] = useState(false); @@ -451,6 +458,9 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({ } return renderStandardAssistantContent(message.content, forcePlain); }, [failureInfo, forcePlain, isAssistantMessage, isEmptyAssistantMessage, message.content, t]); + const hasAssistantFooterRow = isAssistantMessage && !failureInfo && Boolean(message.thinkingOutput || copyAction || onScrollToTop); + const hasVisibleAssistantFooterContent = Boolean(message.thinkingOutput || copyAction || (onScrollToTop && isTopClipped)); + const messageTime =
{formatRelativeTime(message.createdAt, t)}
; return (
{showAssistantIdentity &&
{activeModelProvider ? : }{agentName}{showAssistantModelTag && activeModelTag && {activeModelTag}}
} @@ -480,12 +490,25 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({ ) : ( isAssistantMessage ? assistantBody :
{renderedUserContent}
)} - {isAssistantMessage && !failureInfo && (copyAction || onScrollToTop) &&
{copyAction}{onScrollToTop && }
} - {showEditAction && !isEditing &&
} + {hasAssistantFooterRow && ( +
+ {message.thinkingOutput &&
{t("chat.thinking", "Thinking")}
{linkifyFilePaths(message.thinkingOutput)}
} + {(copyAction || onScrollToTop) && ( +
+ {copyAction} + {onScrollToTop && } +
+ )} +
+ )} {renderStandardToolCalls(message.toolCalls, t, { isAwaitingAnswer: isAwaitingQuestionAnswer, submittedAnswer: submittedQuestionAnswer, onQuestionSubmit, toolCallRenderer })} - {message.thinkingOutput &&
{t("chat.thinking", "Thinking")}
{linkifyFilePaths(message.thinkingOutput)}
} {renderedAttachments} -
{formatRelativeTime(message.createdAt, t)}
+ {isUserMessage ? ( +
+ {messageTime} + {showEditAction && !isEditing && } +
+ ) : messageTime}
); }); diff --git a/packages/dashboard/app/components/__tests__/ChatView.message-edit.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.message-edit.test.tsx index 03d87868d7..533ee9a18e 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.message-edit.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.message-edit.test.tsx @@ -9,6 +9,7 @@ editMessageAndResend wiring. import { beforeEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render as rtlRender, screen } from "@testing-library/react"; import { ChatView } from "../ChatView"; +import { StandardChatMessageItem } from "../StandardChatSurface"; import * as useChatModule from "../../hooks/useChat"; import * as useChatRoomsModule from "../../hooks/useChatRooms"; import type { ChatSessionInfo, UseChatReturn } from "../../hooks/useChat"; @@ -155,7 +156,15 @@ describe("ChatView message edit affordance", () => { await renderWithAct(); - expect(screen.getByTestId("chat-message-edit-user-1")).toHaveAttribute("aria-label", "Edit message"); + const editButton = screen.getByTestId("chat-message-edit-user-1"); + const userMessage = screen.getByTestId("chat-message-user-1"); + const timeRow = userMessage.querySelector(".chat-message-time-row"); + + expect(editButton).toHaveAttribute("aria-label", "Edit message"); + expect(editButton).toHaveClass("chat-message-edit-action--inline"); + expect(timeRow).toContainElement(userMessage.querySelector(".chat-message-time") as HTMLElement); + expect(timeRow).toContainElement(editButton); + expect(userMessage.querySelector(".chat-message-actions--user")).toBeNull(); expect(screen.queryByTestId("chat-message-edit-assistant-1")).toBeNull(); }); @@ -248,6 +257,29 @@ describe("ChatView message edit affordance", () => { expect(screen.getByTestId("chat-message-user-1")).toHaveTextContent("hello"); }); + it("renders inline edit without a go-to-top control for non-scroll-to-top consumers", () => { + rtlRender( + , + ); + + const editButton = screen.getByTestId("chat-message-edit-planner-user-1"); + const message = screen.getByTestId("chat-message-planner-user-1"); + expect(editButton).toHaveClass("chat-message-edit-action--inline"); + expect(message.querySelector(".chat-message-time-row")).toContainElement(editButton); + expect(message.querySelector("[data-testid^='chat-message-scroll-to-top-']")).toBeNull(); + }); + it("does not leave an empty edit-action shell for assistant messages", async () => { mockUseChat.mockReturnValue(baseChatState({ messages: [ diff --git a/packages/dashboard/app/components/__tests__/ChatView.scroll-to-top.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.scroll-to-top.test.tsx index 8b46cced2f..73c51769e3 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.scroll-to-top.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.scroll-to-top.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { act, fireEvent, render as rtlRender, screen } from "@testing-library/react"; +import { act, fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react"; import { ChatView } from "../ChatView"; import * as useChatModule from "../../hooks/useChat"; import * as useChatRoomsModule from "../../hooks/useChatRooms"; @@ -44,6 +44,12 @@ async function renderWithAct(ui: Parameters[0]) { return result!; } +async function flushAnimationFrame() { + await act(async () => { + await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); + }); +} + const mockUseChat = vi.mocked(useChatModule.useChat); const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms); @@ -204,6 +210,45 @@ describe("ChatView scroll-to-top message affordance", () => { expect(container.scrollTo).toHaveBeenCalledWith({ top: 120, behavior: "auto" }); }); + it("shows the affordance only when the direct-session assistant message top is clipped", async () => { + await setup({ + messages: [ + { id: "assistant-ok", sessionId: activeSession.id, role: "assistant", content: "hello", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + }); + + const container = document.querySelector(".chat-messages") as HTMLDivElement; + const target = screen.getByTestId("chat-message-assistant-ok") as HTMLDivElement; + const button = screen.getByTestId("chat-message-scroll-to-top-assistant-ok"); + vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ top: 100 } as DOMRect); + const targetRect = vi.spyOn(target, "getBoundingClientRect").mockReturnValue({ top: 100 } as DOMRect); + + fireEvent.scroll(container); + await flushAnimationFrame(); + expect(button).toHaveClass("chat-message-scroll-to-top-action--hidden"); + + targetRect.mockReturnValue({ top: 80 } as DOMRect); + fireEvent.scroll(container); + await flushAnimationFrame(); + await waitFor(() => expect(button).not.toHaveClass("chat-message-scroll-to-top-action--hidden")); + }); + + it("renders the affordance inline with the Thinking row when thinking output exists", async () => { + await setup({ + messages: [ + { id: "assistant-thinking", sessionId: activeSession.id, role: "assistant", content: "hello", thinkingOutput: "reasoning", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + }); + + const message = screen.getByTestId("chat-message-assistant-thinking"); + const row = message.querySelector(".chat-message-thinking-row"); + const thinking = message.querySelector(".chat-message-thinking"); + const button = screen.getByTestId("chat-message-scroll-to-top-assistant-thinking"); + + expect(row).toContainElement(thinking as HTMLElement); + expect(row).toContainElement(button); + }); + it("renders the affordance for room assistant messages", async () => { await setup( { @@ -223,6 +268,34 @@ describe("ChatView scroll-to-top message affordance", () => { expect(screen.getByTestId("chat-message-scroll-to-top-room-assistant-1")).toBeInTheDocument(); }); + it("shows the affordance only when a room assistant message top is clipped", async () => { + await setup( + { sessions: [activeSession], activeSession }, + { + messages: [ + { id: "room-assistant-1", roomId: roomA.id, role: "assistant", content: "Room response", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }, + ], + }, + { chatRooms: true }, + ); + + fireEvent.click(screen.getByTestId("chat-sidebar-scope-rooms")); + const container = document.querySelector(".chat-messages") as HTMLDivElement; + const target = screen.getByTestId("chat-message-room-assistant-1") as HTMLDivElement; + const button = screen.getByTestId("chat-message-scroll-to-top-room-assistant-1"); + vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ top: 100 } as DOMRect); + const targetRect = vi.spyOn(target, "getBoundingClientRect").mockReturnValue({ top: 120 } as DOMRect); + + fireEvent.scroll(container); + await flushAnimationFrame(); + expect(button).toHaveClass("chat-message-scroll-to-top-action--hidden"); + + targetRect.mockReturnValue({ top: 90 } as DOMRect); + fireEvent.scroll(container); + await flushAnimationFrame(); + await waitFor(() => expect(button).not.toHaveClass("chat-message-scroll-to-top-action--hidden")); + }); + it("does not reset to top when a stale zero snapshot is captured while user is reading older messages", async () => { const state: UseChatReturn = { ...defaultChatState,