Add EventDetailHeader component and fix EventViewer overflow

- Create standardized EventDetailHeader with title, timestamp, actions, and copyText props
- Fix EventViewer firstSlot overflow/scrolling issue
- Update GrpcResponsePane, WebsocketResponsePane, HttpResponseTimeline, and EventStreamViewer to use EventDetailHeader
- Fix Timeline title consistency when toggling Raw/Formatted views
This commit is contained in:
Gregory Schier
2026-01-11 08:51:36 -08:00
parent ff084a224a
commit 28e9657ea5
6 changed files with 127 additions and 133 deletions

View File

@@ -9,14 +9,12 @@ import {
useGrpcEvents, useGrpcEvents,
} from '../hooks/usePinnedGrpcConnection'; } from '../hooks/usePinnedGrpcConnection';
import { useStateWithDeps } from '../hooks/useStateWithDeps'; import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { copyToClipboard } from '../lib/copy';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { Editor } from './core/Editor/LazyEditor'; import { Editor } from './core/Editor/LazyEditor';
import { EventViewer } from './core/EventViewer'; import { EventDetailHeader, EventViewer } from './core/EventViewer';
import { EventViewerRow } from './core/EventViewerRow'; import { EventViewerRow } from './core/EventViewerRow';
import { HotkeyList } from './core/HotkeyList'; import { HotkeyList } from './core/HotkeyList';
import { Icon, type IconProps } from './core/Icon'; import { Icon, type IconProps } from './core/Icon';
import { IconButton } from './core/IconButton';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow'; import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
import { LoadingIcon } from './core/LoadingIcon'; import { LoadingIcon } from './core/LoadingIcon';
import { HStack, VStack } from './core/Stacks'; import { HStack, VStack } from './core/Stacks';
@@ -157,19 +155,11 @@ function GrpcEventDetail({
setShowingLarge: (v: boolean) => void; setShowingLarge: (v: boolean) => void;
}) { }) {
if (event.eventType === 'client_message' || event.eventType === 'server_message') { if (event.eventType === 'client_message' || event.eventType === 'server_message') {
const title = `Message ${event.eventType === 'client_message' ? 'Sent' : 'Received'}`;
return ( return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]"> <div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<div className="mb-2 select-text cursor-text grid grid-cols-[minmax(0,1fr)_auto] items-center"> <EventDetailHeader title={title} timestamp={event.createdAt} copyText={event.content} />
<div className="font-semibold">
Message {event.eventType === 'client_message' ? 'Sent' : 'Received'}
</div>
<IconButton
title="Copy message"
icon="copy"
size="xs"
onClick={() => copyToClipboard(event.content)}
/>
</div>
{!showLarge && event.content.length > 1000 * 1000 ? ( {!showLarge && event.content.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
@@ -207,14 +197,12 @@ function GrpcEventDetail({
// Error or connection_end - show metadata/trailers // Error or connection_end - show metadata/trailers
return ( return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]"> <div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<div> <EventDetailHeader title={event.content} timestamp={event.createdAt} />
<div className="select-text cursor-text font-semibold">{event.content}</div> {event.error && (
{event.error && ( <div className="select-text cursor-text text-sm font-mono py-1 text-warning">
<div className="select-text cursor-text text-sm font-mono py-1 text-warning"> {event.error}
{event.error} </div>
</div> )}
)}
</div>
<div className="py-2 h-full"> <div className="py-2 h-full">
{Object.keys(event.metadata).length === 0 ? ( {Object.keys(event.metadata).length === 0 ? (
<EmptyStateText> <EmptyStateText>

View File

@@ -3,18 +3,15 @@ import type {
HttpResponseEvent, HttpResponseEvent,
HttpResponseEventData, HttpResponseEventData,
} from '@yaakapp-internal/models'; } from '@yaakapp-internal/models';
import { format } from 'date-fns';
import { type ReactNode, useState } from 'react'; import { type ReactNode, useState } from 'react';
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents'; import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
import { Button } from './core/Button';
import { Editor } from './core/Editor/LazyEditor'; import { Editor } from './core/Editor/LazyEditor';
import { EventViewer } from './core/EventViewer'; import { EventDetailHeader, EventViewer, type EventDetailAction } from './core/EventViewer';
import { EventViewerRow } from './core/EventViewerRow'; import { EventViewerRow } from './core/EventViewerRow';
import { HttpMethodTagRaw } from './core/HttpMethodTag'; import { HttpMethodTagRaw } from './core/HttpMethodTag';
import { HttpStatusTagRaw } from './core/HttpStatusTag'; import { HttpStatusTagRaw } from './core/HttpStatusTag';
import { Icon, type IconProps } from './core/Icon'; import { Icon, type IconProps } from './core/Icon';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow'; import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
import { HStack } from './core/Stacks';
interface Props { interface Props {
response: HttpResponse; response: HttpResponse;
@@ -73,20 +70,30 @@ function EventDetails({
setShowRaw: (v: boolean) => void; setShowRaw: (v: boolean) => void;
}) { }) {
const { label } = getEventDisplay(event.event); const { label } = getEventDisplay(event.event);
const timestamp = format(new Date(`${event.createdAt}Z`), 'HH:mm:ss.SSS');
const e = event.event; const e = event.event;
const actions: EventDetailAction[] = [
{
key: 'toggle-raw',
label: showRaw ? 'Formatted' : 'Text',
onClick: () => setShowRaw(!showRaw),
},
];
// Determine the title based on event type
const title =
e.type === 'header_up'
? 'Header Sent'
: e.type === 'header_down'
? 'Header Received'
: label;
// Raw view - show plaintext representation // Raw view - show plaintext representation
if (showRaw) { if (showRaw) {
const rawText = formatEventRaw(event.event); const rawText = formatEventRaw(event.event);
return ( return (
<div className="flex flex-col gap-2 h-full"> <div className="flex flex-col gap-2 h-full">
<DetailHeader <EventDetailHeader title={title} timestamp={event.createdAt} actions={actions} />
title={label}
timestamp={timestamp}
showRaw={showRaw}
setShowRaw={setShowRaw}
/>
<Editor language="text" defaultValue={rawText} readOnly stateKey={null} /> <Editor language="text" defaultValue={rawText} readOnly stateKey={null} />
</div> </div>
); );
@@ -96,12 +103,7 @@ function EventDetails({
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"> <div className="flex flex-col gap-2 h-full">
<DetailHeader <EventDetailHeader title={title} timestamp={event.createdAt} actions={actions} />
title={e.type === 'header_down' ? 'Header Received' : 'Header Sent'}
timestamp={timestamp}
showRaw={showRaw}
setShowRaw={setShowRaw}
/>
<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>
@@ -114,12 +116,7 @@ function EventDetails({
if (e.type === 'send_url') { if (e.type === 'send_url') {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<DetailHeader <EventDetailHeader title="Request" timestamp={event.createdAt} actions={actions} />
title="Request"
timestamp={timestamp}
showRaw={showRaw}
setShowRaw={setShowRaw}
/>
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="Method"> <KeyValueRow label="Method">
<HttpMethodTagRaw forceColor method={e.method} /> <HttpMethodTagRaw forceColor method={e.method} />
@@ -134,12 +131,7 @@ function EventDetails({
if (e.type === 'receive_url') { if (e.type === 'receive_url') {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<DetailHeader <EventDetailHeader title="Response" timestamp={event.createdAt} actions={actions} />
title="Response"
timestamp={timestamp}
showRaw={showRaw}
setShowRaw={setShowRaw}
/>
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow> <KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
<KeyValueRow label="Status"> <KeyValueRow label="Status">
@@ -154,12 +146,7 @@ function EventDetails({
if (e.type === 'redirect') { if (e.type === 'redirect') {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<DetailHeader <EventDetailHeader title="Redirect" timestamp={event.createdAt} actions={actions} />
title="Redirect"
timestamp={timestamp}
showRaw={showRaw}
setShowRaw={setShowRaw}
/>
<KeyValueRows> <KeyValueRows>
<KeyValueRow label="Status"> <KeyValueRow label="Status">
<HttpStatusTagRaw status={e.status} /> <HttpStatusTagRaw status={e.status} />
@@ -177,12 +164,7 @@ function EventDetails({
if (e.type === 'setting') { if (e.type === 'setting') {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<DetailHeader <EventDetailHeader title="Apply Setting" timestamp={event.createdAt} actions={actions} />
title="Apply Setting"
timestamp={timestamp}
showRaw={showRaw}
setShowRaw={setShowRaw}
/>
<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>
@@ -196,11 +178,10 @@ function EventDetails({
const direction = e.type === 'chunk_sent' ? 'Sent' : 'Received'; const direction = e.type === 'chunk_sent' ? 'Sent' : 'Received';
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<DetailHeader <EventDetailHeader
title={`Data ${direction}`} title={`Data ${direction}`}
timestamp={timestamp} timestamp={event.createdAt}
showRaw={showRaw} actions={actions}
setShowRaw={setShowRaw}
/> />
<div className="font-mono text-editor">{formatBytes(e.bytes)}</div> <div className="font-mono text-editor">{formatBytes(e.bytes)}</div>
</div> </div>
@@ -211,57 +192,33 @@ function EventDetails({
const { summary } = getEventDisplay(event.event); const { summary } = getEventDisplay(event.event);
return ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<DetailHeader title={label} timestamp={timestamp} showRaw={showRaw} setShowRaw={setShowRaw} /> <EventDetailHeader title={label} timestamp={event.createdAt} actions={actions} />
<div className="font-mono text-editor">{summary}</div> <div className="font-mono text-editor">{summary}</div>
</div> </div>
); );
} }
function DetailHeader({
title,
timestamp,
showRaw,
setShowRaw,
}: {
title: string;
timestamp: string;
showRaw: boolean;
setShowRaw: (v: boolean) => void;
}) {
return (
<div className="flex items-center justify-between gap-2">
<HStack space={2} className="items-center">
<h3 className="font-semibold select-auto cursor-auto">{title}</h3>
<Button variant="border" size="xs" onClick={() => setShowRaw(!showRaw)}>
{showRaw ? 'Formatted' : 'Raw'}
</Button>
</HStack>
<span className="text-text-subtlest font-mono text-editor">{timestamp}</span>
</div>
);
}
/** Format event as raw plaintext for debugging */ /** Format event as raw plaintext for debugging */
function formatEventRaw(event: HttpResponseEventData): string { function formatEventRaw(event: HttpResponseEventData): string {
switch (event.type) { switch (event.type) {
case 'send_url': case 'send_url':
return `> ${event.method} ${event.path}`; return `${event.method} ${event.path}`;
case 'receive_url': case 'receive_url':
return `< ${event.version} ${event.status}`; return `${event.version} ${event.status}`;
case 'header_up': case 'header_up':
return `> ${event.name}: ${event.value}`; return `${event.name}: ${event.value}`;
case 'header_down': case 'header_down':
return `< ${event.name}: ${event.value}`; return `${event.name}: ${event.value}`;
case 'redirect': case 'redirect':
return `< ${event.status} Redirect: ${event.url}`; return `${event.status} Redirect: ${event.url}`;
case 'setting': case 'setting':
return `[setting] ${event.name} = ${event.value}`; return `${event.name} = ${event.value}`;
case 'info': case 'info':
return `[info] ${event.message}`; return `${event.message}`;
case 'chunk_sent': case 'chunk_sent':
return `> [${formatBytes(event.bytes)} sent]`; return `[${formatBytes(event.bytes)} sent]`;
case 'chunk_received': case 'chunk_received':
return `< [${formatBytes(event.bytes)} received]`; return `[${formatBytes(event.bytes)} received]`;
default: default:
return '[unknown event]'; return '[unknown event]';
} }

View File

@@ -11,14 +11,12 @@ import {
} from '../hooks/usePinnedWebsocketConnection'; } from '../hooks/usePinnedWebsocketConnection';
import { useStateWithDeps } from '../hooks/useStateWithDeps'; import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { languageFromContentType } from '../lib/contentType'; import { languageFromContentType } from '../lib/contentType';
import { copyToClipboard } from '../lib/copy';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { Editor } from './core/Editor/LazyEditor'; import { Editor } from './core/Editor/LazyEditor';
import { EventViewer } from './core/EventViewer'; import { EventDetailHeader, EventViewer, type EventDetailAction } from './core/EventViewer';
import { EventViewerRow } from './core/EventViewerRow'; import { EventViewerRow } from './core/EventViewerRow';
import { HotkeyList } from './core/HotkeyList'; import { HotkeyList } from './core/HotkeyList';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { LoadingIcon } from './core/LoadingIcon'; import { LoadingIcon } from './core/LoadingIcon';
import { HStack, VStack } from './core/Stacks'; import { HStack, VStack } from './core/Stacks';
import { WebsocketStatusTag } from './core/WebsocketStatusTag'; import { WebsocketStatusTag } from './core/WebsocketStatusTag';
@@ -173,24 +171,25 @@ function WebsocketEventDetail({
? 'Connection Open' ? 'Connection Open'
: `Message ${event.isServer ? 'Received' : 'Sent'}`; : `Message ${event.isServer ? 'Received' : 'Sent'}`;
const actions: EventDetailAction[] =
message !== ''
? [
{
key: 'toggle-hexdump',
label: hexDump ? 'Show Message' : 'Show Hexdump',
onClick: () => setHexDump(!hexDump),
},
]
: [];
return ( return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]"> <div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<div className="h-xs mb-2 grid grid-cols-[minmax(0,1fr)_auto] items-center"> <EventDetailHeader
<div className="font-semibold">{title}</div> title={title}
{message !== '' && ( timestamp={event.createdAt}
<HStack space={1}> actions={actions}
<Button variant="border" size="xs" onClick={() => setHexDump(!hexDump)}> copyText={formattedMessage || undefined}
{hexDump ? 'Show Message' : 'Show Hexdump'} />
</Button>
<IconButton
title="Copy message"
icon="copy"
size="xs"
onClick={() => copyToClipboard(formattedMessage ?? '')}
/>
</HStack>
)}
</div>
{!showLarge && event.message.length > 1000 * 1000 ? ( {!showLarge && event.message.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

@@ -63,7 +63,7 @@ export function AutoScroller<T>({
}, [autoScroll, data.length]); }, [autoScroll, data.length]);
return ( return (
<div className="h-full w-full relative grid grid-rows-[minmax(0,auto)_minmax(0,1fr)]"> <div className="h-full w-full relative grid grid-rows-[auto_minmax(0,1fr)]">
{!autoScroll && ( {!autoScroll && (
<div className="absolute bottom-0 right-0 m-2"> <div className="absolute bottom-0 right-0 m-2">
<IconButton <IconButton

View File

@@ -1,11 +1,15 @@
import type { Virtualizer } from '@tanstack/react-virtual'; import type { Virtualizer } from '@tanstack/react-virtual';
import { format } from 'date-fns';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { useEventViewerKeyboard } from '../../hooks/useEventViewerKeyboard'; import { useEventViewerKeyboard } from '../../hooks/useEventViewerKeyboard';
import { CopyIconButton } from '../CopyIconButton';
import { AutoScroller } from './AutoScroller'; import { AutoScroller } from './AutoScroller';
import { Banner } from './Banner'; import { Banner } from './Banner';
import { Button } from './Button';
import { Separator } from './Separator'; import { Separator } from './Separator';
import { SplitLayout } from './SplitLayout'; import { SplitLayout } from './SplitLayout';
import { HStack } from './Stacks';
interface EventViewerProps<T> { interface EventViewerProps<T> {
/** Array of events to display */ /** Array of events to display */
@@ -137,8 +141,8 @@ export function EventViewer<T>({
defaultRatio={defaultRatio} defaultRatio={defaultRatio}
minHeightPx={10} minHeightPx={10}
firstSlot={({ style }) => ( firstSlot={({ style }) => (
<div style={style} className="w-full grid grid-rows-[auto_minmax(0,1fr)]"> <div style={style} className="w-full h-full grid grid-rows-[auto_minmax(0,1fr)]">
{header} {header ?? <span aria-hidden />}
<AutoScroller <AutoScroller
data={events} data={events}
focusable={enableKeyboardNav} focusable={enableKeyboardNav}
@@ -166,7 +170,7 @@ export function EventViewer<T>({
secondSlot={ secondSlot={
activeEvent != null && renderDetail activeEvent != null && renderDetail
? ({ style }) => ( ? ({ style }) => (
<div style={style} className="grid grid-rows-[auto_minmax(0,1fr)]"> <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>
@@ -181,3 +185,55 @@ export function EventViewer<T>({
</div> </div>
); );
} }
export interface EventDetailAction {
/** Unique key for React */
key: string;
/** Button label */
label: string;
/** Optional icon */
icon?: ReactNode;
/** Click handler */
onClick: () => void;
}
interface EventDetailHeaderProps {
/** Title/label for the event */
title: string;
/** Timestamp string (ISO format) - will be formatted as HH:mm:ss.SSS */
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;
}
/** Standardized header for event detail panes */
export function EventDetailHeader({
title,
timestamp,
actions,
copyText,
}: 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">
{actions?.map((action) => (
<Button key={action.key} variant="border" size="xs" onClick={action.onClick}>
{action.icon}
{action.label}
</Button>
))}
{copyText != null && (
<CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" />
)}
{formattedTime && (
<span className="text-text-subtlest font-mono text-editor">{formattedTime}</span>
)}
</HStack>
</div>
);
}

View File

@@ -8,7 +8,7 @@ import { isJSON } from '../../lib/contentType';
import { Button } from '../core/Button'; import { Button } from '../core/Button';
import type { EditorProps } from '../core/Editor/Editor'; import type { EditorProps } from '../core/Editor/Editor';
import { Editor } from '../core/Editor/LazyEditor'; import { Editor } from '../core/Editor/LazyEditor';
import { EventViewer } from '../core/EventViewer'; import { EventDetailHeader, EventViewer } from '../core/EventViewer';
import { EventViewerRow } from '../core/EventViewerRow'; import { EventViewerRow } from '../core/EventViewerRow';
import { Icon } from '../core/Icon'; import { Icon } from '../core/Icon';
import { InlineCode } from '../core/InlineCode'; import { InlineCode } from '../core/InlineCode';
@@ -54,10 +54,9 @@ function ActualEventStreamViewer({ response }: Props) {
timestamp={new Date().toISOString().slice(0, -1)} // SSE events don't have timestamps timestamp={new Date().toISOString().slice(0, -1)} // SSE events don't have timestamps
/> />
)} )}
renderDetail={({ event, index }) => ( renderDetail={({ event }) => (
<EventDetail <EventDetail
event={event} event={event}
index={index}
showLarge={showLarge} showLarge={showLarge}
showingLarge={showingLarge} showingLarge={showingLarge}
setShowLarge={setShowLarge} setShowLarge={setShowLarge}
@@ -70,14 +69,12 @@ 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;
@@ -90,10 +87,7 @@ function EventDetail({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<HStack space={1.5} className="mb-2 font-semibold"> <EventDetailHeader title="Message Received" />
<EventLabels className="text-sm" event={event} index={index} />
Message Received
</HStack>
{!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