Add option to disable scroll. Closes #299.

This commit is contained in:
Jack Kingsman
2026-06-20 21:53:28 -07:00
parent 66148c2c39
commit 8d8bc13a3b
5 changed files with 75 additions and 5 deletions
+2 -2
View File
@@ -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
@@ -478,6 +478,8 @@ export function RawPacketFeedView({
const [analyzeModalOpen, setAnalyzeModalOpen] = useState(false);
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const [enabledTypes, setEnabledTypes] = useState<Set<string>>(() => 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({
</button>
</span>
))}
<label className="ml-auto flex items-center gap-1 text-xs text-foreground cursor-pointer">
<input
type="checkbox"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
className="rounded"
/>
Autoscroll
</label>
</div>
)}
@@ -671,6 +682,15 @@ export function RawPacketFeedView({
</button>
</span>
))}
<label className="ml-auto flex items-center gap-1 text-xs text-foreground cursor-pointer">
<input
type="checkbox"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
className="rounded"
/>
Autoscroll
</label>
</div>
</div>
@@ -680,6 +700,7 @@ export function RawPacketFeedView({
packets={filteredPackets}
channels={channels}
onPacketClick={setSelectedPacket}
autoScroll={autoScroll}
/>
</div>
+12 -3
View File
@@ -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<HTMLDivElement>(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 (
@@ -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] });
+30
View File
@@ -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(
<RawPacketList packets={[createPacket({ id: 1 })]} autoScroll />
);
const list = container.querySelector('.overflow-y-auto') as HTMLElement;
rerender(
<RawPacketList packets={[createPacket({ id: 1 }), createPacket({ id: 2 })]} autoScroll />
);
expect(list.scrollTop).toBe(500);
// Pause autoscroll, simulate the user scrolling up, then receive a packet.
list.scrollTop = 0;
rerender(
<RawPacketList
packets={[createPacket({ id: 1 }), createPacket({ id: 2 }), createPacket({ id: 3 })]}
autoScroll={false}
/>
);
expect(list.scrollTop).toBe(0);
} finally {
delete (HTMLElement.prototype as { scrollHeight?: number }).scrollHeight;
}
});
});