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' ;
55import { Button } from '@/components/ui/button' ;
6+ import { editGroupBadgeStyles } from '@/lib/eventStyles' ;
67import { EventItem } from './EventItem' ;
78import 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 ( / f i l e : \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 / ^ r e a d i n g f i l e : / 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 / ^ ( c r e a t i n g | e d i t i n g | d e l e t i n g ) f i l e : / 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 */
25145export 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 } />
0 commit comments