Skip to content

Commit e1296e7

Browse files
authored
feat(web-ui): compress and summarize verbose execution event stream (#475)
## Summary - groupEvents() (memoized) transforms raw event arrays into EventGroup[] for the smart view - Consecutive file-read events → collapsible ReadGroupRow (expanded=false default, keyboard accessible) - Consecutive file edit/create/delete events → EditGroupRow single summary line - Smart view default; "Show all events" toggle reveals every raw event unmodified - editGroupBadgeStyles extracted to eventStyles.ts for consistency ## Validation - Review feedback: 2 items fixed (useMemo for groupEvents, editGroupBadgeStyles extracted to eventStyles.ts with correct dark:bg-blue-900/30) - Demo: All 5 acceptance criteria verified via screenshots (smart view, expand, raw log) - CI: All checks green Closes #475
1 parent 814f550 commit e1296e7

2 files changed

Lines changed: 172 additions & 12 deletions

File tree

web-ui/src/components/execution/EventStream.tsx

Lines changed: 169 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
'use client';
22

3-
import { useRef, useEffect, useState, useCallback } from 'react';
4-
import { ArrowDown01Icon } from '@hugeicons/react';
3+
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
4+
import { ArrowDown01Icon, ArrowRight01Icon } from '@hugeicons/react';
55
import { Button } from '@/components/ui/button';
6+
import { editGroupBadgeStyles } from '@/lib/eventStyles';
67
import { EventItem } from './EventItem';
78
import type { ExecutionEvent } from '@/hooks/useTaskStream';
89

@@ -12,25 +13,149 @@ interface EventStreamProps {
1213
onBlockerAnswered?: () => void;
1314
}
1415

16+
// ── Event grouping ──────────────────────────────────────────────────────
17+
18+
type EventGroup =
19+
| { type: 'event'; event: ExecutionEvent }
20+
| { type: 'read_group'; count: number; files: string[]; timestamp: string; events: ExecutionEvent[] }
21+
| { type: 'edit_group'; count: number; files: string[]; timestamp: string };
22+
23+
function extractFilename(msg: string): string {
24+
const m = msg.match(/file:\s*(.+)/i);
25+
return m ? (m[1].split('/').pop() ?? m[1]) : msg;
26+
}
27+
28+
function isReadEvent(e: ExecutionEvent): boolean {
29+
if (e.event_type !== 'progress') return false;
30+
return /^reading file:/i.test((e as { message?: string }).message ?? '');
31+
}
32+
33+
function isEditEvent(e: ExecutionEvent): boolean {
34+
if (e.event_type !== 'progress') return false;
35+
return /^(creating|editing|deleting) file:/i.test((e as { message?: string }).message ?? '');
36+
}
37+
38+
function groupEvents(events: ExecutionEvent[]): EventGroup[] {
39+
const groups: EventGroup[] = [];
40+
let readBuf: ExecutionEvent[] = [];
41+
let editBuf: ExecutionEvent[] = [];
42+
43+
function flushRead() {
44+
if (!readBuf.length) return;
45+
const files = readBuf.map((e) => extractFilename((e as { message?: string }).message ?? ''));
46+
groups.push({ type: 'read_group', count: readBuf.length, files, timestamp: readBuf[0].timestamp, events: [...readBuf] });
47+
readBuf = [];
48+
}
49+
50+
function flushEdit() {
51+
if (!editBuf.length) return;
52+
if (editBuf.length === 1) {
53+
groups.push({ type: 'event', event: editBuf[0] });
54+
} else {
55+
const files = editBuf.map((e) => extractFilename((e as { message?: string }).message ?? ''));
56+
groups.push({ type: 'edit_group', count: editBuf.length, files, timestamp: editBuf[0].timestamp });
57+
}
58+
editBuf = [];
59+
}
60+
61+
for (const event of events) {
62+
if (isReadEvent(event)) {
63+
flushEdit();
64+
readBuf.push(event);
65+
} else if (isEditEvent(event)) {
66+
flushRead();
67+
editBuf.push(event);
68+
} else {
69+
flushRead();
70+
flushEdit();
71+
groups.push({ type: 'event', event });
72+
}
73+
}
74+
flushRead();
75+
flushEdit();
76+
return groups;
77+
}
78+
79+
// ── Group row components ────────────────────────────────────────────────
80+
81+
function ReadGroupRow({
82+
group,
83+
workspacePath,
84+
}: {
85+
group: Extract<EventGroup, { type: 'read_group' }>;
86+
workspacePath: string;
87+
}) {
88+
const [expanded, setExpanded] = useState(false);
89+
90+
return (
91+
<div>
92+
<button
93+
className="flex w-full items-center gap-2 rounded px-1 py-1 text-left text-xs text-muted-foreground hover:bg-muted/40"
94+
onClick={() => setExpanded((v) => !v)}
95+
aria-expanded={expanded}
96+
aria-label={`${expanded ? 'Collapse' : 'Expand'} ${group.count} file read events`}
97+
>
98+
<ArrowRight01Icon
99+
className={`h-3 w-3 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`}
100+
/>
101+
<span className="font-mono text-[11px]">
102+
{new Date(group.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
103+
</span>
104+
<span>
105+
Read {group.count} file{group.count !== 1 ? 's' : ''}
106+
{group.count <= 4 && `: ${group.files.join(', ')}`}
107+
</span>
108+
</button>
109+
{expanded && (
110+
<div className="ml-4 space-y-0.5 border-l pl-3">
111+
{group.events.map((e, i) => (
112+
<EventItem key={`${e.timestamp}-${i}`} event={e} workspacePath={workspacePath} />
113+
))}
114+
</div>
115+
)}
116+
</div>
117+
);
118+
}
119+
120+
function EditGroupRow({ group }: { group: Extract<EventGroup, { type: 'edit_group' }> }) {
121+
return (
122+
<div className="flex items-baseline gap-2 py-1.5">
123+
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
124+
{new Date(group.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
125+
</span>
126+
<span className={`rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase leading-none ${editGroupBadgeStyles}`}>
127+
edit
128+
</span>
129+
<p className="text-sm">
130+
Modified {group.count} files: {group.files.join(', ')}
131+
</p>
132+
</div>
133+
);
134+
}
135+
136+
// ── Main EventStream ────────────────────────────────────────────────────
137+
15138
/**
16-
* Scrollable event stream with auto-scroll behavior.
17-
*
18-
* - Default: auto-scrolls to bottom on new events
19-
* - When user scrolls up: pauses auto-scroll, shows "New events" button
20-
* - Click button or scroll to bottom: re-enables auto-scroll
139+
* Scrollable event stream with auto-scroll and smart grouping.
21140
*
22-
* Uses a single scrollable div (no Radix ScrollArea) so that
23-
* onScroll and scrollIntoView work on the same container.
141+
* Smart view (default): groups consecutive file reads into collapsible rows
142+
* and summarises consecutive file edits into a single line.
143+
* Raw log: toggle via "Show all events" button to see every event unmodified.
24144
*/
25145
export function EventStream({ events, workspacePath, onBlockerAnswered }: EventStreamProps) {
26146
const bottomRef = useRef<HTMLDivElement>(null);
27147
const containerRef = useRef<HTMLDivElement>(null);
28148
const [autoScroll, setAutoScroll] = useState(true);
29149
const [hasNewEvents, setHasNewEvents] = useState(false);
150+
const [showAll, setShowAll] = useState(false);
30151
const prevEventCountRef = useRef(events.length);
31152

32153
// Filter out heartbeat events for display
33-
const displayEvents = events.filter((e) => e.event_type !== 'heartbeat');
154+
const displayEvents = useMemo(
155+
() => events.filter((e) => e.event_type !== 'heartbeat'),
156+
[events]
157+
);
158+
const groups = useMemo(() => groupEvents(displayEvents), [displayEvents]);
34159

35160
// Detect new events while scrolled up
36161
useEffect(() => {
@@ -71,9 +196,20 @@ export function EventStream({ events, workspacePath, onBlockerAnswered }: EventS
71196

72197
return (
73198
<div className="relative flex-1 overflow-hidden rounded-lg border">
199+
{/* Header: stream label + view toggle */}
200+
<div className="flex items-center justify-between border-b px-4 py-2">
201+
<span className="text-xs font-medium text-muted-foreground">Event stream</span>
202+
<button
203+
className="text-xs text-muted-foreground hover:text-foreground"
204+
onClick={() => setShowAll((v) => !v)}
205+
>
206+
{showAll ? 'Smart view' : 'Show all events'}
207+
</button>
208+
</div>
209+
74210
<div
75211
ref={containerRef}
76-
className="h-full overflow-y-auto p-4"
212+
className="h-[calc(100%-37px)] overflow-y-auto p-4"
77213
onScroll={handleScroll}
78214
role="log"
79215
aria-live="polite"
@@ -83,7 +219,8 @@ export function EventStream({ events, workspacePath, onBlockerAnswered }: EventS
83219
<p className="py-8 text-center text-sm text-muted-foreground">
84220
Waiting for events...
85221
</p>
86-
) : (
222+
) : showAll ? (
223+
// Raw log — every event unmodified
87224
<div className="space-y-0.5">
88225
{displayEvents.map((event, i) => (
89226
<EventItem
@@ -94,6 +231,26 @@ export function EventStream({ events, workspacePath, onBlockerAnswered }: EventS
94231
/>
95232
))}
96233
</div>
234+
) : (
235+
// Smart view — grouped
236+
<div className="space-y-0.5">
237+
{groups.map((group, i) => {
238+
if (group.type === 'event') {
239+
return (
240+
<EventItem
241+
key={`${group.event.timestamp}-${i}`}
242+
event={group.event}
243+
workspacePath={workspacePath}
244+
onBlockerAnswered={onBlockerAnswered}
245+
/>
246+
);
247+
}
248+
if (group.type === 'read_group') {
249+
return <ReadGroupRow key={`rg-${i}`} group={group} workspacePath={workspacePath} />;
250+
}
251+
return <EditGroupRow key={`eg-${i}`} group={group} />;
252+
})}
253+
</div>
97254
)}
98255
{/* Scroll sentinel */}
99256
<div ref={bottomRef} />

web-ui/src/lib/eventStyles.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ export const agentStateBadgeStyles: Record<UIAgentState, string> = {
8282
DISCONNECTED: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
8383
};
8484

85+
/** Badge style for the edit-group summary row in the EventStream. */
86+
export const editGroupBadgeStyles = 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
87+
8588
/** Human-readable labels for each agent state. */
8689
export const agentStateLabels: Record<UIAgentState, string> = {
8790
CONNECTING: 'Connecting',

0 commit comments

Comments
 (0)