Improve EventViewer UX

- Separate selected item from panel open state (closing panel keeps selection)
- Scroll selected item into view when detail panel opens
- Enter/Space opens detail panel, Escape closes it
- Remove browser focus outline on scroll container
- Add prefix prop to EventDetailHeader for labels
- Make timestamp optional in EventViewerRow
- Add close button to EventDetailHeader
- Fix title truncation with min-w-0
- Consolidate HttpResponseTimeline title generation
- Add ID/event labels to SSE detail header
- Remove fake timestamp from SSE events

Closes https://feedback.yaak.app/p/feedback-on-sse-viewer-ux-in-yaak
This commit is contained in:
Gregory Schier
2026-01-13 09:04:54 -08:00
parent 306e6f358a
commit ae2f2459e9
6 changed files with 136 additions and 116 deletions

View File

@@ -47,8 +47,8 @@ function Inner({ response }: Props) {
/> />
); );
}} }}
renderDetail={({ event }) => ( renderDetail={({ event, onClose }) => (
<EventDetails event={event} showRaw={showRaw} setShowRaw={setShowRaw} /> <EventDetails event={event} showRaw={showRaw} setShowRaw={setShowRaw} onClose={onClose} />
)} )}
/> />
); );
@@ -64,10 +64,12 @@ function EventDetails({
event, event,
showRaw, showRaw,
setShowRaw, setShowRaw,
onClose,
}: { }: {
event: HttpResponseEvent; event: HttpResponseEvent;
showRaw: boolean; showRaw: boolean;
setShowRaw: (v: boolean) => void; setShowRaw: (v: boolean) => void;
onClose: () => void;
}) { }) {
const { label } = getEventDisplay(event.event); const { label } = getEventDisplay(event.event);
const e = event.event; const e = event.event;
@@ -81,72 +83,76 @@ function EventDetails({
]; ];
// Determine the title based on event type // Determine the title based on event type
const title = const title = (() => {
e.type === 'header_up' switch (e.type) {
? 'Header Sent' case 'header_up':
: e.type === 'header_down' return 'Header Sent';
? 'Header Received' case 'header_down':
: label; 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 // Render content based on view mode and event type
if (showRaw) { const renderContent = () => {
const rawText = formatEventRaw(event.event); // Raw view - show plaintext representation
return ( if (showRaw) {
<div className="flex flex-col gap-2 h-full"> const rawText = formatEventRaw(event.event);
<EventDetailHeader title={title} timestamp={event.createdAt} actions={actions} /> return <Editor language="text" defaultValue={rawText} readOnly stateKey={null} />;
<Editor language="text" defaultValue={rawText} readOnly stateKey={null} /> }
</div>
);
}
// Headers - show name and value with Editor for JSON // Headers - show name and value
if (e.type === 'header_up' || e.type === 'header_down') { if (e.type === 'header_up' || e.type === 'header_down') {
return ( return (
<div className="flex flex-col gap-2 h-full">
<EventDetailHeader title={title} timestamp={event.createdAt} actions={actions} />
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="Header">{e.name}</KeyValueRow> <KeyValueRow label="Header">{e.name}</KeyValueRow>
<KeyValueRow label="Value">{e.value}</KeyValueRow> <KeyValueRow label="Value">{e.value}</KeyValueRow>
</KeyValueRows> </KeyValueRows>
</div> );
); }
}
// Request URL - show method and path separately // Request URL - show method and path separately
if (e.type === 'send_url') { if (e.type === 'send_url') {
return ( return (
<div className="flex flex-col gap-2">
<EventDetailHeader title="Request" timestamp={event.createdAt} actions={actions} />
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="Method"> <KeyValueRow label="Method">
<HttpMethodTagRaw forceColor method={e.method} /> <HttpMethodTagRaw forceColor method={e.method} />
</KeyValueRow> </KeyValueRow>
<KeyValueRow label="Path">{e.path}</KeyValueRow> <KeyValueRow label="Path">{e.path}</KeyValueRow>
</KeyValueRows> </KeyValueRows>
</div> );
); }
}
// Response status - show version and status separately // Response status - show version and status separately
if (e.type === 'receive_url') { if (e.type === 'receive_url') {
return ( return (
<div className="flex flex-col gap-2">
<EventDetailHeader title="Response" timestamp={event.createdAt} actions={actions} />
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow> <KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
<KeyValueRow label="Status"> <KeyValueRow label="Status">
<HttpStatusTagRaw status={e.status} /> <HttpStatusTagRaw status={e.status} />
</KeyValueRow> </KeyValueRow>
</KeyValueRows> </KeyValueRows>
</div> );
); }
}
// Redirect - show status, URL, and behavior // Redirect - show status, URL, and behavior
if (e.type === 'redirect') { if (e.type === 'redirect') {
return ( return (
<div className="flex flex-col gap-2">
<EventDetailHeader title="Redirect" timestamp={event.createdAt} actions={actions} />
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="Status"> <KeyValueRow label="Status">
<HttpStatusTagRaw status={e.status} /> <HttpStatusTagRaw status={e.status} />
@@ -156,47 +162,27 @@ function EventDetails({
{e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'} {e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'}
</KeyValueRow> </KeyValueRow>
</KeyValueRows> </KeyValueRows>
</div> );
); }
}
// Settings - show as key/value // Settings - show as key/value
if (e.type === 'setting') { if (e.type === 'setting') {
return ( return (
<div className="flex flex-col gap-2">
<EventDetailHeader title="Apply Setting" timestamp={event.createdAt} actions={actions} />
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="Setting">{e.name}</KeyValueRow> <KeyValueRow label="Setting">{e.name}</KeyValueRow>
<KeyValueRow label="Value">{e.value}</KeyValueRow> <KeyValueRow label="Value">{e.value}</KeyValueRow>
</KeyValueRows> </KeyValueRows>
</div> );
); }
}
// Chunks - show formatted bytes // Chunks - show formatted bytes
if (e.type === 'chunk_sent' || e.type === 'chunk_received') { if (e.type === 'chunk_sent' || e.type === 'chunk_received') {
const direction = e.type === 'chunk_sent' ? 'Sent' : 'Received'; return <div className="font-mono text-editor">{formatBytes(e.bytes)}</div>;
return ( }
<div className="flex flex-col gap-2">
<EventDetailHeader
title={`Data ${direction}`}
timestamp={event.createdAt}
actions={actions}
/>
<div className="font-mono text-editor">{formatBytes(e.bytes)}</div>
</div>
);
}
// DNS Resolution - show hostname, addresses, and timing // DNS Resolution - show hostname, addresses, and timing
if (e.type === 'dns_resolved') { if (e.type === 'dns_resolved') {
return ( return (
<div className="flex flex-col gap-2">
<EventDetailHeader
title={e.overridden ? 'DNS Override' : 'DNS Resolution'}
timestamp={event.createdAt}
actions={actions}
/>
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="Hostname">{e.hostname}</KeyValueRow> <KeyValueRow label="Hostname">{e.hostname}</KeyValueRow>
<KeyValueRow label="Addresses">{e.addresses.join(', ')}</KeyValueRow> <KeyValueRow label="Addresses">{e.addresses.join(', ')}</KeyValueRow>
@@ -207,22 +193,19 @@ function EventDetails({
`${String(e.duration)}ms` `${String(e.duration)}ms`
)} )}
</KeyValueRow> </KeyValueRow>
{e.overridden && <KeyValueRow label="Source">Workspace Override</KeyValueRow>}
</KeyValueRows> </KeyValueRows>
{e.overridden && ( );
<KeyValueRows> }
<KeyValueRow label="Source">Workspace Override</KeyValueRow>
</KeyValueRows>
)}
</div>
);
}
// Default - use summary // Default - use summary
const { summary } = getEventDisplay(event.event); const { summary } = getEventDisplay(event.event);
return <div className="font-mono text-editor">{summary}</div>;
};
return ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-2 h-full">
<EventDetailHeader title={label} timestamp={event.createdAt} actions={actions} /> <EventDetailHeader title={title} timestamp={event.createdAt} actions={actions} onClose={onClose} />
<div className="font-mono text-editor">{summary}</div> {renderContent()}
</div> </div>
); );
} }
@@ -284,7 +267,7 @@ function getEventDisplay(event: HttpResponseEventData): EventDisplay {
case 'redirect': case 'redirect':
return { return {
icon: 'arrow_big_right_dash', icon: 'arrow_big_right_dash',
color: 'warning', color: 'success',
label: 'Redirect', label: 'Redirect',
summary: `Redirecting ${event.status} ${event.url}${event.behavior === 'drop_body' ? ' (drop body)' : ''}`, summary: `Redirecting ${event.status} ${event.url}${event.behavior === 'drop_body' ? ' (drop body)' : ''}`,
}; };

View File

@@ -80,7 +80,7 @@ export function AutoScroller<T>({
{header ?? <span aria-hidden />} {header ?? <span aria-hidden />}
<div <div
ref={containerRef} ref={containerRef}
className="h-full w-full overflow-y-auto" className="h-full w-full overflow-y-auto focus:outline-none"
onScroll={handleScroll} onScroll={handleScroll}
tabIndex={focusable ? 0 : undefined} tabIndex={focusable ? 0 : undefined}
> >

View File

@@ -10,6 +10,8 @@ import { Button } from './Button';
import { Separator } from './Separator'; import { Separator } from './Separator';
import { SplitLayout } from './SplitLayout'; import { SplitLayout } from './SplitLayout';
import { HStack } from './Stacks'; import { HStack } from './Stacks';
import { IconButton } from './IconButton';
import classNames from 'classnames';
interface EventViewerProps<T> { interface EventViewerProps<T> {
/** Array of events to display */ /** Array of events to display */
@@ -27,7 +29,7 @@ interface EventViewerProps<T> {
}) => ReactNode; }) => ReactNode;
/** Render the detail pane for the selected event */ /** 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) */ /** Optional header above the event list (e.g., connection status) */
header?: ReactNode; header?: ReactNode;
@@ -73,6 +75,7 @@ export function EventViewer<T>({
onActiveIndexChange, onActiveIndexChange,
}: EventViewerProps<T>) { }: EventViewerProps<T>) {
const [activeIndex, setActiveIndexInternal] = useState<number | null>(null); const [activeIndex, setActiveIndexInternal] = useState<number | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(false);
// Wrap setActiveIndex to notify parent // Wrap setActiveIndex to notify parent
const setActiveIndex = useCallback( const setActiveIndex = useCallback(
@@ -107,6 +110,8 @@ export function EventViewer<T>({
virtualizer: virtualizerRef.current, virtualizer: virtualizerRef.current,
isContainerFocused, isContainerFocused,
enabled: enableKeyboardNav, enabled: enableKeyboardNav,
closePanel: () => setIsPanelOpen(false),
openPanel: () => setIsPanelOpen(true),
}); });
// Handle virtualizer ready callback // Handle virtualizer ready callback
@@ -117,14 +122,23 @@ export function EventViewer<T>({
[], [],
); );
// Toggle selection on click // Handle row click - select and open panel, scroll into view
const handleRowClick = useCallback( const handleRowClick = useCallback(
(index: number) => { (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], [setActiveIndex],
); );
const handleClose = useCallback(() => {
setIsPanelOpen(false);
}, []);
if (isLoading) { if (isLoading) {
return <div className="p-3 text-text-subtlest italic">{loadingMessage}</div>; return <div className="p-3 text-text-subtlest italic">{loadingMessage}</div>;
} }
@@ -168,14 +182,14 @@ export function EventViewer<T>({
</div> </div>
)} )}
secondSlot={ secondSlot={
activeEvent != null && renderDetail activeEvent != null && renderDetail && isPanelOpen
? ({ style }) => ( ? ({ style }) => (
<div style={style} className="grid grid-rows-[auto_minmax(0,1fr)] bg-surface"> <div style={style} className="grid grid-rows-[auto_minmax(0,1fr)] bg-surface">
<div className="pb-3 px-2"> <div className="pb-3 px-2">
<Separator /> <Separator />
</div> </div>
<div className="mx-2 overflow-y-auto"> <div className="mx-2 overflow-y-auto">
{renderDetail({ event: activeEvent, index: activeIndex ?? 0 })} {renderDetail({ event: activeEvent, index: activeIndex ?? 0, onClose: handleClose })}
</div> </div>
</div> </div>
) )
@@ -198,28 +212,30 @@ export interface EventDetailAction {
} }
interface EventDetailHeaderProps { interface EventDetailHeaderProps {
/** Title/label for the event */
title: string; title: string;
/** Timestamp string (ISO format) - will be formatted as HH:mm:ss.SSS */ prefix?: ReactNode;
timestamp?: string; timestamp?: string;
/** Optional action buttons to show before timestamp */
actions?: EventDetailAction[]; actions?: EventDetailAction[];
/** Text to copy when copy button is clicked - renders a copy icon button after actions */
copyText?: string; copyText?: string;
onClose?: () => void;
} }
/** Standardized header for event detail panes */
export function EventDetailHeader({ export function EventDetailHeader({
title, title,
prefix,
timestamp, timestamp,
actions, actions,
copyText, copyText,
onClose,
}: EventDetailHeaderProps) { }: EventDetailHeaderProps) {
const formattedTime = timestamp ? format(new Date(`${timestamp}Z`), 'HH:mm:ss.SSS') : null; const formattedTime = timestamp ? format(new Date(`${timestamp}Z`), 'HH:mm:ss.SSS') : null;
return ( return (
<div className="flex items-center justify-between gap-2 mb-2 h-xs"> <div className="flex items-center justify-between gap-2 mb-2 h-xs">
<h3 className="font-semibold select-auto cursor-auto">{title}</h3> <HStack space={2} className="items-center min-w-0">
{prefix}
<h3 className="font-semibold select-auto cursor-auto truncate">{title}</h3>
</HStack>
<HStack space={2} className="items-center"> <HStack space={2} className="items-center">
{actions?.map((action) => ( {actions?.map((action) => (
<Button key={action.key} variant="border" size="xs" onClick={action.onClick}> <Button key={action.key} variant="border" size="xs" onClick={action.onClick}>
@@ -231,8 +247,11 @@ export function EventDetailHeader({
<CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" /> <CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" />
)} )}
{formattedTime && ( {formattedTime && (
<span className="text-text-subtlest font-mono text-editor">{formattedTime}</span> <span className="text-text-subtlest font-mono text-editor ml-2">{formattedTime}</span>
)} )}
<div className={classNames(copyText != null || formattedTime || (actions ?? []).length > 0 && "border-l border-l-surface-highlight ml-2 pl-3")}>
<IconButton color="custom" className="text-text-subtle -mr-3" size="xs" icon="x" title="Close event panel" onClick={onClose} />
</div>
</HStack> </HStack>
</div> </div>
); );

View File

@@ -7,7 +7,7 @@ interface EventViewerRowProps {
onClick: () => void; onClick: () => void;
icon: ReactNode; icon: ReactNode;
content: ReactNode; content: ReactNode;
timestamp: string; timestamp?: string;
} }
export function EventViewerRow({ export function EventViewerRow({
@@ -25,13 +25,13 @@ export function EventViewerRow({
className={classNames( className={classNames(
'w-full grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 items-center text-left', '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', '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', 'text-text-subtle hover:text',
)} )}
> >
{icon} {icon}
<div className="w-full truncate">{content}</div> <div className="w-full truncate">{content}</div>
<div className="opacity-50">{format(`${timestamp}Z`, 'HH:mm:ss.SSS')}</div> {timestamp && <div className="opacity-50">{format(`${timestamp}Z`, 'HH:mm:ss.SSS')}</div>}
</button> </button>
</div> </div>
); );

View File

@@ -51,12 +51,13 @@ function ActualEventStreamViewer({ response }: Props) {
<span className="truncate text-xs">{event.data.slice(0, 1000)}</span> <span className="truncate text-xs">{event.data.slice(0, 1000)}</span>
</HStack> </HStack>
} }
timestamp={new Date().toISOString().slice(0, -1)} // SSE events don't have timestamps
/> />
)} )}
renderDetail={({ event }) => ( renderDetail={({ event, index }) => (
<EventDetail <EventDetail
event={event} event={event}
index={index}
showLarge={showLarge} showLarge={showLarge}
showingLarge={showingLarge} showingLarge={showingLarge}
setShowLarge={setShowLarge} setShowLarge={setShowLarge}
@@ -69,12 +70,14 @@ function ActualEventStreamViewer({ response }: Props) {
function EventDetail({ function EventDetail({
event, event,
index,
showLarge, showLarge,
showingLarge, showingLarge,
setShowLarge, setShowLarge,
setShowingLarge, setShowingLarge,
}: { }: {
event: ServerSentEvent; event: ServerSentEvent;
index: number;
showLarge: boolean; showLarge: boolean;
showingLarge: boolean; showingLarge: boolean;
setShowLarge: (v: boolean) => void; setShowLarge: (v: boolean) => void;
@@ -87,7 +90,7 @@ function EventDetail({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<EventDetailHeader title="Message Received" /> <EventDetailHeader title="Message Received" prefix={<EventLabels event={event} index={index} />} />
{!showLarge && event.data.length > 1000 * 1000 ? ( {!showLarge && event.data.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest"> <VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden Message previews larger than 1MB are hidden

View File

@@ -9,6 +9,8 @@ interface UseEventViewerKeyboardProps {
virtualizer?: Virtualizer<HTMLDivElement, Element> | null; virtualizer?: Virtualizer<HTMLDivElement, Element> | null;
isContainerFocused: () => boolean; isContainerFocused: () => boolean;
enabled?: boolean; enabled?: boolean;
closePanel?: () => void;
openPanel?: () => void;
} }
export function useEventViewerKeyboard({ export function useEventViewerKeyboard({
@@ -18,6 +20,8 @@ export function useEventViewerKeyboard({
virtualizer, virtualizer,
isContainerFocused, isContainerFocused,
enabled = true, enabled = true,
closePanel,
openPanel,
}: UseEventViewerKeyboardProps) { }: UseEventViewerKeyboardProps) {
const selectPrev = useCallback(() => { const selectPrev = useCallback(() => {
if (totalCount === 0) return; if (totalCount === 0) return;
@@ -62,9 +66,20 @@ export function useEventViewerKeyboard({
(e) => { (e) => {
if (!enabled || !isContainerFocused()) return; if (!enabled || !isContainerFocused()) return;
e.preventDefault(); e.preventDefault();
setActiveIndex(null); closePanel?.();
}, },
undefined, 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],
); );
} }