Sticky scroll doesn't work?

In Quick Start-Guide: Add an AI Chat Bot to a Vaadin + Spring Boot Application the code claims to feature “Sticky scroll: keep the latest answer in view.”

I am trying to implement the same in my AI chat agent, but it’s not working. I expect I have done something to interrupt the normal scroller behavior, but I can’t find the problem. Do you see the issue?

Please see the setupLayout method below.

@PageTitle("Console Whisperer Agent")
@Route("")
@Menu(order = 0, icon = LineAwesomeIconUrl.HOME_SOLID)
public class HomeViewAgent extends Composite<VerticalLayout> {

    private static final Logger log = LoggerFactory.getLogger(HomeViewAgent.class);

    private final MessageList messageList = new MessageList();
    private final MessageInput messageInput = new MessageInput();

    private final ChatMessageManager messageManager;
    private final FileUploadHandler documentHandler;
    private final StatusBarManager statusBarManager;
    private final ProjectSetupCoordinator projectSetupCoordinator;
    private final WebSocketLifecycleManager webSocketLifecycleManager;
    private final ProjectService projectService;
    private final MessageBroadcaster messageBroadcaster;
    private final AgentAuthenticationService authService;

    public HomeViewAgent(MessageBroadcaster messageBroadcaster, ProjectService projectService, AgentAuthenticationService authService) {
        this.projectService = projectService;
        this.messageBroadcaster = messageBroadcaster;
        this.authService = authService;

        messageManager = new ChatMessageManager(messageList);
        documentHandler = new FileUploadHandler(docs -> {
            if (!docs.isEmpty()) {
                log.info("Documents attached: {}", docs.size());
            }
        });
        statusBarManager = new StatusBarManager(projectService, this::handleProjectChange);
        projectSetupCoordinator = new ProjectSetupCoordinator(projectService, messageManager);
        webSocketLifecycleManager = new WebSocketLifecycleManager(messageBroadcaster, messageManager);

        initializeComponents();
        setupLayout();
        checkAndSetupProject();
    }

    @Override
    protected void onAttach(AttachEvent attachEvent) {
        super.onAttach(attachEvent);

        WebStorage.getItem(WebStorage.Storage.LOCAL_STORAGE, "jwt-token", token -> {
            if (token != null && !token.isEmpty()) {
                authService.setToken(token);
                messageBroadcaster.attemptConnect();
                log.info("Token found in session storage");
            } else {
                log.info("No token found, redirecting to login");
                attachEvent.getUI().navigate(LoginViewAgent.class);
            }
        });

        webSocketLifecycleManager.handleAttach(attachEvent);
        addPageUnloadDetection();
    }

    @Override
    protected void onDetach(DetachEvent detachEvent) {
        super.onDetach(detachEvent);
        webSocketLifecycleManager.handleDetach();
    }

    private void checkAndSetupProject() {
        projectSetupCoordinator.checkAndSetupProject(statusBarManager::updateStatusBar);
    }

    private void setupLayout() {
        final Scroller scroller = new Scroller(messageList);
        scroller.setHeightFull();
        getContent().addAndExpand(scroller);

        VerticalLayout inputArea = new VerticalLayout();
        inputArea.setSpacing(false);
        inputArea.add(documentHandler.getUploadComponent(), messageInput);

        getContent().add(statusBarManager.getStatusBar(), inputArea);
        messageInput.focus();
    }

    private void initializeComponents() {
        messageInput.setId("message-input");
        messageList.setId("message-list");
        messageList.setMarkdown(true);
        messageInput.setWidthFull();
        messageInput.addSubmitListener(this::handleMessageSubmission);
    }

    private void handleMessageSubmission(MessageInput.SubmitEvent event) {
        String text = event.getValue();

        String showBibleContent = readShowBible();
        if (showBibleContent == null) {
            return;
        }

        String displayMessage = documentHandler.formatMessageWithDocumentInfo(text);
        messageManager.appendMessage(displayMessage, "You");

        UserMessage userMessage = new UserMessage(text, showBibleContent, documentHandler.createDocumentInfoList(), ToolMode.API_CALLS);
        messageBroadcaster.sendMessage(userMessage);

        documentHandler.clearDocuments();
        messageManager.prepareForServerResponse();
    }

    private String readShowBible() {
        try {
            return projectService.readShowBible();
        } catch (IOException e) {
            log.error("Failed to read show bible", e);
            Notification.show("Warning: Could not read show bible file", 3000,
                    Notification.Position.BOTTOM_CENTER).addThemeVariants(NotificationVariant.LUMO_WARNING);
            return "";
        } catch (IllegalStateException e) {
            log.error("No project configured", e);
            Notification.show("Please select a project first", 3000,
                    Notification.Position.BOTTOM_CENTER).addThemeVariants(NotificationVariant.LUMO_ERROR);
            checkAndSetupProject();
            return null;
        }
    }

    private void handleProjectChange() {
        statusBarManager.updateStatusBar();
        projectSetupCoordinator.resetProject();
    }

    private void addPageUnloadDetection() {
        getUI().ifPresent(ui -> ui.getPage().executeJs("""
                window.addEventListener('beforeunload', function(e) {
                    // Mark shutdown intent but DON'T clear the token
                    window.sessionStorage.setItem('shutdown-intent', Date.now().toString());
                
                    if (navigator.sendBeacon) {
                        navigator.sendBeacon('/api/shutdown', '');
                    } else {
                        fetch('/api/shutdown', {
                            method: 'POST',
                            keepalive: true
                        }).catch(() => {});
                    }
                });
                
                // Clear any stale shutdown intent on successful page load
                window.sessionStorage.removeItem('shutdown-intent');
                """));
    }
}

Please see appendMessage below.

public class ChatMessageManager {
    private final MessageList messageList;
    private final AtomicReference<MessageListItem> lastServerMessage = new AtomicReference<>();

    public ChatMessageManager(MessageList messageList) {
        this.messageList = messageList;
    }

    public void appendMessage(String text, String sender) {
        MessageListItem item = new MessageListItem(text, Instant.now(), sender);
        item.setUserColorIndex(0);
        messageList.addItem(item);
        lastServerMessage.set(null);
    }

    public void appendStreamingMessage(String message) {
        MessageListItem currentMessage = lastServerMessage.get();
        if (currentMessage != null) {
            currentMessage.setText(currentMessage.getText() + message);
        } else {
            appendMessage(message, "Server");
        }
    }

    public void prepareForServerResponse() {
        MessageListItem serverResponse = new MessageListItem("", Instant.now(), "Server");
        serverResponse.setUserColorIndex(1);
        messageList.addItem(serverResponse);
        lastServerMessage.set(serverResponse);
    }

    public void clearMessages() {
        messageList.setItems();
        lastServerMessage.set(null);
    }

}

Messages overflow off the bottom are never scrolled into view.

I did try the solution from Automatically scroll Scroller which is to remove the MessageList from the Scroller, but that didn’t work for me either:

private void setupLayout() {
//        final Scroller scroller = new Scroller(messageList);
//        scroller.setHeightFull();
//        getContent().addAndExpand(scroller);

        messageList.setHeightFull();
        getContent().addAndExpand(messageList);

        VerticalLayout inputArea = new VerticalLayout();
        inputArea.setSpacing(false);
        inputArea.add(documentHandler.getUploadComponent(), messageInput);

        getContent().add(statusBarManager.getStatusBar(), inputArea);
        messageInput.focus();
    }

For a second it seemed like maybe home-view.css was the problem, but I completely removed it and that did not fix the problem.

I also tried enabling the layoutComponentImprovements feature flag.

I’ve always struggled with troubleshooting the styling in Vaadin apps. Do you have any tips or workflows?

I have gone through the training multiple times, but since I only need to know it once a month I forget all of the styles that each component comes preconfigured with.

OK, I think maybe the problem is not me. I followed the instructions at Quick Start-Guide | AI Chatbot with Vaadin exactly and the same problem exists. There is no sticky scroll behavior.

Here’s the github repo.

Hi, you can enable sticky scroll with the following CSS additions to styles.css:

vaadin-scroller {
  flex: 1;
  scroll-snap-type: y proximity;
}

vaadin-scroller::after {
  display: block;
  content: '';
  scroll-snap-align: end;
  min-height: 1px;
}

To make UX even nicer, you could add streaming class name to the chat while it’s streaming new tokens and prefix the selectors with .streaming vaadin-scroller so it would only auto-scroll while streaming.

2 Likes

I added a ticket: [vaadin-scroller] Add streaming / stricky scroll theme variant. · Issue #10257 · vaadin/web-components · GitHub

2 Likes

Thanks! That worked. I have updated the POC app above with your suggestions. I also upgraded it to Java 25 and added a MainLayout to make it more like my production app.