1use std::collections::HashMap;
17
18use gpui_kit::component::{h_flex, v_flex};
19use gpui_kit::prelude::FluentBuilder as _;
20use gpui_kit::{
21 Context, EventEmitter, FontWeight, Hsla, InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled as _,
22 TestSupportExt as _, Window, div, px, relative,
23};
24
25use crate::theme::{self, color};
26use crate::widgets::MenuSelect;
27
28#[derive(Clone, Debug, Default, PartialEq)]
30pub struct Activity {
31 pub start: f64,
33 pub end: f64,
35 pub lane: SharedString,
37 pub kind: SharedString,
39 pub title: SharedString,
41 pub detail: SharedString,
43 pub source: SharedString,
45 pub assets: Vec<SharedString>,
47}
48
49#[derive(Clone, Debug, PartialEq)]
51pub struct Episode {
52 pub start: f64,
54 pub end: f64,
56 pub lane: SharedString,
58 pub kind: SharedString,
60 pub title: SharedString,
62 pub items: Vec<Activity>,
64}
65
66impl Episode {
67 pub fn duration(&self) -> f64 {
69 (self.end - self.start).max(0.0)
70 }
71
72 pub fn intensity(&self) -> f64 {
74 self.items.len() as f64 / (self.duration() / 60.0).max(1.0)
75 }
76
77 fn of(items: Vec<Activity>) -> Episode {
78 let start = items.iter().map(|a| a.start).fold(f64::INFINITY, f64::min);
79 let end = items.iter().map(|a| a.end).fold(f64::NEG_INFINITY, f64::max);
80 let mut by_kind: HashMap<SharedString, f64> = HashMap::new();
82 let mut by_title: HashMap<SharedString, (usize, f64)> = HashMap::new();
83 for a in &items {
84 *by_kind.entry(a.kind.clone()).or_default() += (a.end - a.start).max(1.0);
85 let t = by_title.entry(a.title.clone()).or_default();
86 t.0 += 1;
87 t.1 += (a.end - a.start).max(1.0);
88 }
89 let kind = by_kind.into_iter().max_by(|a, b| a.1.total_cmp(&b.1)).map(|k| k.0).unwrap_or_default();
90 let (title, (n, _)) = by_title.into_iter().max_by(|a, b| (a.1.0, a.1.1).partial_cmp(&(b.1.0, b.1.1)).unwrap_or(std::cmp::Ordering::Equal)).unwrap_or_default();
91 let title = if n > 1 { format!("{title} ×{n}").into() } else { title };
92 Episode { start, end, lane: items.first().map(|a| a.lane.clone()).unwrap_or_default(), kind, title, items }
93 }
94}
95
96pub fn fill_ends(items: &[Activity], idle: f64, now: f64) -> Vec<Activity> {
99 let mut out: Vec<Activity> = items.to_vec();
100 out.sort_by(|a, b| a.start.total_cmp(&b.start));
101 let mut next_in_lane: HashMap<SharedString, f64> = HashMap::new();
102 for a in out.iter_mut().rev() {
103 if a.end <= a.start {
104 let next = next_in_lane.get(&a.lane).copied().unwrap_or(now);
105 a.end = (a.start + idle).min(next).max(a.start + 1.0);
106 }
107 next_in_lane.insert(a.lane.clone(), a.start);
108 }
109 out
110}
111
112pub fn each(items: &[Activity]) -> Vec<Episode> {
114 let mut out: Vec<Episode> = items.iter().map(|a| Episode::of(vec![a.clone()])).collect();
115 out.sort_by(|a, b| a.start.total_cmp(&b.start));
116 out
117}
118
119pub fn episodes(items: &[Activity], target: usize, gap: f64) -> Vec<Episode> {
123 let mut lanes: Vec<SharedString> = Vec::new();
124 for a in items {
125 if !lanes.contains(&a.lane) {
126 lanes.push(a.lane.clone());
127 }
128 }
129 let mut per_lane: Vec<Vec<Vec<Activity>>> = lanes
130 .iter()
131 .map(|lane| {
132 let mut mine: Vec<Activity> = items.iter().filter(|a| &a.lane == lane).cloned().collect();
133 mine.sort_by(|a, b| a.start.total_cmp(&b.start));
134 let mut groups: Vec<Vec<Activity>> = Vec::new();
135 for a in mine {
136 match groups.last_mut() {
137 Some(g) if g.last().is_some_and(|l| l.kind == a.kind && a.start - l.end <= gap) => g.push(a),
138 _ => groups.push(vec![a]),
139 }
140 }
141 groups
142 })
143 .collect();
144 let span = |g: &[Activity]| g.last().map_or(0.0, |l| l.end) - g.first().map_or(0.0, |f| f.start);
145 loop {
146 let count: usize = per_lane.iter().map(Vec::len).sum();
147 if count <= target.max(1) {
148 break;
149 }
150 let mut best: Option<(usize, usize, f64)> = None;
152 for (l, groups) in per_lane.iter().enumerate() {
153 if groups.len() < 2 {
154 continue;
155 }
156 for (i, g) in groups.iter().enumerate() {
157 let s = span(g);
158 if best.is_none_or(|b| s < b.2) {
159 best = Some((l, i, s));
160 }
161 }
162 }
163 let Some((l, i, _)) = best else { break };
164 let groups = &mut per_lane[l];
165 let gap_before = (i > 0).then(|| groups[i][0].start - groups[i - 1].last().map_or(0.0, |a| a.end));
167 let gap_after = (i + 1 < groups.len()).then(|| groups[i + 1][0].start - groups[i].last().map_or(0.0, |a| a.end));
168 let into = match (gap_before, gap_after) {
169 (Some(b), Some(a)) => if b <= a { i - 1 } else { i + 1 },
170 (Some(_), None) => i - 1,
171 _ => i + 1,
172 };
173 let g = groups.remove(i);
174 let into = if into > i { into - 1 } else { into };
175 groups[into].extend(g);
176 groups[into].sort_by(|a, b| a.start.total_cmp(&b.start));
177 }
178 let mut out: Vec<Episode> = per_lane.into_iter().flatten().map(Episode::of).collect();
179 out.sort_by(|a, b| a.start.total_cmp(&b.start));
180 out
181}
182
183const KIND_HUES: [u32; 10] = [0x5b8def, 0xf2b134, 0x2fb67c, 0xd9479b, 0x1fb5c9, 0x8b6cf0, 0xef6f4a, 0x8a96a8, 0x9bc53d, 0xc98bdb];
186
187pub fn clock(t: f64) -> String {
189 jiff::Timestamp::from_millisecond((t * 1000.0) as i64)
190 .map(|ts| ts.to_zoned(jiff::tz::TimeZone::system()).strftime("%H:%M").to_string())
191 .unwrap_or_default()
192}
193
194pub fn duration(s: f64) -> String {
196 let s = s.max(0.0).round() as u64;
197 match s {
198 0..60 => format!("{s} s"),
199 60..3600 => format!("{} min", s / 60),
200 _ => format!("{} h {:02}", s / 3600, (s % 3600) / 60),
201 }
202}
203
204fn tick_step(span: f64) -> f64 {
206 const STEPS: [f64; 12] = [10.0, 30.0, 60.0, 120.0, 300.0, 600.0, 900.0, 1800.0, 3600.0, 7200.0, 10800.0, 21600.0];
207 STEPS.into_iter().find(|s| span / s <= 7.0).unwrap_or(43200.0)
208}
209
210#[derive(Clone, Debug, PartialEq)]
212pub enum TimelineEvent {
213 Selected(Option<usize>),
215}
216
217pub struct Timeline {
219 title: SharedString,
220 activities: Vec<Activity>,
221 shown: Vec<Episode>,
222 summary: bool,
223 target: usize,
224 gap: f64,
225 idle: f64,
226 hovered: Option<usize>,
227 selected: Option<usize>,
228 colors: HashMap<SharedString, Hsla>,
229}
230
231impl EventEmitter<TimelineEvent> for Timeline {}
232
233impl Timeline {
234 pub fn new(title: impl Into<SharedString>) -> Self {
236 Self { title: title.into(), activities: Vec::new(), shown: Vec::new(), summary: true, target: 8, gap: 120.0, idle: 300.0, hovered: None, selected: None, colors: HashMap::new() }
237 }
238
239 pub fn set_activities(&mut self, items: Vec<Activity>, now: f64, cx: &mut Context<Self>) {
242 let keep = self.selected.and_then(|i| self.shown.get(i)).map(|e| (e.lane.clone(), e.start));
243 self.activities = fill_ends(&items, self.idle, now);
244 self.rebuild();
245 self.selected = keep.and_then(|(lane, start)| self.shown.iter().position(|e| e.lane == lane && e.start == start));
246 cx.notify();
247 }
248
249 pub fn set_summary(&mut self, summary: bool, cx: &mut Context<Self>) {
251 self.summary = summary;
252 self.selected = None;
253 self.hovered = None;
254 self.rebuild();
255 cx.notify();
256 }
257
258 pub fn set_target(&mut self, target: usize, cx: &mut Context<Self>) {
260 self.target = target.max(1);
261 self.rebuild();
262 cx.notify();
263 }
264
265 pub fn shown(&self) -> &[Episode] {
267 &self.shown
268 }
269
270 pub fn activities(&self) -> &[Activity] {
272 &self.activities
273 }
274
275 pub fn selected(&self) -> Option<&Episode> {
277 self.selected.and_then(|i| self.shown.get(i))
278 }
279
280 pub fn select(&mut self, index: Option<usize>, cx: &mut Context<Self>) {
282 self.selected = index.filter(|i| *i < self.shown.len());
283 cx.emit(TimelineEvent::Selected(self.selected));
284 cx.notify();
285 }
286
287 fn rebuild(&mut self) {
288 self.shown = if self.summary { episodes(&self.activities, self.target, self.gap) } else { each(&self.activities) };
289 for a in &self.activities {
290 if !self.colors.contains_key(&a.kind) {
291 let hue = KIND_HUES[self.colors.len() % KIND_HUES.len()];
292 self.colors.insert(a.kind.clone(), theme::rgb_hsla(hue));
293 }
294 }
295 }
296
297 fn color(&self, kind: &SharedString) -> Hsla {
298 self.colors.get(kind).copied().unwrap_or_else(color::text_muted)
299 }
300
301 fn lanes(&self) -> Vec<SharedString> {
302 let mut out: Vec<SharedString> = Vec::new();
303 for a in &self.activities {
304 if !out.contains(&a.lane) {
305 out.push(a.lane.clone());
306 }
307 }
308 out
309 }
310
311 fn range(&self) -> (f64, f64) {
312 let start = self.activities.iter().map(|a| a.start).fold(f64::INFINITY, f64::min);
313 let end = self.activities.iter().map(|a| a.end).fold(f64::NEG_INFINITY, f64::max);
314 if start.is_finite() && end > start { (start, end) } else { (0.0, 1.0) }
315 }
316
317 fn context_cards(&self, e: &Episode) -> impl IntoElement {
318 let dot = self.color(&e.kind);
319 let head = h_flex()
320 .gap(px(theme::SM))
321 .items_center()
322 .child(div().size(px(10.)).rounded_full().bg(dot))
323 .child(div().text_sm().font_weight(FontWeight::SEMIBOLD).child(e.title.clone()))
324 .child(div().text_xs().text_color(color::text_muted()).child(format!("{} · {} · {} – {} · {}", e.lane, e.kind, clock(e.start), clock(e.end), duration(e.duration()))));
325 let cards = e.items.iter().enumerate().map(|(i, a)| {
326 v_flex()
327 .id(SharedString::from(format!("timeline-card-{i}")))
328 .w(px(260.))
329 .flex_none()
330 .gap(px(theme::XS))
331 .p(px(theme::SM))
332 .rounded(px(theme::RADIUS))
333 .border_1()
334 .border_color(color::border())
335 .bg(color::raised())
336 .child(h_flex().gap(px(theme::XS)).items_center().child(div().size(px(8.)).rounded_full().bg(self.color(&a.kind))).child(div().flex_1().min_w_0().truncate().text_sm().font_weight(FontWeight::SEMIBOLD).child(a.title.clone())).child(div().text_xs().text_color(color::text_muted()).child(clock(a.start))))
337 .when(!a.source.is_empty(), |d| d.child(div().text_xs().text_color(color::text_muted()).child(format!("From {}", a.source))))
338 .when(!a.detail.is_empty(), |d| d.child(div().text_xs().font_family("monospace").text_color(color::text_muted()).line_clamp(3).child(a.detail.clone())))
339 .when(!a.assets.is_empty(), |d| {
340 d.child(h_flex().flex_wrap().gap(px(theme::XS)).children(a.assets.iter().map(|s| div().px(px(theme::XS)).rounded(px(theme::RADIUS_SM)).bg(color::control()).text_xs().truncate().max_w(px(240.)).child(s.clone()))))
341 })
342 });
343 v_flex().gap(px(theme::SM)).child(head).child(h_flex().id("timeline-cards").gap(px(theme::SM)).overflow_x_scroll().pb(px(theme::XS)).children(cards))
344 }
345}
346
347const LANE_H: f32 = 64.;
349const LANE_LABEL_W: f32 = 72.;
351
352impl Render for Timeline {
353 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
354 let (t0, t1) = self.range();
355 let span = (t1 - t0).max(1.0);
356 let x = move |t: f64| ((t - t0) / span).clamp(0.0, 1.0) as f32;
357 let busiest = self.shown.iter().map(Episode::intensity).fold(0.0f64, f64::max).max(1e-9);
358 let header = h_flex()
359 .gap(px(theme::MD))
360 .items_center()
361 .child(div().text_sm().font_weight(FontWeight::SEMIBOLD).child(self.title.clone()))
362 .child(div().flex_1().text_xs().text_color(color::text_muted()).child(if self.activities.is_empty() {
363 "Nothing yet: what you and the agent do shows here".to_string()
364 } else {
365 format!("{} episodes from {} actions · {} – {}", self.shown.len(), self.activities.len(), clock(t0), clock(t1))
366 }))
367 .child(div().w(px(150.)).child({
368 let me = cx.entity().downgrade();
369 MenuSelect::new("timeline-detail", vec!["Summary".into(), "Every action".into()], Some(if self.summary { 0 } else { 1 }), move |i, _, cx| {
370 let _ = me.update(cx, |t, cx| t.set_summary(i == 0, cx));
371 })
372 }));
373 let lanes = self.lanes().into_iter().map(|lane| {
374 let bars = self.shown.iter().enumerate().filter(|(_, e)| e.lane == lane).map(|(i, e)| {
375 let (left, width) = (x(e.start), (x(e.end) - x(e.start)).max(0.002));
376 let h = 0.35 + 0.65 * (e.intensity() / busiest).sqrt() as f32;
378 let (hovered, selected) = (self.hovered == Some(i), self.selected == Some(i));
379 let fill = self.color(&e.kind);
380 let label = (width > 0.07).then(|| {
381 div().absolute().left(relative(left)).w(relative(width)).top_0().px(px(2.)).truncate().text_xs().text_color(fill).child(e.title.clone())
382 });
383 let bar = div()
384 .id(SharedString::from(format!("timeline-bar-{i}")))
385 .absolute()
386 .left(relative(left))
387 .w(relative(width))
388 .bottom_0()
389 .h(px((LANE_H - 18.) * h))
390 .rounded(px(2.))
391 .bg(if hovered || selected { fill } else { fill.opacity(0.85) })
392 .when(selected, |d| d.border_2().border_color(color::text()))
393 .cursor_pointer()
394 .on_hover(cx.listener(move |t, on: &bool, _, cx| {
395 t.hovered = if *on { Some(i) } else if t.hovered == Some(i) { None } else { t.hovered };
396 cx.notify();
397 }))
398 .on_click(cx.listener(move |t, _, _, cx| {
399 let next = if t.selected == Some(i) { None } else { Some(i) };
400 t.select(next, cx);
401 }))
402 .test_support();
403 [label.map(|l| l.into_any_element()), Some(bar.into_any_element())]
404 });
405 h_flex()
406 .h(px(LANE_H))
407 .items_end()
408 .child(div().w(px(LANE_LABEL_W)).flex_none().pb(px(theme::XS)).text_xs().text_color(color::text_muted()).child(lane.clone()))
409 .child(div().relative().flex_1().h_full().border_b_1().border_color(color::border()).children(bars.flatten().flatten()))
410 });
411 let step = tick_step(span);
413 let first = (t0 / step).ceil() * step;
414 let ticks = (0..12).map(|k| first + k as f64 * step).take_while(|t| *t <= t1).map(|t| {
415 div().absolute().left(relative(x(t))).top_0().pl(px(2.)).border_l_1().border_color(color::border()).text_xs().text_color(color::text_muted()).child(clock(t))
416 });
417 let axis = h_flex().h(px(18.)).child(div().w(px(LANE_LABEL_W)).flex_none()).child(div().relative().flex_1().h_full().children(ticks));
418 let hover = self.hovered.and_then(|i| self.shown.get(i)).map(|e| {
420 let left = x(e.start).min(0.75);
421 h_flex().h(px(0.)).child(div().w(px(LANE_LABEL_W)).flex_none()).child(
422 div().relative().flex_1().child(
423 v_flex()
424 .absolute()
425 .left(relative(left))
426 .top(px(theme::XS))
427 .w(px(260.))
428 .gap(px(2.))
429 .p(px(theme::SM))
430 .rounded(px(theme::RADIUS))
431 .border_1()
432 .border_color(color::border())
433 .bg(color::raised())
434 .shadow_lg()
435 .child(h_flex().gap(px(theme::XS)).items_center().child(div().size(px(8.)).rounded_full().bg(self.color(&e.kind))).child(div().text_sm().font_weight(FontWeight::SEMIBOLD).child(e.kind.clone())).child(div().text_xs().text_color(color::text_muted()).child(format!("{} · {}", clock(e.start), duration(e.duration())))))
436 .child(div().text_sm().truncate().child(e.title.clone()))
437 .child(div().text_xs().text_color(color::text_muted()).child(format!("{} action{} · {}", e.items.len(), if e.items.len() == 1 { "" } else { "s" }, e.lane)))
438 .children(e.items.first().filter(|a| !a.detail.is_empty()).map(|a| div().text_xs().font_family("monospace").text_color(color::text_muted()).line_clamp(2).child(a.detail.clone()))),
439 ),
440 )
441 });
442 let context = self.selected.and_then(|i| self.shown.get(i)).map(|e| self.context_cards(e));
443 v_flex()
444 .id("timeline")
445 .size_full()
446 .gap(px(theme::SM))
447 .p(px(theme::MD))
448 .overflow_y_scroll()
449 .child(header)
450 .child(v_flex().children(lanes).child(axis).children(hover))
451 .children(context)
452 .when(self.selected.is_none() && !self.shown.is_empty(), |d| d.child(div().pt(px(theme::LG)).text_xs().text_color(color::text_muted()).child("Click a bar to see what was done in it, where it came from and what it produced.")))
453 }
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 fn act(start: f64, lane: &str, kind: &str, title: &str) -> Activity {
461 Activity { start, end: start, lane: lane.into(), kind: kind.into(), title: title.into(), ..Default::default() }
462 }
463
464 #[test]
466 fn instants_last_until_the_next_one() {
467 let filled = fill_ends(&[act(0.0, "You", "A", "a"), act(60.0, "You", "B", "b"), act(10.0, "Agent", "C", "c")], 300.0, 1000.0);
468 let end = |t: &str| filled.iter().find(|a| a.title == t).unwrap().end;
469 assert_eq!(end("a"), 60.0);
470 assert_eq!(end("b"), 360.0, "the idle limit");
471 assert_eq!(end("c"), 310.0, "lanes are apart");
472 }
473
474 #[test]
476 fn many_actions_become_a_few_episodes() {
477 let mut items = Vec::new();
478 let kinds = ["Research", "Draft", "Research", "Chat", "Draft"];
479 for k in 0..50 {
480 let kind = kinds[(k / 10) % kinds.len()];
481 let kind = if k % 7 == 3 { "System" } else { kind };
483 items.push(act(k as f64 * 30.0, "You", kind, &format!("step {}", k % 4)));
484 }
485 let items = fill_ends(&items, 300.0, 1500.0);
486 let raw = each(&items);
487 assert_eq!(raw.len(), 50);
488 let eps = episodes(&items, 7, 120.0);
489 assert!(eps.len() <= 7, "{} episodes", eps.len());
490 assert_eq!(eps.iter().map(|e| e.items.len()).sum::<usize>(), 50, "nothing lost");
491 assert!(eps.windows(2).all(|w| w[0].start <= w[1].start));
492 assert!(eps.iter().any(|e| e.kind == "Research") && eps.iter().any(|e| e.kind == "Draft"));
493 }
494
495 #[test]
496 fn durations_and_ticks_read_well() {
497 assert_eq!(duration(45.0), "45 s");
498 assert_eq!(duration(26.0 * 60.0), "26 min");
499 assert_eq!(duration(3900.0), "1 h 05");
500 assert_eq!(tick_step(3.0 * 3600.0), 1800.0);
501 assert_eq!(tick_step(600.0), 120.0);
502 }
503}