Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion packages/acp-client/src/components/AgentPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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<typeof vi.fn>;

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(<AgentPanel session={session} messages={messages} />);

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(<AgentPanel session={session} messages={messages} />);

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(<AgentPanel session={session} messages={messages} />);

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(<AgentPanel session={session} messages={messages} />);

fireEvent.click(screen.getByLabelText('Scroll to bottom'));
expect(scrollToBottomSpy).toHaveBeenCalledTimes(1);
});
});
34 changes: 25 additions & 9 deletions packages/acp-client/src/components/AgentPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export const AgentPanel = React.forwardRef<AgentPanelHandle, AgentPanelProps>(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<HTMLTextAreaElement>(null);
const paletteRef = useRef<SlashCommandPaletteHandle>(null);

Expand Down Expand Up @@ -281,15 +281,31 @@ export const AgentPanel = React.forwardRef<AgentPanelHandle, AgentPanelProps>(fu
)}

{/* Message area */}
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-1">
{messages.items.length === 0 && !isReconnecting && (
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
Send a message to start the conversation
</div>
<div className="relative flex-1 min-h-0">
<div ref={scrollRef} className="h-full overflow-y-auto p-4 space-y-1">
{messages.items.length === 0 && !isReconnecting && (
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
Send a message to start the conversation
</div>
)}
{messages.items.map((item) => (
<ConversationItemView key={item.id} item={item} />
))}
</div>
{/* Scroll-to-bottom FAB */}
{!isAtBottom && messages.items.length > 0 && (
<button
type="button"
onClick={scrollToBottom}
className="absolute bottom-3 right-3 w-8 h-8 flex items-center justify-center rounded-full bg-white border border-gray-300 shadow-md text-gray-500 hover:bg-gray-50 hover:text-gray-700 transition-colors"
aria-label="Scroll to bottom"
title="Scroll to bottom"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
)}
{messages.items.map((item) => (
<ConversationItemView key={item.id} item={item} />
))}
</div>

{/* Input area */}
Expand Down
Loading