feat(FN-5379): add scroll-to-top action for chat messages

Added a scroll-to-top action for the ChatView with per-message scroll behavior, styled to match the mobile design, with comprehensive test coverage for scroll-to-top behavior. Also preserves the mobile copy-action CSS selector.

Fusion-Task-Id: FN-5379
This commit is contained in:
Fusion (runfusion.ai)
2026-05-21 15:35:48 -07:00
committed by gsxdsm
parent f21464070a
commit 8e872301c4
4 changed files with 275 additions and 5 deletions

View File

@@ -925,7 +925,16 @@
overflow-wrap: anywhere;
}
.chat-message-copy-action {
.chat-message-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-xs);
margin-top: var(--space-xs);
}
.chat-message-copy-action,
.chat-message-scroll-to-top-action {
display: flex;
align-items: center;
justify-content: center;
@@ -945,13 +954,21 @@
transition: opacity var(--transition-fast), color var(--transition-fast), background var(--transition-fast);
}
.chat-message-copy-action:hover {
.chat-message-actions .chat-message-copy-action,
.chat-message-actions .chat-message-scroll-to-top-action {
margin-top: 0;
margin-left: 0;
}
.chat-message-copy-action:hover,
.chat-message-scroll-to-top-action:hover {
opacity: 1;
color: var(--text);
background: color-mix(in srgb, var(--surface) 55%, transparent);
}
.chat-message-copy-action:focus-visible {
.chat-message-copy-action:focus-visible,
.chat-message-scroll-to-top-action:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
opacity: 1;
@@ -1777,6 +1794,10 @@
opacity: 1;
}
.chat-message-scroll-to-top-action {
opacity: 1;
}
.chat-tool-calls-group-summary,
.chat-tool-call summary {
flex-wrap: nowrap;

View File

@@ -23,6 +23,7 @@ import {
Copy,
Check,
TriangleAlert,
ArrowUpToLine,
} from "lucide-react";
import { useChat, type ChatMessageInfo, type FailureInfo, type ToolCallInfo } from "../hooks/useChat";
import { RoomMessageDeliveredButReplyFailedError, useChatRooms } from "../hooks/useChatRooms";
@@ -713,6 +714,7 @@ interface ChatMessageItemProps {
mentionAgentsByName: Map<string, Agent>;
roomContext: RoomContext | null;
copyAction?: ReactNode;
onScrollToTop?: (messageId: string) => void;
}
// Renders a single chat message bubble. Memoized so the streaming bubble's
@@ -730,6 +732,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
mentionAgentsByName,
roomContext,
copyAction,
onScrollToTop,
}: ChatMessageItemProps) {
const isAssistantMessage = message.role === "assistant";
const failureInfo = isAssistantMessage ? message.failureInfo : undefined;
@@ -876,7 +879,22 @@ const ChatMessageItem = memo(function ChatMessageItem({
{isAssistantMessage
? assistantBody
: <div className="chat-message-content">{renderedUserContent}</div>}
{!failureInfo && copyAction}
{isAssistantMessage && !failureInfo && (copyAction || onScrollToTop) && (
<div className="chat-message-actions">
{copyAction}
{onScrollToTop && (
<button
type="button"
className="btn-icon chat-message-scroll-to-top-action"
aria-label="Scroll message to top"
data-testid={`chat-message-scroll-to-top-${message.id}`}
onClick={() => onScrollToTop(message.id)}
>
<ArrowUpToLine size={14} />
</button>
)}
</div>
)}
{renderToolCalls(message.toolCalls)}
{message.thinkingOutput && (
<details className="chat-message-thinking">
@@ -2500,6 +2518,18 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
</button>
), [copyFeedbackByMessageId, handleCopyResponse]);
const handleScrollMessageToTop = useCallback((messageId: string) => {
const containerEl = messagesContainerRef.current;
if (!containerEl) return;
const selector = `[data-testid="chat-message-${messageId}"]`;
const targetEl = containerEl.querySelector<HTMLElement>(selector);
if (!targetEl) return;
const top = targetEl.getBoundingClientRect().top - containerEl.getBoundingClientRect().top + containerEl.scrollTop;
const prefersReducedMotion = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
containerEl.scrollTo({ top, behavior: prefersReducedMotion ? "auto" : "smooth" });
}, []);
return (
<div className="chat-view">
{/* Sidebar */}
@@ -2917,6 +2947,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
activeSessionId={rooms.activeRoom?.id ?? null}
mentionAgentsByName={mentionAgentsByName}
roomContext={roomContext}
onScrollToTop={handleScrollMessageToTop}
/>
);
})
@@ -3101,6 +3132,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
mentionAgentsByName={mentionAgentsByName}
roomContext={null}
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
onScrollToTop={handleScrollMessageToTop}
/>
))}
<div className="chat-message chat-message--assistant chat-message--streaming">
@@ -3155,6 +3187,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
mentionAgentsByName={mentionAgentsByName}
roomContext={null}
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
onScrollToTop={handleScrollMessageToTop}
/>
))}
</>

View File

@@ -0,0 +1,216 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { ChatView } from "../ChatView";
import * as useChatModule from "../../hooks/useChat";
import * as useChatRoomsModule from "../../hooks/useChatRooms";
import type { UseChatReturn, ChatSessionInfo } from "../../hooks/useChat";
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
vi.mock("../../hooks/useChat");
vi.mock("../../hooks/useChatRooms", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useChatRooms")>();
return {
...actual,
useChatRooms: vi.fn(),
};
});
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
return {
...actual,
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
};
});
vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../api")>();
return {
...actual,
fetchAgents: vi.fn().mockResolvedValue([{ id: "agent-1", name: "Alpha", role: "executor", state: "idle", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} }]),
};
});
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react")>();
return {
...actual,
ArrowUpToLine: (props: any) => <svg data-testid="icon-arrow-up-to-line" {...props} />,
};
});
const mockUseChat = vi.mocked(useChatModule.useChat);
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
const activeSession: ChatSessionInfo = {
id: "session-001",
agentId: "agent-001",
status: "active",
title: "Test Chat",
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
};
const defaultChatState: UseChatReturn = {
sessions: [activeSession],
activeSession,
sessionsLoading: false,
messages: [],
messagesLoading: false,
isStreaming: false,
streamingText: "",
streamingThinking: "",
streamingToolCalls: [],
selectSession: vi.fn(),
createSession: vi.fn(),
archiveSession: vi.fn(),
deleteSession: vi.fn(),
sendMessage: vi.fn(),
stopStreaming: vi.fn(),
pendingMessage: "",
clearPendingMessage: vi.fn(),
loadMoreMessages: vi.fn(),
hasMoreMessages: false,
searchQuery: "",
setSearchQuery: vi.fn(),
filteredSessions: [activeSession],
refreshSessions: vi.fn(),
agentsMap: new Map(),
};
const roomA = {
id: "room-a",
name: "Room A",
slug: "room-a",
description: null,
projectId: "proj-123",
createdBy: "agent-1",
status: "active" as const,
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
};
const defaultRoomsState: UseChatRoomsResult = {
rooms: [roomA],
roomsLoading: false,
roomsError: null,
activeRoom: roomA,
activeRoomMembers: [],
messages: [],
messagesLoading: false,
selectRoom: vi.fn(),
createRoom: vi.fn(),
deleteRoom: vi.fn(),
sendRoomMessage: vi.fn(),
clearRoom: vi.fn(),
refreshRooms: vi.fn(),
};
function setup(chatOverrides: Partial<UseChatReturn> = {}, roomsOverrides: Partial<UseChatRoomsResult> = {}, experimentalFeatures?: Record<string, boolean>) {
mockUseChat.mockReturnValue({ ...defaultChatState, ...chatOverrides });
mockUseChatRooms.mockReturnValue({ ...defaultRoomsState, ...roomsOverrides });
return render(<ChatView addToast={vi.fn()} experimentalFeatures={experimentalFeatures} />);
}
describe("ChatView scroll-to-top message affordance", () => {
beforeEach(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
Object.defineProperty(HTMLElement.prototype, "scrollTo", {
configurable: true,
value: vi.fn(),
});
});
it("renders on assistant messages and not on user or failed assistant messages", () => {
setup({
messages: [
{ id: "assistant-ok", sessionId: activeSession.id, role: "assistant", content: "hello", createdAt: "2026-04-08T00:00:00.000Z" },
{ id: "assistant-failed", sessionId: activeSession.id, role: "assistant", content: "failed", createdAt: "2026-04-08T00:00:01.000Z", failureInfo: { summary: "oops" } },
{ id: "user-1", sessionId: activeSession.id, role: "user", content: "hey", createdAt: "2026-04-08T00:00:02.000Z" },
],
});
expect(screen.getByTestId("chat-message-scroll-to-top-assistant-ok")).toHaveAttribute("aria-label", "Scroll message to top");
expect(screen.queryByTestId("chat-message-scroll-to-top-assistant-failed")).toBeNull();
expect(screen.queryByTestId("chat-message-scroll-to-top-user-1")).toBeNull();
});
it("scrolls container to message top with smooth behavior", () => {
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;
Object.defineProperty(container, "scrollTop", { configurable: true, writable: true, value: 20 });
vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ top: 100 } as DOMRect);
vi.spyOn(target, "getBoundingClientRect").mockReturnValue({ top: 260 } as DOMRect);
fireEvent.click(screen.getByTestId("chat-message-scroll-to-top-assistant-ok"));
expect(container.scrollTo).toHaveBeenCalledWith({ top: 180, behavior: "smooth" });
});
it("uses auto behavior when reduced motion is preferred", () => {
vi.mocked(window.matchMedia).mockImplementation((query: string) => ({
matches: query === "(prefers-reduced-motion: reduce)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
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;
Object.defineProperty(container, "scrollTop", { configurable: true, writable: true, value: 0 });
vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ top: 0 } as DOMRect);
vi.spyOn(target, "getBoundingClientRect").mockReturnValue({ top: 120 } as DOMRect);
const button = screen.getByTestId("chat-message-scroll-to-top-assistant-ok");
button.focus();
fireEvent.click(button);
expect(container.scrollTo).toHaveBeenCalledWith({ top: 120, behavior: "auto" });
});
it("renders the affordance for room assistant messages", () => {
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"));
expect(screen.getByTestId("chat-message-scroll-to-top-room-assistant-1")).toBeInTheDocument();
});
});

View File

@@ -12,7 +12,7 @@ const qualityAppTests = [
"app/api/**/*.test.ts",
// Representative workflow/component coverage. Exhaustive modal/view suites
// stay available in the full `dashboard-app` project.
"app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MergeAdvanceNotice,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.worktrunk,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx",
"app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.scroll-to-top,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MergeAdvanceNotice,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.worktrunk,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx",
// Hooks and utilities are fast, user-visible state/formatting behavior.
"app/context/**/*.test.tsx",
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms.resume-instrumentation,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",