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
51 changes: 39 additions & 12 deletions components/frontend/src/components/session/MessagesTab.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"use client";

import React, { useState, useRef, useEffect, useMemo, useLayoutEffect, useCallback } from "react";
import { MessageSquare, ChevronUp } from "lucide-react";
import { MessageSquare, ChevronUp, ChevronDown } from "lucide-react";
import { StreamMessage } from "@/components/ui/stream-message";
import { LoadingDots } from "@/components/ui/message";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ChatInputBox } from "@/components/chat/ChatInputBox";
import { QueuedMessageBubble } from "@/components/chat/QueuedMessageBubble";
import { useCurrentUser } from "@/services/queries/use-auth";
Expand Down Expand Up @@ -99,6 +100,7 @@ const MessagesTab: React.FC<MessagesTabProps> = ({ session, streamMessages, chat
const checkIfAtBottom = () => {
const container = messagesContainerRef.current;
if (!container) return true;
if (container.scrollHeight <= container.clientHeight) return true;
const threshold = 50;
return container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
};
Expand Down Expand Up @@ -287,17 +289,42 @@ const MessagesTab: React.FC<MessagesTabProps> = ({ session, streamMessages, chat
)}
</div>

{showScrollToTop && (
<Button
variant="outline"
size="icon-sm"
onClick={scrollToTop}
aria-label="Scroll to top"
className="absolute bottom-3 right-5 z-10 rounded-full shadow-md transition-opacity duration-200"
>
<ChevronUp className="h-4 w-4" />
</Button>
)}
<TooltipProvider>
<div className="absolute bottom-3 right-5 z-10 flex flex-col gap-1">
<div className={`transition-all duration-200 ${showScrollToTop ? "opacity-100 scale-100" : "opacity-0 scale-75 pointer-events-none"}`}>
<Tooltip open={showScrollToTop ? undefined : false}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon-sm"
onClick={scrollToTop}
aria-label="Scroll to top"
className="rounded-full shadow-md cursor-pointer"
>
<ChevronUp className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Scroll to top</TooltipContent>
</Tooltip>
</div>
<div className={`transition-all duration-200 ${!isAtBottom ? "opacity-100 scale-100" : "opacity-0 scale-75 pointer-events-none"}`}>
<Tooltip open={isAtBottom ? false : undefined}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon-sm"
onClick={() => messagesContainerRef.current?.scrollTo({ top: messagesContainerRef.current.scrollHeight, behavior: "smooth" })}
aria-label="Scroll to bottom"
className="rounded-full shadow-md cursor-pointer"
>
<ChevronDown className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Scroll to bottom</TooltipContent>
</Tooltip>
Comment on lines +294 to +324
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make the hidden buttons unfocusable.

pointer-events-none only blocks mouse input. Both buttons still stay in the tab order while invisible, so keyboard users can land on hidden controls and potentially surface the tooltip.

Fix
-                <Button
+                <Button
                   variant="outline"
                   size="icon-sm"
                   onClick={scrollToTop}
                   aria-label="Scroll to top"
+                  tabIndex={showScrollToTop ? 0 : -1}
+                  aria-hidden={!showScrollToTop}
                   className="rounded-full shadow-md cursor-pointer"
                 >
...
-                <Button
+                <Button
                   variant="outline"
                   size="icon-sm"
                   onClick={() => messagesContainerRef.current?.scrollTo({ top: messagesContainerRef.current.scrollHeight, behavior: "smooth" })}
                   aria-label="Scroll to bottom"
+                  tabIndex={!isAtBottom ? 0 : -1}
+                  aria-hidden={isAtBottom}
                   className="rounded-full shadow-md cursor-pointer"
                 >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className={`transition-all duration-200 ${showScrollToTop ? "opacity-100 scale-100" : "opacity-0 scale-75 pointer-events-none"}`}>
<Tooltip open={showScrollToTop ? undefined : false}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon-sm"
onClick={scrollToTop}
aria-label="Scroll to top"
className="rounded-full shadow-md cursor-pointer"
>
<ChevronUp className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Scroll to top</TooltipContent>
</Tooltip>
</div>
<div className={`transition-all duration-200 ${!isAtBottom ? "opacity-100 scale-100" : "opacity-0 scale-75 pointer-events-none"}`}>
<Tooltip open={isAtBottom ? false : undefined}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon-sm"
onClick={() => messagesContainerRef.current?.scrollTo({ top: messagesContainerRef.current.scrollHeight, behavior: "smooth" })}
aria-label="Scroll to bottom"
className="rounded-full shadow-md cursor-pointer"
>
<ChevronDown className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Scroll to bottom</TooltipContent>
</Tooltip>
<div className={`transition-all duration-200 ${showScrollToTop ? "opacity-100 scale-100" : "opacity-0 scale-75 pointer-events-none"}`}>
<Tooltip open={showScrollToTop ? undefined : false}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon-sm"
onClick={scrollToTop}
aria-label="Scroll to top"
tabIndex={showScrollToTop ? 0 : -1}
aria-hidden={!showScrollToTop}
className="rounded-full shadow-md cursor-pointer"
>
<ChevronUp className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Scroll to top</TooltipContent>
</Tooltip>
</div>
<div className={`transition-all duration-200 ${!isAtBottom ? "opacity-100 scale-100" : "opacity-0 scale-75 pointer-events-none"}`}>
<Tooltip open={isAtBottom ? false : undefined}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon-sm"
onClick={() => messagesContainerRef.current?.scrollTo({ top: messagesContainerRef.current.scrollHeight, behavior: "smooth" })}
aria-label="Scroll to bottom"
tabIndex={!isAtBottom ? 0 : -1}
aria-hidden={isAtBottom}
className="rounded-full shadow-md cursor-pointer"
>
<ChevronDown className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Scroll to bottom</TooltipContent>
</Tooltip>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/frontend/src/components/session/MessagesTab.tsx` around lines 294
- 324, The two floating buttons (the one using showScrollToTop/scrollToTop and
the one using isAtBottom/messagesContainerRef) remain in the tab order when
visually hidden; update each Button to be unfocusable and hidden from assistive
tech when its container is hidden by adding conditional attributes like
tabIndex={showScrollToTop ? 0 : -1} (and for the other button
tabIndex={isAtBottom ? -1 : 0}) and aria-hidden={showScrollToTop ? "false" :
"true"} (and aria-hidden for the other using isAtBottom), or alternatively set
disabled when hidden; apply these changes on the Button elements referenced (the
Button inside the TooltipTrigger for scrollToTop and the Button that scrolls to
bottom) so keyboard users cannot focus hidden controls and tooltips won’t be
exposed.

</div>
</div>
</TooltipProvider>
</div>

<ChatInputBox
Expand Down
115 changes: 115 additions & 0 deletions specs/frontend/sessions/messages/scroll-navigation.spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Session Message Scroll Navigation

## Purpose

The session message view SHALL provide contextual scroll navigation buttons that allow users to quickly navigate to the top or bottom of the message list without manual scrolling.

## Requirements

### Requirement: Scroll-to-Top Button Visibility

The message view SHALL display a scroll-to-top button when the user has scrolled past a threshold distance from the top of the message container.

#### Scenario: Button appears after scrolling down

- GIVEN a message list with content taller than the viewport
- WHEN the user scrolls past a threshold distance from the top
- THEN a scroll-to-top button becomes visible

#### Scenario: Button hidden near the top

- GIVEN the scroll-to-top button is visible
- WHEN the user scrolls back within the threshold distance of the top
- THEN the button becomes hidden

### Requirement: Scroll-to-Bottom Button Visibility

The message view SHALL display a scroll-to-bottom button when the user is not at the bottom of the message list.

#### Scenario: Button appears when scrolled up

- GIVEN a message list with content taller than the viewport
- WHEN the user scrolls up so the bottom of the content is not visible
- THEN a scroll-to-bottom button becomes visible

#### Scenario: Button hidden at bottom

- GIVEN the scroll-to-bottom button is visible
- WHEN the user scrolls to near the bottom of the content
- THEN the button becomes hidden

### Requirement: No Buttons on Short Content

Neither scroll button SHALL appear when the message content does not overflow the container.

#### Scenario: Few messages

- GIVEN a message list shorter than the viewport height
- WHEN the view renders
- THEN neither scroll button is visible

### Requirement: Smooth Animated Transitions

Both buttons SHALL animate in and out smoothly rather than appearing or disappearing abruptly. Hidden buttons SHALL NOT trigger hover effects or tooltips.

#### Scenario: Button fades in

- GIVEN a scroll button is hidden
- WHEN its visibility condition becomes true
- THEN the button transitions to visible smoothly

#### Scenario: Button fades out

- GIVEN a scroll button is visible
- WHEN its visibility condition becomes false
- THEN the button transitions to hidden smoothly
- AND the button does not receive pointer events while hidden
- AND the button's tooltip does not appear while hidden

### Requirement: Smooth User-Initiated Scrolling

Both buttons SHALL scroll smoothly when activated by user click.

#### Scenario: Scroll to top

- GIVEN the scroll-to-top button is visible
- WHEN the user clicks it
- THEN the message container scrolls smoothly to the top

#### Scenario: Scroll to bottom

- GIVEN the scroll-to-bottom button is visible
- WHEN the user clicks it
- THEN the message container scrolls smoothly to the bottom

### Requirement: Instant Auto-Scroll During Streaming

Auto-scroll that keeps the viewport pinned to new messages during streaming SHALL remain instant and not use smooth scrolling.

#### Scenario: New message during streaming

- GIVEN the user is at the bottom of the message list
- WHEN a new streaming message or token arrives
- THEN the container scrolls instantly to show the new content
- AND the scroll is not animated

### Requirement: Button Tooltips

Both buttons SHALL display a tooltip describing their action.

#### Scenario: Tooltip content

- GIVEN either scroll button is visible
- WHEN the user hovers over the button
- THEN a tooltip appears with the text "Scroll to top" or "Scroll to bottom" respectively

### Requirement: Button Layout

Both buttons SHALL be positioned in the bottom-right corner of the message container, stacked vertically with the scroll-to-top button above the scroll-to-bottom button.

#### Scenario: Both buttons visible

- GIVEN the user has scrolled past the top threshold and is not at the bottom
- WHEN both buttons are visible
- THEN they appear stacked vertically in the bottom-right corner of the message area
- AND the scroll-to-top button is above the scroll-to-bottom button
Loading