mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-10 11:13:48 +02:00
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:
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user