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

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

View File

@@ -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<T> {
/** Array of events to display */
@@ -27,7 +29,7 @@ interface EventViewerProps<T> {
}) => 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<T>({
onActiveIndexChange,
}: EventViewerProps<T>) {
const [activeIndex, setActiveIndexInternal] = useState<number | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(false);
// Wrap setActiveIndex to notify parent
const setActiveIndex = useCallback(
@@ -107,6 +110,8 @@ export function EventViewer<T>({
virtualizer: virtualizerRef.current,
isContainerFocused,
enabled: enableKeyboardNav,
closePanel: () => setIsPanelOpen(false),
openPanel: () => setIsPanelOpen(true),
});
// 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(
(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 <div className="p-3 text-text-subtlest italic">{loadingMessage}</div>;
}
@@ -168,14 +182,14 @@ export function EventViewer<T>({
</div>
)}
secondSlot={
activeEvent != null && renderDetail
activeEvent != null && renderDetail && isPanelOpen
? ({ style }) => (
<div style={style} className="grid grid-rows-[auto_minmax(0,1fr)] bg-surface">
<div className="pb-3 px-2">
<Separator />
</div>
<div className="mx-2 overflow-y-auto">
{renderDetail({ event: activeEvent, index: activeIndex ?? 0 })}
{renderDetail({ event: activeEvent, index: activeIndex ?? 0, onClose: handleClose })}
</div>
</div>
)
@@ -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 (
<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">
{actions?.map((action) => (
<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" />
)}
{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>
</div>
);

View File

@@ -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}
<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>
</div>
);