diff --git a/frontend/src/components/ConversationPane.tsx b/frontend/src/components/ConversationPane.tsx index 942f399..7e12c70 100644 --- a/frontend/src/components/ConversationPane.tsx +++ b/frontend/src/components/ConversationPane.tsx @@ -242,7 +242,12 @@ export function ConversationPane({ if (activeConversation.type === 'visualizer') { return ( }> - + ); } diff --git a/frontend/src/components/RawPacketDetailModal.tsx b/frontend/src/components/RawPacketDetailModal.tsx index f3f452c..eafa376 100644 --- a/frontend/src/components/RawPacketDetailModal.tsx +++ b/frontend/src/components/RawPacketDetailModal.tsx @@ -49,6 +49,8 @@ interface RawPacketInspectorDialogProps { description: string; notice?: ReactNode; signalOverride?: SignalOverride; + /** Portal target; see `DialogContent`. Needed when a host pane uses `requestFullscreen()`. */ + container?: HTMLElement | null; } interface RawPacketInspectionPanelProps { @@ -768,6 +770,7 @@ export function RawPacketInspectorDialog({ description, notice, signalOverride, + container, }: RawPacketInspectorDialogProps) { const [packetInput, setPacketInput] = useState(''); @@ -847,7 +850,10 @@ export function RawPacketInspectorDialog({ return ( - + {title} {description} diff --git a/frontend/src/components/VisualizerView.tsx b/frontend/src/components/VisualizerView.tsx index a548e14..8033ebb 100644 --- a/frontend/src/components/VisualizerView.tsx +++ b/frontend/src/components/VisualizerView.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Maximize2, Minimize2 } from 'lucide-react'; -import type { Contact, RawPacket, RadioConfig } from '../types'; +import type { Channel, Contact, RawPacket, RadioConfig } from '../types'; import { PacketVisualizer3D } from './PacketVisualizer3D'; import { RawPacketList } from './RawPacketList'; +import { RawPacketInspectorDialog } from './RawPacketDetailModal'; import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; import { cn } from '@/lib/utils'; import { getVisualizerSettings, saveVisualizerSettings } from '../utils/visualizerSettings'; @@ -10,12 +11,14 @@ import { getVisualizerSettings, saveVisualizerSettings } from '../utils/visualiz interface VisualizerViewProps { packets: RawPacket[]; contacts: Contact[]; + channels: Channel[]; config: RadioConfig | null; } -export function VisualizerView({ packets, contacts, config }: VisualizerViewProps) { +export function VisualizerView({ packets, contacts, channels, config }: VisualizerViewProps) { const [fullScreen, setFullScreen] = useState(() => getVisualizerSettings().hidePacketFeed); const [paneFullScreen, setPaneFullScreen] = useState(false); + const [selectedPacket, setSelectedPacket] = useState(null); const containerRef = useRef(null); // Persist packet feed visibility to localStorage @@ -71,7 +74,11 @@ export function VisualizerView({ packets, contacts, config }: VisualizerViewProp - + @@ -106,11 +113,31 @@ export function VisualizerView({ packets, contacts, config }: VisualizerViewProp Packet Feed
- +
+ + {/* While natively fullscreened, portal into the fullscreen element so the + dialog stays visible; otherwise keep the default document.body target. */} + !isOpen && setSelectedPacket(null)} + channels={channels} + container={paneFullScreen ? containerRef.current : undefined} + source={ + selectedPacket + ? { kind: 'packet', packet: selectedPacket } + : { kind: 'loading', message: 'Loading packet...' } + } + title="Packet Details" + description="Detailed byte and field breakdown for the selected raw packet." + /> ); } diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 575df20..7f282fe 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -33,13 +33,19 @@ interface DialogContentProps extends React.ComponentPropsWithoutRef< typeof DialogPrimitive.Content > { hideCloseButton?: boolean; + /** + * Portal target. Defaults to `document.body`. Pass the active fullscreen + * element when rendering inside `requestFullscreen()`, otherwise the dialog + * mounts outside that subtree and is invisible while still trapping focus. + */ + container?: HTMLElement | null; } const DialogContent = React.forwardRef< React.ElementRef, DialogContentProps ->(({ className, children, hideCloseButton = false, ...props }, ref) => ( - +>(({ className, children, hideCloseButton = false, container, ...props }, ref) => ( + ({ + PacketVisualizer3D: () =>
, +})); + +function createPacket(overrides: Partial = {}): RawPacket { + return { + id: 1, + timestamp: 1700000000, + data: '000000000000', + payload_type: 'REQ', + snr: null, + rssi: null, + decrypted: false, + decrypted_info: null, + ...overrides, + }; +} + +describe('VisualizerView packet feed', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('opens the packet analyzer when a feed packet is clicked', () => { + render( + + ); + + expect(screen.queryByText('Packet Details')).not.toBeInTheDocument(); + + // Desktop split-pane and mobile tab both render the feed, so take the first. + fireEvent.click(screen.getAllByRole('button', { name: /TF/ })[0]); + + expect(screen.getByText('Packet Details')).toBeInTheDocument(); + }); + + it('does not render the analyzer until a packet is selected', () => { + render(); + + expect(screen.queryByText('Packet Details')).not.toBeInTheDocument(); + }); +});