diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index 6dec075bb..51106189f 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -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; diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 0e8024527..af2b38119 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -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; 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 :
{renderedUserContent}
} - {!failureInfo && copyAction} + {isAssistantMessage && !failureInfo && (copyAction || onScrollToTop) && ( +
+ {copyAction} + {onScrollToTop && ( + + )} +
+ )} {renderToolCalls(message.toolCalls)} {message.thinkingOutput && (
@@ -2500,6 +2518,18 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView ), [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(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 (
{/* 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} /> ))}
@@ -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} /> ))} 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 new file mode 100644 index 000000000..a762a7576 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/ChatView.scroll-to-top.test.tsx @@ -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(); + return { + ...actual, + useChatRooms: vi.fn(), + }; +}); +vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }), + }; +}); +vi.mock("../../api", async (importOriginal) => { + const actual = await importOriginal(); + 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(); + return { + ...actual, + ArrowUpToLine: (props: any) => , + }; +}); + +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 = {}, roomsOverrides: Partial = {}, experimentalFeatures?: Record) { + mockUseChat.mockReturnValue({ ...defaultChatState, ...chatOverrides }); + mockUseChatRooms.mockReturnValue({ ...defaultRoomsState, ...roomsOverrides }); + return render(); +} + +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(); + }); +}); diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index e6e473322..aada561df 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -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}",