From 8d8bc13a3b0f9f495e1868430e1f4fd1fa7cbd40 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Sat, 20 Jun 2026 21:53:28 -0700 Subject: [PATCH] Add option to disable scroll. Closes #299. --- frontend/AGENTS.md | 4 +-- frontend/src/components/RawPacketFeedView.tsx | 21 +++++++++++++ frontend/src/components/RawPacketList.tsx | 15 ++++++++-- frontend/src/test/rawPacketFeedView.test.tsx | 10 +++++++ frontend/src/test/rawPacketList.test.tsx | 30 +++++++++++++++++++ 5 files changed, 75 insertions(+), 5 deletions(-) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 0290c97..966e196 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -507,9 +507,9 @@ PYTHONPATH=. uv run pytest tests/ -v This is intentional. In the sidebar, unread direct messages for actual contact conversations are treated as mention-equivalent for badge styling. That means both the Contacts section header and contact unread badges themselves use the highlighted mention-style colors for unread DMs, including when those contacts appear in Favorites. Repeaters do not inherit this rule, and channel badges still use mention styling only for real `@[name]` mentions. -### RawPacketList always scrolls to bottom +### RawPacketList autoscroll -`RawPacketList` unconditionally scrolls to the latest packet on every update. This is intentional — the packet feed is a live status display, not an interactive log meant for lingering or long-term analysis. Users watching it want to see the newest packet, not hold a scroll position. +`RawPacketList` sticks to the latest packet on every update when its `autoScroll` prop is true (the default). `RawPacketFeedView` exposes an "Autoscroll" checkbox next to the type filters (default ticked, session-only — intentionally not persisted) so users can pause scrolling to correlate older packets. Toggling it back on jumps to the bottom immediately (`autoScroll` is an effect dependency). ## Editing Checklist diff --git a/frontend/src/components/RawPacketFeedView.tsx b/frontend/src/components/RawPacketFeedView.tsx index 134343d..43f13bf 100644 --- a/frontend/src/components/RawPacketFeedView.tsx +++ b/frontend/src/components/RawPacketFeedView.tsx @@ -478,6 +478,8 @@ export function RawPacketFeedView({ const [analyzeModalOpen, setAnalyzeModalOpen] = useState(false); const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false); const [enabledTypes, setEnabledTypes] = useState>(() => new Set(KNOWN_PAYLOAD_TYPES)); + // Autoscroll defaults on; intentionally not persisted across refreshes. + const [autoScroll, setAutoScroll] = useState(true); const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]); @@ -638,6 +640,15 @@ export function RawPacketFeedView({ ))} + )} @@ -671,6 +682,15 @@ export function RawPacketFeedView({ ))} + @@ -680,6 +700,7 @@ export function RawPacketFeedView({ packets={filteredPackets} channels={channels} onPacketClick={setSelectedPacket} + autoScroll={autoScroll} /> diff --git a/frontend/src/components/RawPacketList.tsx b/frontend/src/components/RawPacketList.tsx index 3f58526..ee18781 100644 --- a/frontend/src/components/RawPacketList.tsx +++ b/frontend/src/components/RawPacketList.tsx @@ -8,6 +8,8 @@ interface RawPacketListProps { packets: RawPacket[]; channels?: Channel[]; onPacketClick?: (packet: RawPacket) => void; + /** When true (default), the feed sticks to the newest packet. */ + autoScroll?: boolean; } function formatTime(timestamp: number): string { @@ -58,7 +60,12 @@ function getRouteTypeLabel(routeType: string): string { } } -export function RawPacketList({ packets, channels, onPacketClick }: RawPacketListProps) { +export function RawPacketList({ + packets, + channels, + onPacketClick, + autoScroll = true, +}: RawPacketListProps) { const listRef = useRef(null); const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]); @@ -76,11 +83,13 @@ export function RawPacketList({ packets, channels, onPacketClick }: RawPacketLis [decodedPackets] ); + // Stick to the newest packet while autoscroll is on. Toggling it back on also + // jumps to the bottom immediately (autoScroll is a dependency). useEffect(() => { - if (listRef.current) { + if (autoScroll && listRef.current) { listRef.current.scrollTop = listRef.current.scrollHeight; } - }, [packets]); + }, [packets, autoScroll]); if (packets.length === 0) { return ( diff --git a/frontend/src/test/rawPacketFeedView.test.tsx b/frontend/src/test/rawPacketFeedView.test.tsx index 4901887..f987c83 100644 --- a/frontend/src/test/rawPacketFeedView.test.tsx +++ b/frontend/src/test/rawPacketFeedView.test.tsx @@ -138,6 +138,16 @@ describe('RawPacketFeedView', () => { expect(screen.getByText('Traffic Timeline')).toBeInTheDocument(); }); + it('shows an Autoscroll toggle that is ticked by default and can be unchecked', () => { + renderView(); + + const autoscroll = screen.getByLabelText('Autoscroll') as HTMLInputElement; + expect(autoscroll.checked).toBe(true); + + fireEvent.click(autoscroll); + expect((screen.getByLabelText('Autoscroll') as HTMLInputElement).checked).toBe(false); + }); + it('analyzes a pasted raw packet without adding it to the live feed', () => { renderView({ channels: [TEST_CHANNEL] }); diff --git a/frontend/src/test/rawPacketList.test.tsx b/frontend/src/test/rawPacketList.test.tsx index 41a17f9..55a1548 100644 --- a/frontend/src/test/rawPacketList.test.tsx +++ b/frontend/src/test/rawPacketList.test.tsx @@ -36,4 +36,34 @@ describe('RawPacketList', () => { expect(onPacketClick).toHaveBeenCalledWith(packet); }); + + it('sticks to the bottom on new packets when autoScroll is on, and holds when off', () => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get: () => 500, + }); + try { + const { container, rerender } = render( + + ); + const list = container.querySelector('.overflow-y-auto') as HTMLElement; + + rerender( + + ); + expect(list.scrollTop).toBe(500); + + // Pause autoscroll, simulate the user scrolling up, then receive a packet. + list.scrollTop = 0; + rerender( + + ); + expect(list.scrollTop).toBe(0); + } finally { + delete (HTMLElement.prototype as { scrollHeight?: number }).scrollHeight; + } + }); });