diff --git a/web-ui/src/components/execution/EventStream.tsx b/web-ui/src/components/execution/EventStream.tsx index a47b1052..6c44ae70 100644 --- a/web-ui/src/components/execution/EventStream.tsx +++ b/web-ui/src/components/execution/EventStream.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useRef, useEffect, useState, useCallback } from 'react'; -import { ArrowDown01Icon } from '@hugeicons/react'; +import { useRef, useEffect, useState, useCallback, useMemo } from 'react'; +import { ArrowDown01Icon, ArrowRight01Icon } from '@hugeicons/react'; import { Button } from '@/components/ui/button'; +import { editGroupBadgeStyles } from '@/lib/eventStyles'; import { EventItem } from './EventItem'; import type { ExecutionEvent } from '@/hooks/useTaskStream'; @@ -12,25 +13,149 @@ interface EventStreamProps { onBlockerAnswered?: () => void; } +// ── Event grouping ────────────────────────────────────────────────────── + +type EventGroup = + | { type: 'event'; event: ExecutionEvent } + | { type: 'read_group'; count: number; files: string[]; timestamp: string; events: ExecutionEvent[] } + | { type: 'edit_group'; count: number; files: string[]; timestamp: string }; + +function extractFilename(msg: string): string { + const m = msg.match(/file:\s*(.+)/i); + return m ? (m[1].split('/').pop() ?? m[1]) : msg; +} + +function isReadEvent(e: ExecutionEvent): boolean { + if (e.event_type !== 'progress') return false; + return /^reading file:/i.test((e as { message?: string }).message ?? ''); +} + +function isEditEvent(e: ExecutionEvent): boolean { + if (e.event_type !== 'progress') return false; + return /^(creating|editing|deleting) file:/i.test((e as { message?: string }).message ?? ''); +} + +function groupEvents(events: ExecutionEvent[]): EventGroup[] { + const groups: EventGroup[] = []; + let readBuf: ExecutionEvent[] = []; + let editBuf: ExecutionEvent[] = []; + + function flushRead() { + if (!readBuf.length) return; + const files = readBuf.map((e) => extractFilename((e as { message?: string }).message ?? '')); + groups.push({ type: 'read_group', count: readBuf.length, files, timestamp: readBuf[0].timestamp, events: [...readBuf] }); + readBuf = []; + } + + function flushEdit() { + if (!editBuf.length) return; + if (editBuf.length === 1) { + groups.push({ type: 'event', event: editBuf[0] }); + } else { + const files = editBuf.map((e) => extractFilename((e as { message?: string }).message ?? '')); + groups.push({ type: 'edit_group', count: editBuf.length, files, timestamp: editBuf[0].timestamp }); + } + editBuf = []; + } + + for (const event of events) { + if (isReadEvent(event)) { + flushEdit(); + readBuf.push(event); + } else if (isEditEvent(event)) { + flushRead(); + editBuf.push(event); + } else { + flushRead(); + flushEdit(); + groups.push({ type: 'event', event }); + } + } + flushRead(); + flushEdit(); + return groups; +} + +// ── Group row components ──────────────────────────────────────────────── + +function ReadGroupRow({ + group, + workspacePath, +}: { + group: Extract; + workspacePath: string; +}) { + const [expanded, setExpanded] = useState(false); + + return ( +
+ + {expanded && ( +
+ {group.events.map((e, i) => ( + + ))} +
+ )} +
+ ); +} + +function EditGroupRow({ group }: { group: Extract }) { + return ( +
+ + {new Date(group.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })} + + + edit + +

+ Modified {group.count} files: {group.files.join(', ')} +

+
+ ); +} + +// ── Main EventStream ──────────────────────────────────────────────────── + /** - * Scrollable event stream with auto-scroll behavior. - * - * - Default: auto-scrolls to bottom on new events - * - When user scrolls up: pauses auto-scroll, shows "New events" button - * - Click button or scroll to bottom: re-enables auto-scroll + * Scrollable event stream with auto-scroll and smart grouping. * - * Uses a single scrollable div (no Radix ScrollArea) so that - * onScroll and scrollIntoView work on the same container. + * Smart view (default): groups consecutive file reads into collapsible rows + * and summarises consecutive file edits into a single line. + * Raw log: toggle via "Show all events" button to see every event unmodified. */ export function EventStream({ events, workspacePath, onBlockerAnswered }: EventStreamProps) { const bottomRef = useRef(null); const containerRef = useRef(null); const [autoScroll, setAutoScroll] = useState(true); const [hasNewEvents, setHasNewEvents] = useState(false); + const [showAll, setShowAll] = useState(false); const prevEventCountRef = useRef(events.length); // Filter out heartbeat events for display - const displayEvents = events.filter((e) => e.event_type !== 'heartbeat'); + const displayEvents = useMemo( + () => events.filter((e) => e.event_type !== 'heartbeat'), + [events] + ); + const groups = useMemo(() => groupEvents(displayEvents), [displayEvents]); // Detect new events while scrolled up useEffect(() => { @@ -71,9 +196,20 @@ export function EventStream({ events, workspacePath, onBlockerAnswered }: EventS return (
+ {/* Header: stream label + view toggle */} +
+ Event stream + +
+
Waiting for events...

- ) : ( + ) : showAll ? ( + // Raw log — every event unmodified
{displayEvents.map((event, i) => ( ))}
+ ) : ( + // Smart view — grouped +
+ {groups.map((group, i) => { + if (group.type === 'event') { + return ( + + ); + } + if (group.type === 'read_group') { + return ; + } + return ; + })} +
)} {/* Scroll sentinel */}
diff --git a/web-ui/src/lib/eventStyles.ts b/web-ui/src/lib/eventStyles.ts index 4dc147ff..5b1a4644 100644 --- a/web-ui/src/lib/eventStyles.ts +++ b/web-ui/src/lib/eventStyles.ts @@ -82,6 +82,9 @@ export const agentStateBadgeStyles: Record = { DISCONNECTED: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300', }; +/** Badge style for the edit-group summary row in the EventStream. */ +export const editGroupBadgeStyles = 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'; + /** Human-readable labels for each agent state. */ export const agentStateLabels: Record = { CONNECTING: 'Connecting',