Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 169 additions & 12 deletions web-ui/src/components/execution/EventStream.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<EventGroup, { type: 'read_group' }>;
workspacePath: string;
}) {
const [expanded, setExpanded] = useState(false);

return (
<div>
<button
className="flex w-full items-center gap-2 rounded px-1 py-1 text-left text-xs text-muted-foreground hover:bg-muted/40"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-label={`${expanded ? 'Collapse' : 'Expand'} ${group.count} file read events`}
>
<ArrowRight01Icon
className={`h-3 w-3 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`}
/>
<span className="font-mono text-[11px]">
{new Date(group.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<span>
Read {group.count} file{group.count !== 1 ? 's' : ''}
{group.count <= 4 && `: ${group.files.join(', ')}`}
</span>
</button>
{expanded && (
<div className="ml-4 space-y-0.5 border-l pl-3">
{group.events.map((e, i) => (
<EventItem key={`${e.timestamp}-${i}`} event={e} workspacePath={workspacePath} />
))}
</div>
)}
</div>
);
}

function EditGroupRow({ group }: { group: Extract<EventGroup, { type: 'edit_group' }> }) {
return (
<div className="flex items-baseline gap-2 py-1.5">
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
{new Date(group.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<span className={`rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase leading-none ${editGroupBadgeStyles}`}>
edit
</span>
<p className="text-sm">
Modified {group.count} files: {group.files.join(', ')}
</p>
</div>
);
}

// ── 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<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(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(() => {
Expand Down Expand Up @@ -71,9 +196,20 @@ export function EventStream({ events, workspacePath, onBlockerAnswered }: EventS

return (
<div className="relative flex-1 overflow-hidden rounded-lg border">
{/* Header: stream label + view toggle */}
<div className="flex items-center justify-between border-b px-4 py-2">
<span className="text-xs font-medium text-muted-foreground">Event stream</span>
<button
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => setShowAll((v) => !v)}
>
{showAll ? 'Smart view' : 'Show all events'}
</button>
</div>

<div
ref={containerRef}
className="h-full overflow-y-auto p-4"
className="h-[calc(100%-37px)] overflow-y-auto p-4"
onScroll={handleScroll}
role="log"
aria-live="polite"
Expand All @@ -83,7 +219,8 @@ export function EventStream({ events, workspacePath, onBlockerAnswered }: EventS
<p className="py-8 text-center text-sm text-muted-foreground">
Waiting for events...
</p>
) : (
) : showAll ? (
// Raw log — every event unmodified
<div className="space-y-0.5">
{displayEvents.map((event, i) => (
<EventItem
Expand All @@ -94,6 +231,26 @@ export function EventStream({ events, workspacePath, onBlockerAnswered }: EventS
/>
))}
</div>
) : (
// Smart view — grouped
<div className="space-y-0.5">
{groups.map((group, i) => {
if (group.type === 'event') {
return (
<EventItem
key={`${group.event.timestamp}-${i}`}
event={group.event}
workspacePath={workspacePath}
onBlockerAnswered={onBlockerAnswered}
/>
);
}
if (group.type === 'read_group') {
return <ReadGroupRow key={`rg-${i}`} group={group} workspacePath={workspacePath} />;
}
return <EditGroupRow key={`eg-${i}`} group={group} />;
})}
</div>
)}
{/* Scroll sentinel */}
<div ref={bottomRef} />
Expand Down
3 changes: 3 additions & 0 deletions web-ui/src/lib/eventStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ export const agentStateBadgeStyles: Record<UIAgentState, string> = {
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';

Comment on lines +85 to +87
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use gray badge classes for the new edit-group style constant.

Line 86 introduces blue badge colors, which conflicts with the repo’s gray-only UI color scheme requirement for web-ui/src/**/*.{ts,tsx}.

🎨 Suggested change
-export const editGroupBadgeStyles = 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
+export const editGroupBadgeStyles = 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300';

As per coding guidelines, web-ui/src/**/*.{ts,tsx}: Web UI must use Shadcn/UI (Nova preset) with gray color scheme and Hugeicons (@hugeicons/react); never use lucide-react.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/** 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';
/** Badge style for the edit-group summary row in the EventStream. */
export const editGroupBadgeStyles = 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/src/lib/eventStyles.ts` around lines 85 - 87, The new export
editGroupBadgeStyles uses blue color classes which violates the repo's gray-only
UI requirement; update the editGroupBadgeStyles constant to use the Shadcn/UI
Nova preset gray badge classes (replace blue-* and dark:blue-* classes with the
equivalent gray-* and dark:gray-* classes) so it conforms to
web-ui/src/**/*.{ts,tsx} gray color scheme and Hugeicons conventions; locate the
editGroupBadgeStyles export to make this change.

/** Human-readable labels for each agent state. */
export const agentStateLabels: Record<UIAgentState, string> = {
CONNECTING: 'Connecting',
Expand Down
Loading