@@ -4,149 +4,109 @@ import { useState } from 'react'
44import { Icon } from '@iconify/react'
55import {
66 type AgentActivity ,
7- summarizeActivities ,
87 activityIcon ,
98 activityColor ,
9+ formatDuration ,
10+ summarizeActivities ,
1011} from '@/lib/agent-activity'
1112
1213interface Props {
1314 activities : AgentActivity [ ]
1415 isRunning : boolean
16+ elapsedMs ?: number
1517}
1618
17- export function AgentActivityFeed ( { activities, isRunning } : Props ) {
19+ /**
20+ * Codex-inspired activity feed — collapsible summary bar + expandable timeline.
21+ * Exec commands render as discrete cards with command text, output, and exit code.
22+ */
23+ export function AgentActivityFeed ( { activities, isRunning, elapsedMs } : Props ) {
1824 const [ expanded , setExpanded ] = useState ( false )
1925 const summary = summarizeActivities ( activities )
2026
21- if ( activities . length === 0 ) return null
27+ if ( activities . length === 0 && ! isRunning ) return null
2228
2329 const lastActivity = activities [ activities . length - 1 ]
2430
2531 return (
26- < div className = "rounded-xl border border-[var(--border)] bg-[var(--bg-subtle)] overflow-hidden my-1.5 " >
27- { /* Compact summary bar */ }
32+ < div className = "mx-2 mb-2 " >
33+ { /* Summary bar — always visible */ }
2834 < button
29- onClick = { ( ) => setExpanded ( v => ! v ) }
30- className = "w-full flex items-center gap-2 px-3 py-2 text-left cursor-pointer hover:bg-[color-mix(in_srgb,var(--text-primary)_3%,transparent)] transition-colors"
35+ onClick = { ( ) => setExpanded ( ! expanded ) }
36+ className = "w-full flex items-center gap-2 px-3 py-1.5 rounded-lg text-[11px] transition-colors cursor-pointer
37+ bg-[color-mix(in_srgb,var(--bg-elevated)_80%,transparent)]
38+ border border-[var(--border)]
39+ hover:bg-[color-mix(in_srgb,var(--text-primary)_4%,transparent)]"
3140 >
32- { isRunning && (
41+ { /* Status indicator */ }
42+ { isRunning ? (
3343 < span className = "relative flex h-2 w-2 shrink-0" >
3444 < span className = "animate-ping absolute inline-flex h-full w-full rounded-full bg-[var(--brand)] opacity-50" />
3545 < span className = "relative inline-flex rounded-full h-2 w-2 bg-[var(--brand)]" />
3646 </ span >
37- ) }
38- { ! isRunning && (
39- < Icon icon = "lucide:check-circle" width = { 12 } className = "text-[var(--color-additions)] shrink-0" />
47+ ) : (
48+ < Icon icon = "lucide:check-circle-2" width = { 12 } className = "text-[color-mix(in_srgb,#34d399_80%,var(--brand))] shrink-0" />
4049 ) }
4150
4251 { /* Current action or summary */ }
43- < span className = "text-[11px] text-[ var(--text-secondary)] flex-1 truncate " >
44- { isRunning
52+ < span className = "text-[var(--text-secondary)] truncate flex-1 text-left " >
53+ { isRunning && lastActivity
4554 ? lastActivity . label
46- : `${ summary . totalActions } actions ` }
55+ : `${ summary . totalActions } action ${ summary . totalActions !== 1 ? 's' : '' } completed ` }
4756 </ span >
4857
49- { /* File change badges */ }
50- < div className = "flex items-center gap-1.5" >
58+ { /* Badges */ }
59+ < span className = "flex items-center gap-1.5 shrink-0 " >
5160 { summary . filesEdited . length > 0 && (
52- < span className = "inline-flex items-center gap-0.5 text-[9px] font-medium text-amber-400 " >
53- < Icon icon = "lucide:file-pen-line" width = { 10 } />
61+ < span className = "inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-[color-mix(in_srgb,#fbbf24_10%,transparent)] text-[color-mix(in_srgb,#fbbf24_80%,var(--brand))] " >
62+ < Icon icon = "lucide:file-pen-line" width = { 9 } />
5463 { summary . filesEdited . length }
5564 </ span >
5665 ) }
5766 { summary . filesCreated . length > 0 && (
58- < span className = "inline-flex items-center gap-0.5 text-[9px] font-medium text-green-400 " >
59- < Icon icon = "lucide:file-plus" width = { 10 } />
67+ < span className = "inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-[color-mix(in_srgb,#34d399_10%,transparent)] text-[color-mix(in_srgb,#34d399_80%,var(--brand))] " >
68+ < Icon icon = "lucide:file-plus" width = { 9 } />
6069 { summary . filesCreated . length }
6170 </ span >
6271 ) }
63- { summary . filesRead . length > 0 && (
64- < span className = "inline-flex items-center gap-0.5 text-[9px] font-medium text-blue-400" >
65- < Icon icon = "lucide:file-search" width = { 10 } />
66- { summary . filesRead . length }
67- </ span >
68- ) }
6972 { summary . commandsRun > 0 && (
70- < span className = "inline-flex items-center gap-0.5 text-[9px] font-medium text-cyan-400 " >
71- < Icon icon = "lucide:terminal" width = { 10 } />
73+ < span className = "inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-[color-mix(in_srgb,#22d3ee_10%,transparent)] text-[color-mix(in_srgb,#22d3ee_80%,var(--brand))] " >
74+ < Icon icon = "lucide:terminal" width = { 9 } />
7275 { summary . commandsRun }
7376 </ span >
7477 ) }
75- </ div >
78+ { /* Elapsed time */ }
79+ { elapsedMs != null && elapsedMs > 0 && (
80+ < span className = "text-[10px] text-[var(--text-disabled)] tabular-nums" >
81+ { formatDuration ( elapsedMs ) }
82+ </ span >
83+ ) }
84+ </ span >
7685
77- < Icon
78- icon = { expanded ? 'lucide:chevron-up' : 'lucide:chevron-down' }
79- width = { 11 }
80- className = "text-[var(--text-disabled)] shrink-0"
81- />
86+ < Icon icon = { expanded ? 'lucide:chevron-up' : 'lucide:chevron-down' } width = { 12 } className = "text-[var(--text-disabled)] shrink-0" />
8287 </ button >
8388
8489 { /* Expanded timeline */ }
8590 { expanded && (
86- < div className = "border-t border-[var(--border)] px-3 py-2 max-h-[240px] overflow-y-auto" >
87- < div className = "relative flex flex-col gap-0" >
88- { /* Timeline line */ }
89- < div className = "absolute left-[5px] top-2 bottom-2 w-px bg-[color-mix(in_srgb,var(--brand)_15%,var(--border))]" />
90-
91- { activities . map ( ( act , i ) => {
92- const isLast = i === activities . length - 1
93- return (
94- < div
95- key = { act . id }
96- className = "flex items-start gap-2.5 py-1 relative"
97- >
98- { /* Timeline dot */ }
99- < div className = "relative z-[1] shrink-0 mt-0.5" >
100- { isLast && isRunning ? (
101- < span className = "relative flex h-[9px] w-[9px]" >
102- < span className = "animate-ping absolute inline-flex h-full w-full rounded-full bg-[var(--brand)] opacity-50" />
103- < span className = "relative inline-flex rounded-full h-[9px] w-[9px] bg-[var(--brand)]" />
104- </ span >
105- ) : (
106- < span className = { `block w-[9px] h-[9px] rounded-full border-2 ${
107- act . status === 'error'
108- ? 'border-[var(--color-deletions)] bg-[var(--color-deletions)]'
109- : 'border-[color-mix(in_srgb,var(--brand)_40%,var(--border))] bg-[var(--bg-subtle)]'
110- } `} />
111- ) }
112- </ div >
113-
114- { /* Activity content */ }
115- < div className = "flex items-center gap-1.5 min-w-0 flex-1" >
116- < Icon
117- icon = { activityIcon ( act . type ) }
118- width = { 11 }
119- className = { `shrink-0 ${ activityColor ( act . type ) } ` }
120- />
121- < span className = { `text-[10px] truncate ${
122- isLast && isRunning ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-disabled)]'
123- } `} >
124- { act . label }
125- </ span >
126- </ div >
127-
128- { /* File chip */ }
129- { act . file && (
130- < span className = "text-[8px] font-mono text-[var(--text-disabled)] truncate max-w-[100px]" >
131- { act . file . split ( '/' ) . pop ( ) }
132- </ span >
133- ) }
134- </ div >
135- )
136- } ) }
91+ < div className = "mt-1 rounded-lg border border-[var(--border)] bg-[color-mix(in_srgb,var(--bg-elevated)_90%,transparent)] overflow-hidden" >
92+ < div className = "max-h-64 overflow-y-auto" >
93+ { activities . map ( ( activity , idx ) => (
94+ < ActivityItem key = { activity . id } activity = { activity } isLast = { idx === activities . length - 1 && isRunning } />
95+ ) ) }
13796 </ div >
13897
13998 { /* Changed files summary */ }
140- { ! isRunning && summary . filesEdited . length > 0 && (
141- < div className = "mt-2 pt-2 border-t border-[var(--border)]" >
142- < p className = "text-[9px] uppercase tracking-wider text-[var(--text-disabled)] font-medium mb-1" > Changed Files</ p >
99+ { ! isRunning && ( summary . filesEdited . length > 0 || summary . filesCreated . length > 0 ) && (
100+ < div className = "border-t border-[var(--border)] px-3 py-2 " >
101+ < div className = "text-[10px] font-medium text-[var(--text-disabled)] uppercase tracking-wider mb-1" > Changed Files</ div >
143102 < div className = "flex flex-wrap gap-1" >
144103 { [ ...summary . filesEdited , ...summary . filesCreated ] . map ( f => (
145- < span
146- key = { f }
147- className = "inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-mono bg-[color-mix(in_srgb,var(--brand)_6%,transparent)] border border-[color-mix(in_srgb,var(--brand)_20%,var(--border))] text-[var(--text-secondary)]"
148- >
149- < Icon icon = { summary . filesCreated . includes ( f ) ? 'lucide:file-plus' : 'lucide:file-pen-line' } width = { 9 } />
104+ < span key = { f } className = "inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-[color-mix(in_srgb,var(--text-primary)_5%,transparent)] text-[10px] text-[var(--text-secondary)] font-mono" >
105+ { summary . filesCreated . includes ( f ) ? (
106+ < span className = "w-1.5 h-1.5 rounded-full bg-[color-mix(in_srgb,#34d399_80%,var(--brand))]" />
107+ ) : (
108+ < span className = "w-1.5 h-1.5 rounded-full bg-[color-mix(in_srgb,#fbbf24_80%,var(--brand))]" />
109+ ) }
150110 { f . split ( '/' ) . pop ( ) }
151111 </ span >
152112 ) ) }
@@ -158,3 +118,76 @@ export function AgentActivityFeed({ activities, isRunning }: Props) {
158118 </ div >
159119 )
160120}
121+
122+ /** Single activity item — exec commands get a card treatment */
123+ function ActivityItem ( { activity, isLast } : { activity : AgentActivity ; isLast : boolean } ) {
124+ const [ outputExpanded , setOutputExpanded ] = useState ( false )
125+ const isCommand = activity . type === 'command'
126+ const color = activityColor ( activity . type )
127+
128+ return (
129+ < div className = "flex gap-2 px-3 py-1.5 relative" >
130+ { /* Timeline dot */ }
131+ < div className = "flex flex-col items-center shrink-0 pt-0.5" >
132+ < div
133+ className = "w-3 h-3 rounded-full flex items-center justify-center"
134+ style = { { backgroundColor : `color-mix(in srgb, ${ color } 15%, transparent)` } }
135+ >
136+ { isLast && activity . status === 'running' ? (
137+ < span className = "w-1.5 h-1.5 rounded-full animate-pulse" style = { { backgroundColor : color } } />
138+ ) : (
139+ < Icon icon = { activityIcon ( activity . type ) } width = { 8 } style = { { color } } />
140+ ) }
141+ </ div >
142+ </ div >
143+
144+ { /* Content */ }
145+ < div className = "flex-1 min-w-0" >
146+ { isCommand ? (
147+ /* Exec command card */
148+ < div className = "rounded-md border border-[var(--border)] bg-[color-mix(in_srgb,var(--bg)_95%,transparent)] overflow-hidden" >
149+ { /* Command header */ }
150+ < div className = "flex items-center gap-2 px-2 py-1 bg-[color-mix(in_srgb,var(--text-primary)_3%,transparent)]" >
151+ < Icon icon = "lucide:terminal" width = { 10 } style = { { color } } />
152+ < code className = "text-[10px] text-[var(--text-primary)] font-mono truncate flex-1" > { activity . label } </ code >
153+ < span className = "flex items-center gap-1.5 shrink-0" >
154+ { activity . durationMs != null && (
155+ < span className = "text-[9px] text-[var(--text-disabled)] tabular-nums" > { formatDuration ( activity . durationMs ) } </ span >
156+ ) }
157+ { activity . status === 'running' ? (
158+ < span className = "text-[9px] text-[var(--brand)] animate-pulse" > running</ span >
159+ ) : activity . exitCode === 0 || activity . exitCode === undefined ? (
160+ < Icon icon = "lucide:check" width = { 10 } className = "text-[color-mix(in_srgb,#34d399_80%,var(--brand))]" />
161+ ) : (
162+ < span className = "text-[9px] text-red-400 font-mono" > exit { activity . exitCode } </ span >
163+ ) }
164+ </ span >
165+ </ div >
166+ { /* Output preview */ }
167+ { activity . output && (
168+ < button
169+ onClick = { ( ) => setOutputExpanded ( ! outputExpanded ) }
170+ className = "w-full text-left px-2 py-1 border-t border-[var(--border)] cursor-pointer hover:bg-[color-mix(in_srgb,var(--text-primary)_2%,transparent)]"
171+ >
172+ < pre className = { `text-[9px] text-[var(--text-tertiary)] font-mono whitespace-pre-wrap ${ outputExpanded ? '' : 'line-clamp-3' } ` } >
173+ { activity . output }
174+ </ pre >
175+ { ! outputExpanded && activity . output . split ( '\n' ) . length > 3 && (
176+ < span className = "text-[9px] text-[var(--text-disabled)]" > click to expand…</ span >
177+ ) }
178+ </ button >
179+ ) }
180+ </ div >
181+ ) : (
182+ /* Standard activity row */
183+ < div className = "flex items-center gap-1.5" >
184+ < span className = "text-[11px] text-[var(--text-secondary)] truncate" > { activity . label } </ span >
185+ { activity . durationMs != null && (
186+ < span className = "text-[9px] text-[var(--text-disabled)] tabular-nums shrink-0" > { formatDuration ( activity . durationMs ) } </ span >
187+ ) }
188+ </ div >
189+ ) }
190+ </ div >
191+ </ div >
192+ )
193+ }
0 commit comments