diff --git a/src-web/components/HttpResponseTimeline.tsx b/src-web/components/HttpResponseTimeline.tsx index b0122700..98c10b5d 100644 --- a/src-web/components/HttpResponseTimeline.tsx +++ b/src-web/components/HttpResponseTimeline.tsx @@ -47,8 +47,8 @@ function Inner({ response }: Props) { /> ); }} - renderDetail={({ event }) => ( - + renderDetail={({ event, onClose }) => ( + )} /> ); @@ -64,10 +64,12 @@ function EventDetails({ event, showRaw, setShowRaw, + onClose, }: { event: HttpResponseEvent; showRaw: boolean; setShowRaw: (v: boolean) => void; + onClose: () => void; }) { const { label } = getEventDisplay(event.event); const e = event.event; @@ -81,72 +83,76 @@ function EventDetails({ ]; // Determine the title based on event type - const title = - e.type === 'header_up' - ? 'Header Sent' - : e.type === 'header_down' - ? 'Header Received' - : label; + const title = (() => { + switch (e.type) { + case 'header_up': + return 'Header Sent'; + case 'header_down': + return 'Header Received'; + case 'send_url': + return 'Request'; + case 'receive_url': + return 'Response'; + case 'redirect': + return 'Redirect'; + case 'setting': + return 'Apply Setting'; + case 'chunk_sent': + return 'Data Sent'; + case 'chunk_received': + return 'Data Received'; + case 'dns_resolved': + return e.overridden ? 'DNS Override' : 'DNS Resolution'; + default: + return label; + } + })(); - // Raw view - show plaintext representation - if (showRaw) { - const rawText = formatEventRaw(event.event); - return ( -
- - -
- ); - } + // Render content based on view mode and event type + const renderContent = () => { + // Raw view - show plaintext representation + if (showRaw) { + const rawText = formatEventRaw(event.event); + return ; + } - // Headers - show name and value with Editor for JSON - if (e.type === 'header_up' || e.type === 'header_down') { - return ( -
- + // Headers - show name and value + if (e.type === 'header_up' || e.type === 'header_down') { + return ( {e.name} {e.value} -
- ); - } + ); + } - // Request URL - show method and path separately - if (e.type === 'send_url') { - return ( -
- + // Request URL - show method and path separately + if (e.type === 'send_url') { + return ( {e.path} -
- ); - } + ); + } - // Response status - show version and status separately - if (e.type === 'receive_url') { - return ( -
- + // Response status - show version and status separately + if (e.type === 'receive_url') { + return ( {e.version} -
- ); - } + ); + } - // Redirect - show status, URL, and behavior - if (e.type === 'redirect') { - return ( -
- + // Redirect - show status, URL, and behavior + if (e.type === 'redirect') { + return ( @@ -156,47 +162,27 @@ function EventDetails({ {e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'} -
- ); - } + ); + } - // Settings - show as key/value - if (e.type === 'setting') { - return ( -
- + // Settings - show as key/value + if (e.type === 'setting') { + return ( {e.name} {e.value} -
- ); - } + ); + } - // Chunks - show formatted bytes - if (e.type === 'chunk_sent' || e.type === 'chunk_received') { - const direction = e.type === 'chunk_sent' ? 'Sent' : 'Received'; - return ( -
- -
{formatBytes(e.bytes)}
-
- ); - } + // Chunks - show formatted bytes + if (e.type === 'chunk_sent' || e.type === 'chunk_received') { + return
{formatBytes(e.bytes)}
; + } - // DNS Resolution - show hostname, addresses, and timing - if (e.type === 'dns_resolved') { - return ( -
- + // DNS Resolution - show hostname, addresses, and timing + if (e.type === 'dns_resolved') { + return ( {e.hostname} {e.addresses.join(', ')} @@ -207,22 +193,19 @@ function EventDetails({ `${String(e.duration)}ms` )} + {e.overridden && Workspace Override} - {e.overridden && ( - - Workspace Override - - )} -
- ); - } + ); + } - // Default - use summary - const { summary } = getEventDisplay(event.event); + // Default - use summary + const { summary } = getEventDisplay(event.event); + return
{summary}
; + }; return ( -
- -
{summary}
+
+ + {renderContent()}
); } @@ -284,7 +267,7 @@ function getEventDisplay(event: HttpResponseEventData): EventDisplay { case 'redirect': return { icon: 'arrow_big_right_dash', - color: 'warning', + color: 'success', label: 'Redirect', summary: `Redirecting ${event.status} ${event.url}${event.behavior === 'drop_body' ? ' (drop body)' : ''}`, }; diff --git a/src-web/components/core/AutoScroller.tsx b/src-web/components/core/AutoScroller.tsx index f0b0b30b..574d32ff 100644 --- a/src-web/components/core/AutoScroller.tsx +++ b/src-web/components/core/AutoScroller.tsx @@ -80,7 +80,7 @@ export function AutoScroller({ {header ?? }
diff --git a/src-web/components/core/EventViewer.tsx b/src-web/components/core/EventViewer.tsx index 69c605bc..41cac8e0 100644 --- a/src-web/components/core/EventViewer.tsx +++ b/src-web/components/core/EventViewer.tsx @@ -10,6 +10,8 @@ import { Button } from './Button'; import { Separator } from './Separator'; import { SplitLayout } from './SplitLayout'; import { HStack } from './Stacks'; +import { IconButton } from './IconButton'; +import classNames from 'classnames'; interface EventViewerProps { /** Array of events to display */ @@ -27,7 +29,7 @@ interface EventViewerProps { }) => ReactNode; /** Render the detail pane for the selected event */ - renderDetail?: (props: { event: T; index: number }) => ReactNode; + renderDetail?: (props: { event: T; index: number; onClose: () => void }) => ReactNode; /** Optional header above the event list (e.g., connection status) */ header?: ReactNode; @@ -73,6 +75,7 @@ export function EventViewer({ onActiveIndexChange, }: EventViewerProps) { const [activeIndex, setActiveIndexInternal] = useState(null); + const [isPanelOpen, setIsPanelOpen] = useState(false); // Wrap setActiveIndex to notify parent const setActiveIndex = useCallback( @@ -107,6 +110,8 @@ export function EventViewer({ virtualizer: virtualizerRef.current, isContainerFocused, enabled: enableKeyboardNav, + closePanel: () => setIsPanelOpen(false), + openPanel: () => setIsPanelOpen(true), }); // Handle virtualizer ready callback @@ -117,14 +122,23 @@ export function EventViewer({ [], ); - // Toggle selection on click + // Handle row click - select and open panel, scroll into view const handleRowClick = useCallback( (index: number) => { - setActiveIndex((prev) => (prev === index ? null : index)); + setActiveIndex(index); + setIsPanelOpen(true); + // Scroll to ensure selected item is visible after panel opens + requestAnimationFrame(() => { + virtualizerRef.current?.scrollToIndex(index, { align: 'auto' }); + }); }, [setActiveIndex], ); + const handleClose = useCallback(() => { + setIsPanelOpen(false); + }, []); + if (isLoading) { return
{loadingMessage}
; } @@ -168,14 +182,14 @@ export function EventViewer({
)} secondSlot={ - activeEvent != null && renderDetail + activeEvent != null && renderDetail && isPanelOpen ? ({ style }) => (
- {renderDetail({ event: activeEvent, index: activeIndex ?? 0 })} + {renderDetail({ event: activeEvent, index: activeIndex ?? 0, onClose: handleClose })}
) @@ -198,28 +212,30 @@ export interface EventDetailAction { } interface EventDetailHeaderProps { - /** Title/label for the event */ title: string; - /** Timestamp string (ISO format) - will be formatted as HH:mm:ss.SSS */ + prefix?: ReactNode; timestamp?: string; - /** Optional action buttons to show before timestamp */ actions?: EventDetailAction[]; - /** Text to copy when copy button is clicked - renders a copy icon button after actions */ copyText?: string; + onClose?: () => void; } -/** Standardized header for event detail panes */ export function EventDetailHeader({ title, + prefix, timestamp, actions, copyText, + onClose, }: EventDetailHeaderProps) { const formattedTime = timestamp ? format(new Date(`${timestamp}Z`), 'HH:mm:ss.SSS') : null; return (
-

{title}

+ + {prefix} +

{title}

+
{actions?.map((action) => (
); diff --git a/src-web/components/core/EventViewerRow.tsx b/src-web/components/core/EventViewerRow.tsx index dea4ad2a..d1a40cc3 100644 --- a/src-web/components/core/EventViewerRow.tsx +++ b/src-web/components/core/EventViewerRow.tsx @@ -7,7 +7,7 @@ interface EventViewerRowProps { onClick: () => void; icon: ReactNode; content: ReactNode; - timestamp: string; + timestamp?: string; } export function EventViewerRow({ @@ -25,13 +25,13 @@ export function EventViewerRow({ className={classNames( 'w-full grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 items-center text-left', 'px-1.5 h-xs font-mono text-editor cursor-default group focus:outline-none focus:text-text rounded', - isActive && '!bg-surface-active !text-text', + isActive && 'bg-surface-active !text-text', 'text-text-subtle hover:text', )} > {icon}
{content}
-
{format(`${timestamp}Z`, 'HH:mm:ss.SSS')}
+ {timestamp &&
{format(`${timestamp}Z`, 'HH:mm:ss.SSS')}
}
); diff --git a/src-web/components/responseViewers/EventStreamViewer.tsx b/src-web/components/responseViewers/EventStreamViewer.tsx index 4f99227e..4058f6a5 100644 --- a/src-web/components/responseViewers/EventStreamViewer.tsx +++ b/src-web/components/responseViewers/EventStreamViewer.tsx @@ -51,12 +51,13 @@ function ActualEventStreamViewer({ response }: Props) { {event.data.slice(0, 1000)} } - timestamp={new Date().toISOString().slice(0, -1)} // SSE events don't have timestamps + /> )} - renderDetail={({ event }) => ( + renderDetail={({ event, index }) => ( void; @@ -87,7 +90,7 @@ function EventDetail({ return (
- + } /> {!showLarge && event.data.length > 1000 * 1000 ? ( Message previews larger than 1MB are hidden diff --git a/src-web/hooks/useEventViewerKeyboard.ts b/src-web/hooks/useEventViewerKeyboard.ts index 063117cd..3e30f496 100644 --- a/src-web/hooks/useEventViewerKeyboard.ts +++ b/src-web/hooks/useEventViewerKeyboard.ts @@ -9,6 +9,8 @@ interface UseEventViewerKeyboardProps { virtualizer?: Virtualizer | null; isContainerFocused: () => boolean; enabled?: boolean; + closePanel?: () => void; + openPanel?: () => void; } export function useEventViewerKeyboard({ @@ -18,6 +20,8 @@ export function useEventViewerKeyboard({ virtualizer, isContainerFocused, enabled = true, + closePanel, + openPanel, }: UseEventViewerKeyboardProps) { const selectPrev = useCallback(() => { if (totalCount === 0) return; @@ -62,9 +66,20 @@ export function useEventViewerKeyboard({ (e) => { if (!enabled || !isContainerFocused()) return; e.preventDefault(); - setActiveIndex(null); + closePanel?.(); }, undefined, - [enabled, isContainerFocused, setActiveIndex], + [enabled, isContainerFocused, closePanel], + ); + + useKey( + (e) => e.key === 'Enter' || e.key === ' ', + (e) => { + if (!enabled || !isContainerFocused() || activeIndex == null) return; + e.preventDefault(); + openPanel?.(); + }, + undefined, + [enabled, isContainerFocused, activeIndex, openPanel], ); }