From 3351bf990a19f157e1acb5ccd64c67367ed67e04 Mon Sep 17 00:00:00 2001 From: Pooja Date: Tue, 13 Jan 2026 22:09:08 +0530 Subject: [PATCH] fix: websocket message scroll (#6503) * fix: websocket message scroll * fix * fix: icon color * fix: sse message list * fix * rm: sort test * rm: WSResponseSortOrder * fix: auto scroll --- package-lock.json | 10 ++ packages/bruno-app/package.json | 1 + .../WsResponsePane/StyledWrapper.js | 11 ++ .../WSMessagesList/StyledWrapper.js | 4 +- .../WsResponsePane/WSMessagesList/index.js | 112 ++++++++++++++---- .../ResponsePane/WsResponsePane/index.js | 4 +- .../ReduxStore/slices/collections/index.js | 4 +- tests/websockets/connection.spec.ts | 14 --- 8 files changed, 114 insertions(+), 46 deletions(-) diff --git a/package-lock.json b/package-lock.json index eab55e0e5..9fb736dff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24819,6 +24819,15 @@ "react-dom": ">=16.14.0" } }, + "node_modules/react-virtuoso": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.17.0.tgz", + "integrity": "sha512-od3pi2v13v31uzn5zPXC2u3ouISFCVhjFVFch2VvS2Cx7pWA2F1aJa3XhNTN2F07M3lhfnMnsmGeH+7wZICr7w==", + "peerDependencies": { + "react": ">=16 || >=17 || >= 18 || >= 19", + "react-dom": ">=16 || >=17 || >= 18 || >=19" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -30075,6 +30084,7 @@ "react-player": "^2.16.0", "react-redux": "^7.2.9", "react-tooltip": "^5.5.2", + "react-virtuoso": "^4.17.0", "sass": "^1.46.0", "semver": "^7.7.1", "shell-quote": "^1.8.3", diff --git a/packages/bruno-app/package.json b/packages/bruno-app/package.json index b866aa15c..e8341a0f7 100644 --- a/packages/bruno-app/package.json +++ b/packages/bruno-app/package.json @@ -84,6 +84,7 @@ "react-player": "^2.16.0", "react-redux": "^7.2.9", "react-tooltip": "^5.5.2", + "react-virtuoso": "^4.17.0", "sass": "^1.46.0", "semver": "^7.7.1", "shell-quote": "^1.8.3", diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/StyledWrapper.js index 19098970a..142b45b7d 100644 --- a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/StyledWrapper.js @@ -53,6 +53,17 @@ const StyledWrapper = styled.div` align-items: center; margin-left: 10px; } + + div.tabs .action-icon { + color: ${(props) => props.theme.dropdown.iconColor}; + opacity: 0.8; + + &:hover { + color: ${(props) => props.theme.text}; + opacity: 1; + background-color: ${(props) => props.theme.workspace.button.bg}; + } + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/StyledWrapper.js index a0e8c744e..00175777c 100644 --- a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/StyledWrapper.js @@ -1,7 +1,9 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` - overflow-y: auto; + flex: 1; + min-height: 0; + height: 100%; .empty-state { padding: 1rem; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/index.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/index.js index 233be1988..e30e102b1 100644 --- a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/index.js +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/index.js @@ -1,13 +1,11 @@ -import React from 'react'; +import React, { useState, useRef, useEffect, useCallback, memo } from 'react'; import classnames from 'classnames'; import StyledWrapper from './StyledWrapper'; import { IconExclamationCircle, IconChevronRight, IconInfoCircle, IconChevronDown, IconArrowUpRight, IconArrowDownLeft } from '@tabler/icons'; import CodeEditor from 'components/CodeEditor/index'; import { useTheme } from 'providers/Theme'; -import { useState } from 'react'; import { useSelector } from 'react-redux'; -import { useRef } from 'react'; -import { useEffect } from 'react'; +import { Virtuoso } from 'react-virtuoso'; const getContentMeta = (content) => { if (typeof content === 'object') { @@ -61,8 +59,7 @@ const TypeIcon = ({ type }) => { }[type]; }; -const WSMessageItem = ({ message, inFocus }) => { - const [isOpen, setIsOpen] = useState(false); +const WSMessageItem = memo(({ message, isOpen, onToggle }) => { const [showHex, setShowHex] = useState(false); const preferences = useSelector((state) => state.app.preferences); const { displayedTheme } = useTheme(); @@ -82,21 +79,23 @@ const WSMessageItem = ({ message, inFocus }) => { const dateDiff = Date.now() - new Date(message.timestamp).getTime(); if (dateDiff < 1000 * 10) { setIsNew(true); - setTimeout(() => { + const timer = setTimeout(() => { notified.current = true; setIsNew(false); }, 2500); + return () => clearTimeout(timer); } - }, [message]); + }, [message.timestamp]); const canOpenMessage = !isInfo && !isError; + const handleToggle = () => { + if (!canOpenMessage) return; + onToggle?.(message.timestamp); + }; + return (
{ - if (!node) return; - if (inFocus) node.scrollIntoView(); - }} className={classnames('ws-message flex flex-col p-2', { 'ws-incoming': isIncoming, 'ws-outgoing': isOutgoing, @@ -111,10 +110,7 @@ const WSMessageItem = ({ message, inFocus }) => { 'cursor-pointer': canOpenMessage, 'cursor-not-allowed': !canOpenMessage })} - onClick={(e) => { - if (!canOpenMessage) return; - setIsOpen(!isOpen); - }} + onClick={handleToggle} >
@@ -176,23 +172,87 @@ const WSMessageItem = ({ message, inFocus }) => { )}
); -}; +}); + +const WSMessagesList = ({ messages = [] }) => { + const virtuosoRef = useRef(null); + const [scrollerElement, setScrollerElement] = useState(null); + const [openMessages, setOpenMessages] = useState(new Set()); + const userScrolledAwayRef = useRef(false); + + // Toggle message open/closed state by timestamp + const handleMessageToggle = useCallback((timestamp) => { + setOpenMessages((prev) => { + const next = new Set(prev); + if (next.has(timestamp)) { + next.delete(timestamp); + } else { + next.add(timestamp); + } + return next; + }); + }, []); + + useEffect(() => { + if (!scrollerElement) return; + + const handleWheel = (e) => { + // deltaY < 0 means scrolling up + if (e.deltaY < 0) { + userScrolledAwayRef.current = true; + } + }; + + scrollerElement.addEventListener('wheel', handleWheel, { passive: true }); + + return () => { + scrollerElement.removeEventListener('wheel', handleWheel); + }; + }, [scrollerElement]); + + const handleAtBottomStateChange = useCallback((atBottom) => { + if (atBottom) { + // User scrolled back to bottom, re-enable auto-scroll + userScrolledAwayRef.current = false; + } + }, []); + + const followOutput = useCallback((isAtBottom) => { + // Don't auto-scroll if user has scrolled away or has messages open + if (userScrolledAwayRef.current || openMessages.size > 0) { + return false; + } + if (isAtBottom) { + return 'smooth'; + } + return false; + }, [openMessages.size]); + + const renderItem = useCallback((_, msg) => { + const isOpen = openMessages.has(msg.timestamp); + return ; + }, [openMessages, handleMessageToggle]); + + const computeItemKey = useCallback((_, msg) => { + return msg.seq ?? msg.timestamp; + }, []); -const WSMessagesList = ({ order = -1, messages = [] }) => { if (!messages.length) { return
No messages yet.
; } - // sort based on order, seq was newly added and might be missing in some cases and when missing, - // the timestamp will be used instead - const ordered = messages.toSorted((x, y) => ((x.seq ?? x.timestamp) - (y.seq ?? y.timestamp)) * (-order)); - return ( - {ordered.map((msg, idx, src) => { - const inFocus = order === -1 ? src.length - 1 === idx : idx === 0; - return ; - })} + ); }; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.js index 28cc50efc..5722a4166 100644 --- a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.js +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.js @@ -13,11 +13,10 @@ import StyledWrapper from './StyledWrapper'; import ResponseLayoutToggle from '../ResponseLayoutToggle'; import ResponsiveTabs from 'ui/ResponsiveTabs'; import WSMessagesList from './WSMessagesList'; -import WSResponseSortOrder from './WSResponseSortOrder'; import WSResponseHeaders from './WSResponseHeaders'; const WSResult = ({ response }) => { - return ; + return ; }; const WSResponsePane = ({ item, collection }) => { @@ -116,7 +115,6 @@ const WSResponsePane = ({ item, collection }) => { <> - { await expect(locators.messages().nth(1).getByText('Closed')).toBeAttached(); }); - test('websocket messages sorting can be changed', async ({ pageWithUserData: page, restartApp }) => { - const locators = buildWebsocketCommonLocators(page); - - await locators.toolbar.latestLast().click(); - - await expect(locators.messages().first().getByText('Closed')).toBeAttached(); - await expect(locators.messages().nth(1).getByText('Connected to ws://')).toBeAttached(); - - await locators.toolbar.latestFirst().click(); - - await expect(locators.messages().first().getByText('Connected to ws://')).toBeAttached(); - await expect(locators.messages().nth(1).getByText('Closed')).toBeAttached(); - }); - test('websocket request can send messages', async ({ pageWithUserData: page, restartApp }) => { const locators = buildWebsocketCommonLocators(page);