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:
committed by
gsxdsm
parent
f21464070a
commit
8e872301c4
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user