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
This commit is contained in:
Pooja
2026-01-13 22:09:08 +05:30
committed by GitHub
parent 07fff423bb
commit 3351bf990a
8 changed files with 114 additions and 46 deletions

10
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 (
<div
ref={(node) => {
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}
>
<div className="flex min-w-0 shrink">
<span className="message-type-icon">
@@ -176,23 +172,87 @@ const WSMessageItem = ({ message, inFocus }) => {
)}
</div>
);
};
});
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 <WSMessageItem message={msg} isOpen={isOpen} onToggle={handleMessageToggle} />;
}, [openMessages, handleMessageToggle]);
const computeItemKey = useCallback((_, msg) => {
return msg.seq ?? msg.timestamp;
}, []);
const WSMessagesList = ({ order = -1, messages = [] }) => {
if (!messages.length) {
return <StyledWrapper><div className="empty-state">No messages yet.</div></StyledWrapper>;
}
// 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 (
<StyledWrapper className="ws-messages-list flex flex-col">
{ordered.map((msg, idx, src) => {
const inFocus = order === -1 ? src.length - 1 === idx : idx === 0;
return <WSMessageItem key={msg.seq ? msg.seq : msg.timestamp} inFocus={inFocus} id={idx} message={msg} />;
})}
<Virtuoso
ref={virtuosoRef}
scrollerRef={setScrollerElement}
data={messages}
itemContent={renderItem}
computeItemKey={computeItemKey}
followOutput={followOutput}
initialTopMostItemIndex={messages.length - 1}
atBottomStateChange={handleAtBottomStateChange}
/>
</StyledWrapper>
);
};

View File

@@ -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 <WSMessagesList order={response?.sortOrder} messages={response.responses || []} />;
return <WSMessagesList messages={response.responses || []} />;
};
const WSResponsePane = ({ item, collection }) => {
@@ -116,7 +115,6 @@ const WSResponsePane = ({ item, collection }) => {
<>
<ResponseLayoutToggle />
<ResponseClear item={item} collection={collection} />
<WSResponseSortOrder item={item} collection={collection} />
<WSStatusCode
status={response.statusCode}
text={response.statusText}

View File

@@ -3154,13 +3154,13 @@ export const collectionsSlice = createSlice({
const item = findItemInCollection(collection, itemUid);
if (data.data) {
item.response.data ||= [];
item.response.data = [{
item.response.data.push({
type: 'incoming',
seq,
message: data.data,
messageHexdump: hexdump(data.data),
timestamp: timestamp || Date.now()
}].concat(item.response.data);
});
}
if (item.response.dataBuffer && item.response.dataBuffer.length && data.dataBuffer) {
item.response.dataBuffer = Buffer.concat([Buffer.from(item.response.dataBuffer), Buffer.from(data.dataBuffer)]);

View File

@@ -40,20 +40,6 @@ test.describe.serial('websockets', () => {
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);