From 0014d13ca83e5a587863697643c3fff445a57fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Mon, 23 Feb 2026 16:20:41 +0000 Subject: [PATCH] feat(acp-client): add scroll-to-bottom FAB in chat message area Small floating action button (down chevron) appears over the message area when the user has scrolled up and there are messages. Clicking it calls scrollToBottom() from the useAutoScroll hook. - Wrapped message area in relative container for FAB positioning - FAB hidden when at bottom or when no messages exist - 32px circle, white bg, subtle shadow, hover state Co-Authored-By: Claude Opus 4.6 --- .../src/components/AgentPanel.test.tsx | 76 ++++++++++++++++++- .../acp-client/src/components/AgentPanel.tsx | 34 ++++++--- 2 files changed, 100 insertions(+), 10 deletions(-) diff --git a/packages/acp-client/src/components/AgentPanel.test.tsx b/packages/acp-client/src/components/AgentPanel.test.tsx index a361d91..66c4702 100644 --- a/packages/acp-client/src/components/AgentPanel.test.tsx +++ b/packages/acp-client/src/components/AgentPanel.test.tsx @@ -276,7 +276,7 @@ describe('AgentPanel auto-scroll behavior', () => { // is the right element. expect(scrollContainer).toBeTruthy(); expect(scrollContainer.classList.contains('overflow-y-auto')).toBe(true); - expect(scrollContainer.classList.contains('flex-1')).toBe(true); + expect(scrollContainer.classList.contains('h-full')).toBe(true); }); it('renders streaming messages correctly in scroll container', () => { @@ -507,3 +507,77 @@ describe('AgentPanel scroll reset on replay', () => { expect(resetToBottomSpy).not.toHaveBeenCalled(); }); }); + +// ============================================================================= +// Scroll-to-bottom FAB tests +// ============================================================================= + +describe('AgentPanel scroll-to-bottom FAB', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let autoScrollSpy: any; + let scrollToBottomSpy: ReturnType; + + function setupMock(isAtBottom: boolean) { + scrollToBottomSpy = vi.fn(); + autoScrollSpy = vi.spyOn(autoScrollModule, 'useAutoScroll').mockReturnValue({ + scrollRef: vi.fn(), + isAtBottom, + scrollToBottom: scrollToBottomSpy, + resetToBottom: vi.fn(), + }); + } + + afterEach(() => { + autoScrollSpy?.mockRestore(); + }); + + it('shows scroll-to-bottom button when not at bottom and messages exist', () => { + setupMock(false); + const session = createMockSession(); + const items: ConversationItem[] = [ + { kind: 'user_message', id: '1', text: 'Hello', timestamp: Date.now() }, + ]; + const messages = createMockMessages({ items }); + + render(); + + expect(screen.getByLabelText('Scroll to bottom')).toBeTruthy(); + }); + + it('hides scroll-to-bottom button when at bottom', () => { + setupMock(true); + const session = createMockSession(); + const items: ConversationItem[] = [ + { kind: 'user_message', id: '1', text: 'Hello', timestamp: Date.now() }, + ]; + const messages = createMockMessages({ items }); + + render(); + + expect(screen.queryByLabelText('Scroll to bottom')).toBeNull(); + }); + + it('hides scroll-to-bottom button when no messages', () => { + setupMock(false); + const session = createMockSession(); + const messages = createMockMessages({ items: [] }); + + render(); + + expect(screen.queryByLabelText('Scroll to bottom')).toBeNull(); + }); + + it('calls scrollToBottom when FAB is clicked', () => { + setupMock(false); + const session = createMockSession(); + const items: ConversationItem[] = [ + { kind: 'user_message', id: '1', text: 'Hello', timestamp: Date.now() }, + ]; + const messages = createMockMessages({ items }); + + render(); + + fireEvent.click(screen.getByLabelText('Scroll to bottom')); + expect(scrollToBottomSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/acp-client/src/components/AgentPanel.tsx b/packages/acp-client/src/components/AgentPanel.tsx index be9af7d..6a0a92c 100644 --- a/packages/acp-client/src/components/AgentPanel.tsx +++ b/packages/acp-client/src/components/AgentPanel.tsx @@ -80,7 +80,7 @@ export const AgentPanel = React.forwardRef(fu const [showPalette, setShowPalette] = useState(false); const [showSettings, setShowSettings] = useState(false); const [showPlanModal, setShowPlanModal] = useState(false); - const { scrollRef, resetToBottom } = useAutoScroll(); + const { scrollRef, isAtBottom, scrollToBottom, resetToBottom } = useAutoScroll(); const inputRef = useRef(null); const paletteRef = useRef(null); @@ -281,15 +281,31 @@ export const AgentPanel = React.forwardRef(fu )} {/* Message area */} -
- {messages.items.length === 0 && !isReconnecting && ( -
- Send a message to start the conversation -
+
+
+ {messages.items.length === 0 && !isReconnecting && ( +
+ Send a message to start the conversation +
+ )} + {messages.items.map((item) => ( + + ))} +
+ {/* Scroll-to-bottom FAB */} + {!isAtBottom && messages.items.length > 0 && ( + )} - {messages.items.map((item) => ( - - ))}
{/* Input area */}