thread_history.rs

   1use crate::acp::AcpThreadView;
   2use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread};
   3use acp_thread::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest};
   4use agent_client_protocol as acp;
   5use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
   6use editor::{Editor, EditorEvent};
   7use fuzzy::StringMatchCandidate;
   8use gpui::{
   9    App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task,
  10    UniformListScrollHandle, WeakEntity, Window, uniform_list,
  11};
  12use std::{fmt::Display, ops::Range, rc::Rc};
  13use text::Bias;
  14use time::{OffsetDateTime, UtcOffset};
  15use ui::{
  16    ElementId, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip,
  17    WithScrollbar, prelude::*,
  18};
  19
  20const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread");
  21
  22fn thread_title(entry: &AgentSessionInfo) -> &SharedString {
  23    entry
  24        .title
  25        .as_ref()
  26        .filter(|title| !title.is_empty())
  27        .unwrap_or(DEFAULT_TITLE)
  28}
  29
  30pub struct AcpThreadHistory {
  31    session_list: Option<Rc<dyn AgentSessionList>>,
  32    sessions: Vec<AgentSessionInfo>,
  33    scroll_handle: UniformListScrollHandle,
  34    selected_index: usize,
  35    hovered_index: Option<usize>,
  36    search_editor: Entity<Editor>,
  37    search_query: SharedString,
  38    visible_items: Vec<ListItemType>,
  39    local_timezone: UtcOffset,
  40    confirming_delete_history: bool,
  41    _update_task: Task<()>,
  42    _watch_task: Option<Task<()>>,
  43    _subscriptions: Vec<gpui::Subscription>,
  44}
  45
  46enum ListItemType {
  47    BucketSeparator(TimeBucket),
  48    Entry {
  49        entry: AgentSessionInfo,
  50        format: EntryTimeFormat,
  51    },
  52    SearchResult {
  53        entry: AgentSessionInfo,
  54        positions: Vec<usize>,
  55    },
  56}
  57
  58impl ListItemType {
  59    fn history_entry(&self) -> Option<&AgentSessionInfo> {
  60        match self {
  61            ListItemType::Entry { entry, .. } => Some(entry),
  62            ListItemType::SearchResult { entry, .. } => Some(entry),
  63            _ => None,
  64        }
  65    }
  66}
  67
  68pub enum ThreadHistoryEvent {
  69    Open(AgentSessionInfo),
  70}
  71
  72impl EventEmitter<ThreadHistoryEvent> for AcpThreadHistory {}
  73
  74impl AcpThreadHistory {
  75    pub fn new(
  76        session_list: Option<Rc<dyn AgentSessionList>>,
  77        window: &mut Window,
  78        cx: &mut Context<Self>,
  79    ) -> Self {
  80        let search_editor = cx.new(|cx| {
  81            let mut editor = Editor::single_line(window, cx);
  82            editor.set_placeholder_text("Search threads...", window, cx);
  83            editor
  84        });
  85
  86        let search_editor_subscription =
  87            cx.subscribe(&search_editor, |this, search_editor, event, cx| {
  88                if let EditorEvent::BufferEdited = event {
  89                    let query = search_editor.read(cx).text(cx);
  90                    if this.search_query != query {
  91                        this.search_query = query.into();
  92                        this.update_visible_items(false, cx);
  93                    }
  94                }
  95            });
  96
  97        let scroll_handle = UniformListScrollHandle::default();
  98
  99        let mut this = Self {
 100            session_list: None,
 101            sessions: Vec::new(),
 102            scroll_handle,
 103            selected_index: 0,
 104            hovered_index: None,
 105            visible_items: Default::default(),
 106            search_editor,
 107            local_timezone: UtcOffset::from_whole_seconds(
 108                chrono::Local::now().offset().local_minus_utc(),
 109            )
 110            .unwrap(),
 111            search_query: SharedString::default(),
 112            confirming_delete_history: false,
 113            _subscriptions: vec![search_editor_subscription],
 114            _update_task: Task::ready(()),
 115            _watch_task: None,
 116        };
 117        this.set_session_list(session_list, cx);
 118        this
 119    }
 120
 121    fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
 122        let entries = self.sessions.clone();
 123        let new_list_items = if self.search_query.is_empty() {
 124            self.add_list_separators(entries, cx)
 125        } else {
 126            self.filter_search_results(entries, cx)
 127        };
 128        let selected_history_entry = if preserve_selected_item {
 129            self.selected_history_entry().cloned()
 130        } else {
 131            None
 132        };
 133
 134        self._update_task = cx.spawn(async move |this, cx| {
 135            let new_visible_items = new_list_items.await;
 136            this.update(cx, |this, cx| {
 137                let new_selected_index = if let Some(history_entry) = selected_history_entry {
 138                    new_visible_items
 139                        .iter()
 140                        .position(|visible_entry| {
 141                            visible_entry
 142                                .history_entry()
 143                                .is_some_and(|entry| entry.session_id == history_entry.session_id)
 144                        })
 145                        .unwrap_or(0)
 146                } else {
 147                    0
 148                };
 149
 150                this.visible_items = new_visible_items;
 151                this.set_selected_index(new_selected_index, Bias::Right, cx);
 152                cx.notify();
 153            })
 154            .ok();
 155        });
 156    }
 157
 158    pub fn set_session_list(
 159        &mut self,
 160        session_list: Option<Rc<dyn AgentSessionList>>,
 161        cx: &mut Context<Self>,
 162    ) {
 163        if let (Some(current), Some(next)) = (&self.session_list, &session_list)
 164            && Rc::ptr_eq(current, next)
 165        {
 166            return;
 167        }
 168
 169        self.session_list = session_list;
 170        self.sessions.clear();
 171        self.visible_items.clear();
 172        self.selected_index = 0;
 173        self.refresh_sessions(false, cx);
 174
 175        self._watch_task = self.session_list.as_ref().and_then(|session_list| {
 176            let mut rx = session_list.watch(cx)?;
 177            Some(cx.spawn(async move |this, cx| {
 178                while let Ok(()) = rx.recv().await {
 179                    this.update(cx, |this, cx| {
 180                        this.refresh_sessions(true, cx);
 181                    })
 182                    .ok();
 183                }
 184            }))
 185        });
 186    }
 187
 188    fn refresh_sessions(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
 189        let Some(session_list) = self.session_list.clone() else {
 190            self.update_visible_items(preserve_selected_item, cx);
 191            return;
 192        };
 193
 194        self._update_task = cx.spawn(async move |this, cx| {
 195            let mut cursor: Option<String> = None;
 196            let mut is_first_page = true;
 197
 198            loop {
 199                let request = AgentSessionListRequest {
 200                    cursor: cursor.clone(),
 201                    ..Default::default()
 202                };
 203                let task = cx.update(|cx| session_list.list_sessions(request, cx));
 204                let response = match task.await {
 205                    Ok(response) => response,
 206                    Err(error) => {
 207                        log::error!("Failed to load session history: {error:#}");
 208                        return;
 209                    }
 210                };
 211
 212                let acp_thread::AgentSessionListResponse {
 213                    sessions: page_sessions,
 214                    next_cursor,
 215                    ..
 216                } = response;
 217
 218                this.update(cx, |this, cx| {
 219                    if is_first_page {
 220                        this.sessions = page_sessions;
 221                    } else {
 222                        this.sessions.extend(page_sessions);
 223                    }
 224                    this.update_visible_items(preserve_selected_item, cx);
 225                })
 226                .ok();
 227
 228                is_first_page = false;
 229                match next_cursor {
 230                    Some(next_cursor) => {
 231                        if cursor.as_ref() == Some(&next_cursor) {
 232                            log::warn!(
 233                                "Session list pagination returned the same cursor; stopping to avoid a loop."
 234                            );
 235                            break;
 236                        }
 237                        cursor = Some(next_cursor);
 238                    }
 239                    None => break,
 240                }
 241            }
 242        });
 243    }
 244
 245    pub(crate) fn is_empty(&self) -> bool {
 246        self.sessions.is_empty()
 247    }
 248
 249    pub fn has_session_list(&self) -> bool {
 250        self.session_list.is_some()
 251    }
 252
 253    pub fn refresh(&mut self, cx: &mut Context<Self>) {
 254        self.refresh_sessions(true, cx);
 255    }
 256
 257    pub fn session_for_id(&self, session_id: &acp::SessionId) -> Option<AgentSessionInfo> {
 258        self.sessions
 259            .iter()
 260            .find(|entry| &entry.session_id == session_id)
 261            .cloned()
 262    }
 263
 264    pub(crate) fn sessions(&self) -> &[AgentSessionInfo] {
 265        &self.sessions
 266    }
 267
 268    pub(crate) fn get_recent_sessions(&self, limit: usize) -> Vec<AgentSessionInfo> {
 269        self.sessions.iter().take(limit).cloned().collect()
 270    }
 271
 272    pub fn supports_delete(&self) -> bool {
 273        self.session_list
 274            .as_ref()
 275            .map(|sl| sl.supports_delete())
 276            .unwrap_or(false)
 277    }
 278
 279    pub(crate) fn delete_session(
 280        &self,
 281        session_id: &acp::SessionId,
 282        cx: &mut App,
 283    ) -> Task<anyhow::Result<()>> {
 284        if let Some(session_list) = self.session_list.as_ref() {
 285            session_list.delete_session(session_id, cx)
 286        } else {
 287            Task::ready(Ok(()))
 288        }
 289    }
 290
 291    fn add_list_separators(
 292        &self,
 293        entries: Vec<AgentSessionInfo>,
 294        cx: &App,
 295    ) -> Task<Vec<ListItemType>> {
 296        cx.background_spawn(async move {
 297            let mut items = Vec::with_capacity(entries.len() + 1);
 298            let mut bucket = None;
 299            let today = Local::now().naive_local().date();
 300
 301            for entry in entries.into_iter() {
 302                let entry_bucket = entry
 303                    .updated_at
 304                    .map(|timestamp| {
 305                        let entry_date = timestamp.with_timezone(&Local).naive_local().date();
 306                        TimeBucket::from_dates(today, entry_date)
 307                    })
 308                    .unwrap_or(TimeBucket::All);
 309
 310                if Some(entry_bucket) != bucket {
 311                    bucket = Some(entry_bucket);
 312                    items.push(ListItemType::BucketSeparator(entry_bucket));
 313                }
 314
 315                items.push(ListItemType::Entry {
 316                    entry,
 317                    format: entry_bucket.into(),
 318                });
 319            }
 320            items
 321        })
 322    }
 323
 324    fn filter_search_results(
 325        &self,
 326        entries: Vec<AgentSessionInfo>,
 327        cx: &App,
 328    ) -> Task<Vec<ListItemType>> {
 329        let query = self.search_query.clone();
 330        cx.background_spawn({
 331            let executor = cx.background_executor().clone();
 332            async move {
 333                let mut candidates = Vec::with_capacity(entries.len());
 334
 335                for (idx, entry) in entries.iter().enumerate() {
 336                    candidates.push(StringMatchCandidate::new(idx, thread_title(entry)));
 337                }
 338
 339                const MAX_MATCHES: usize = 100;
 340
 341                let matches = fuzzy::match_strings(
 342                    &candidates,
 343                    &query,
 344                    false,
 345                    true,
 346                    MAX_MATCHES,
 347                    &Default::default(),
 348                    executor,
 349                )
 350                .await;
 351
 352                matches
 353                    .into_iter()
 354                    .map(|search_match| ListItemType::SearchResult {
 355                        entry: entries[search_match.candidate_id].clone(),
 356                        positions: search_match.positions,
 357                    })
 358                    .collect()
 359            }
 360        })
 361    }
 362
 363    fn search_produced_no_matches(&self) -> bool {
 364        self.visible_items.is_empty() && !self.search_query.is_empty()
 365    }
 366
 367    fn selected_history_entry(&self) -> Option<&AgentSessionInfo> {
 368        self.get_history_entry(self.selected_index)
 369    }
 370
 371    fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> {
 372        self.visible_items.get(visible_items_ix)?.history_entry()
 373    }
 374
 375    fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context<Self>) {
 376        if self.visible_items.len() == 0 {
 377            self.selected_index = 0;
 378            return;
 379        }
 380        while matches!(
 381            self.visible_items.get(index),
 382            None | Some(ListItemType::BucketSeparator(..))
 383        ) {
 384            index = match bias {
 385                Bias::Left => {
 386                    if index == 0 {
 387                        self.visible_items.len() - 1
 388                    } else {
 389                        index - 1
 390                    }
 391                }
 392                Bias::Right => {
 393                    if index >= self.visible_items.len() - 1 {
 394                        0
 395                    } else {
 396                        index + 1
 397                    }
 398                }
 399            };
 400        }
 401        self.selected_index = index;
 402        self.scroll_handle
 403            .scroll_to_item(index, ScrollStrategy::Top);
 404        cx.notify()
 405    }
 406
 407    pub fn select_previous(
 408        &mut self,
 409        _: &menu::SelectPrevious,
 410        _window: &mut Window,
 411        cx: &mut Context<Self>,
 412    ) {
 413        if self.selected_index == 0 {
 414            self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
 415        } else {
 416            self.set_selected_index(self.selected_index - 1, Bias::Left, cx);
 417        }
 418    }
 419
 420    pub fn select_next(
 421        &mut self,
 422        _: &menu::SelectNext,
 423        _window: &mut Window,
 424        cx: &mut Context<Self>,
 425    ) {
 426        if self.selected_index == self.visible_items.len() - 1 {
 427            self.set_selected_index(0, Bias::Right, cx);
 428        } else {
 429            self.set_selected_index(self.selected_index + 1, Bias::Right, cx);
 430        }
 431    }
 432
 433    fn select_first(
 434        &mut self,
 435        _: &menu::SelectFirst,
 436        _window: &mut Window,
 437        cx: &mut Context<Self>,
 438    ) {
 439        self.set_selected_index(0, Bias::Right, cx);
 440    }
 441
 442    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
 443        self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
 444    }
 445
 446    fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
 447        self.confirm_entry(self.selected_index, cx);
 448    }
 449
 450    fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
 451        let Some(entry) = self.get_history_entry(ix) else {
 452            return;
 453        };
 454        cx.emit(ThreadHistoryEvent::Open(entry.clone()));
 455    }
 456
 457    fn remove_selected_thread(
 458        &mut self,
 459        _: &RemoveSelectedThread,
 460        _window: &mut Window,
 461        cx: &mut Context<Self>,
 462    ) {
 463        self.remove_thread(self.selected_index, cx)
 464    }
 465
 466    fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
 467        let Some(entry) = self.get_history_entry(visible_item_ix) else {
 468            return;
 469        };
 470        let Some(session_list) = self.session_list.as_ref() else {
 471            return;
 472        };
 473        if !session_list.supports_delete() {
 474            return;
 475        }
 476        let task = session_list.delete_session(&entry.session_id, cx);
 477        task.detach_and_log_err(cx);
 478    }
 479
 480    fn remove_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
 481        let Some(session_list) = self.session_list.as_ref() else {
 482            return;
 483        };
 484        if !session_list.supports_delete() {
 485            return;
 486        }
 487        session_list.delete_sessions(cx).detach_and_log_err(cx);
 488        self.confirming_delete_history = false;
 489        cx.notify();
 490    }
 491
 492    fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
 493        self.confirming_delete_history = true;
 494        cx.notify();
 495    }
 496
 497    fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
 498        self.confirming_delete_history = false;
 499        cx.notify();
 500    }
 501
 502    fn render_list_items(
 503        &mut self,
 504        range: Range<usize>,
 505        _window: &mut Window,
 506        cx: &mut Context<Self>,
 507    ) -> Vec<AnyElement> {
 508        self.visible_items
 509            .get(range.clone())
 510            .into_iter()
 511            .flatten()
 512            .enumerate()
 513            .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx))
 514            .collect()
 515    }
 516
 517    fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context<Self>) -> AnyElement {
 518        match item {
 519            ListItemType::Entry { entry, format } => self
 520                .render_history_entry(entry, *format, ix, Vec::default(), cx)
 521                .into_any(),
 522            ListItemType::SearchResult { entry, positions } => self.render_history_entry(
 523                entry,
 524                EntryTimeFormat::DateAndTime,
 525                ix,
 526                positions.clone(),
 527                cx,
 528            ),
 529            ListItemType::BucketSeparator(bucket) => div()
 530                .px(DynamicSpacing::Base06.rems(cx))
 531                .pt_2()
 532                .pb_1()
 533                .child(
 534                    Label::new(bucket.to_string())
 535                        .size(LabelSize::XSmall)
 536                        .color(Color::Muted),
 537                )
 538                .into_any_element(),
 539        }
 540    }
 541
 542    fn render_history_entry(
 543        &self,
 544        entry: &AgentSessionInfo,
 545        format: EntryTimeFormat,
 546        ix: usize,
 547        highlight_positions: Vec<usize>,
 548        cx: &Context<Self>,
 549    ) -> AnyElement {
 550        let selected = ix == self.selected_index;
 551        let hovered = Some(ix) == self.hovered_index;
 552        let entry_time = entry.updated_at;
 553        let display_text = match (format, entry_time) {
 554            (EntryTimeFormat::DateAndTime, Some(entry_time)) => {
 555                let now = Utc::now();
 556                let duration = now.signed_duration_since(entry_time);
 557                let days = duration.num_days();
 558
 559                format!("{}d", days)
 560            }
 561            (EntryTimeFormat::TimeOnly, Some(entry_time)) => {
 562                format.format_timestamp(entry_time.timestamp(), self.local_timezone)
 563            }
 564            (_, None) => "".to_string(),
 565        };
 566
 567        let title = thread_title(entry).clone();
 568        let full_date = entry_time
 569            .map(|time| {
 570                EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone)
 571            })
 572            .unwrap_or_else(|| "Unknown".to_string());
 573
 574        h_flex()
 575            .w_full()
 576            .pb_1()
 577            .child(
 578                ListItem::new(ix)
 579                    .rounded()
 580                    .toggle_state(selected)
 581                    .spacing(ListItemSpacing::Sparse)
 582                    .start_slot(
 583                        h_flex()
 584                            .w_full()
 585                            .gap_2()
 586                            .justify_between()
 587                            .child(
 588                                HighlightedLabel::new(thread_title(entry), highlight_positions)
 589                                    .size(LabelSize::Small)
 590                                    .truncate(),
 591                            )
 592                            .child(
 593                                Label::new(display_text)
 594                                    .color(Color::Muted)
 595                                    .size(LabelSize::XSmall),
 596                            ),
 597                    )
 598                    .tooltip(move |_, cx| {
 599                        Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
 600                    })
 601                    .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
 602                        if *is_hovered {
 603                            this.hovered_index = Some(ix);
 604                        } else if this.hovered_index == Some(ix) {
 605                            this.hovered_index = None;
 606                        }
 607
 608                        cx.notify();
 609                    }))
 610                    .end_slot::<IconButton>(if hovered && self.supports_delete() {
 611                        Some(
 612                            IconButton::new("delete", IconName::Trash)
 613                                .shape(IconButtonShape::Square)
 614                                .icon_size(IconSize::XSmall)
 615                                .icon_color(Color::Muted)
 616                                .tooltip(move |_window, cx| {
 617                                    Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
 618                                })
 619                                .on_click(cx.listener(move |this, _, _, cx| {
 620                                    this.remove_thread(ix, cx);
 621                                    cx.stop_propagation()
 622                                })),
 623                        )
 624                    } else {
 625                        None
 626                    })
 627                    .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
 628            )
 629            .into_any_element()
 630    }
 631}
 632
 633impl Focusable for AcpThreadHistory {
 634    fn focus_handle(&self, cx: &App) -> FocusHandle {
 635        self.search_editor.focus_handle(cx)
 636    }
 637}
 638
 639impl Render for AcpThreadHistory {
 640    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 641        let has_no_history = self.is_empty();
 642
 643        v_flex()
 644            .key_context("ThreadHistory")
 645            .size_full()
 646            .bg(cx.theme().colors().panel_background)
 647            .on_action(cx.listener(Self::select_previous))
 648            .on_action(cx.listener(Self::select_next))
 649            .on_action(cx.listener(Self::select_first))
 650            .on_action(cx.listener(Self::select_last))
 651            .on_action(cx.listener(Self::confirm))
 652            .on_action(cx.listener(Self::remove_selected_thread))
 653            .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| {
 654                this.remove_history(window, cx);
 655            }))
 656            .child(
 657                h_flex()
 658                    .h(Tab::container_height(cx))
 659                    .w_full()
 660                    .py_1()
 661                    .px_2()
 662                    .gap_2()
 663                    .justify_between()
 664                    .border_b_1()
 665                    .border_color(cx.theme().colors().border)
 666                    .child(
 667                        Icon::new(IconName::MagnifyingGlass)
 668                            .color(Color::Muted)
 669                            .size(IconSize::Small),
 670                    )
 671                    .child(self.search_editor.clone()),
 672            )
 673            .child({
 674                let view = v_flex()
 675                    .id("list-container")
 676                    .relative()
 677                    .overflow_hidden()
 678                    .flex_grow();
 679
 680                if has_no_history {
 681                    view.justify_center().items_center().child(
 682                        Label::new("You don't have any past threads yet.")
 683                            .size(LabelSize::Small)
 684                            .color(Color::Muted),
 685                    )
 686                } else if self.search_produced_no_matches() {
 687                    view.justify_center()
 688                        .items_center()
 689                        .child(Label::new("No threads match your search.").size(LabelSize::Small))
 690                } else {
 691                    view.child(
 692                        uniform_list(
 693                            "thread-history",
 694                            self.visible_items.len(),
 695                            cx.processor(|this, range: Range<usize>, window, cx| {
 696                                this.render_list_items(range, window, cx)
 697                            }),
 698                        )
 699                        .p_1()
 700                        .pr_4()
 701                        .track_scroll(&self.scroll_handle)
 702                        .flex_grow(),
 703                    )
 704                    .vertical_scrollbar_for(&self.scroll_handle, window, cx)
 705                }
 706            })
 707            .when(!has_no_history && self.supports_delete(), |this| {
 708                this.child(
 709                    h_flex()
 710                        .p_2()
 711                        .border_t_1()
 712                        .border_color(cx.theme().colors().border_variant)
 713                        .when(!self.confirming_delete_history, |this| {
 714                            this.child(
 715                                Button::new("delete_history", "Delete All History")
 716                                    .full_width()
 717                                    .style(ButtonStyle::Outlined)
 718                                    .label_size(LabelSize::Small)
 719                                    .on_click(cx.listener(|this, _, window, cx| {
 720                                        this.prompt_delete_history(window, cx);
 721                                    })),
 722                            )
 723                        })
 724                        .when(self.confirming_delete_history, |this| {
 725                            this.w_full()
 726                                .gap_2()
 727                                .flex_wrap()
 728                                .justify_between()
 729                                .child(
 730                                    h_flex()
 731                                        .flex_wrap()
 732                                        .gap_1()
 733                                        .child(
 734                                            Label::new("Delete all threads?")
 735                                                .size(LabelSize::Small),
 736                                        )
 737                                        .child(
 738                                            Label::new("You won't be able to recover them later.")
 739                                                .size(LabelSize::Small)
 740                                                .color(Color::Muted),
 741                                        ),
 742                                )
 743                                .child(
 744                                    h_flex()
 745                                        .gap_1()
 746                                        .child(
 747                                            Button::new("cancel_delete", "Cancel")
 748                                                .label_size(LabelSize::Small)
 749                                                .on_click(cx.listener(|this, _, window, cx| {
 750                                                    this.cancel_delete_history(window, cx);
 751                                                })),
 752                                        )
 753                                        .child(
 754                                            Button::new("confirm_delete", "Delete")
 755                                                .style(ButtonStyle::Tinted(ui::TintColor::Error))
 756                                                .color(Color::Error)
 757                                                .label_size(LabelSize::Small)
 758                                                .on_click(cx.listener(|_, _, window, cx| {
 759                                                    window.dispatch_action(
 760                                                        Box::new(RemoveHistory),
 761                                                        cx,
 762                                                    );
 763                                                })),
 764                                        ),
 765                                )
 766                        }),
 767                )
 768            })
 769    }
 770}
 771
 772#[derive(IntoElement)]
 773pub struct AcpHistoryEntryElement {
 774    entry: AgentSessionInfo,
 775    thread_view: WeakEntity<AcpThreadView>,
 776    selected: bool,
 777    hovered: bool,
 778    supports_delete: bool,
 779    on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
 780}
 781
 782impl AcpHistoryEntryElement {
 783    pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity<AcpThreadView>) -> Self {
 784        Self {
 785            entry,
 786            thread_view,
 787            selected: false,
 788            hovered: false,
 789            supports_delete: false,
 790            on_hover: Box::new(|_, _, _| {}),
 791        }
 792    }
 793
 794    pub fn supports_delete(mut self, supports_delete: bool) -> Self {
 795        self.supports_delete = supports_delete;
 796        self
 797    }
 798
 799    pub fn hovered(mut self, hovered: bool) -> Self {
 800        self.hovered = hovered;
 801        self
 802    }
 803
 804    pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
 805        self.on_hover = Box::new(on_hover);
 806        self
 807    }
 808}
 809
 810impl RenderOnce for AcpHistoryEntryElement {
 811    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
 812        let id = ElementId::Name(self.entry.session_id.0.clone().into());
 813        let title = thread_title(&self.entry).clone();
 814        let formatted_time = self
 815            .entry
 816            .updated_at
 817            .map(|timestamp| {
 818                let now = chrono::Utc::now();
 819                let duration = now.signed_duration_since(timestamp);
 820
 821                if duration.num_days() > 0 {
 822                    format!("{}d", duration.num_days())
 823                } else if duration.num_hours() > 0 {
 824                    format!("{}h ago", duration.num_hours())
 825                } else if duration.num_minutes() > 0 {
 826                    format!("{}m ago", duration.num_minutes())
 827                } else {
 828                    "Just now".to_string()
 829                }
 830            })
 831            .unwrap_or_else(|| "Unknown".to_string());
 832
 833        ListItem::new(id)
 834            .rounded()
 835            .toggle_state(self.selected)
 836            .spacing(ListItemSpacing::Sparse)
 837            .start_slot(
 838                h_flex()
 839                    .w_full()
 840                    .gap_2()
 841                    .justify_between()
 842                    .child(Label::new(title).size(LabelSize::Small).truncate())
 843                    .child(
 844                        Label::new(formatted_time)
 845                            .color(Color::Muted)
 846                            .size(LabelSize::XSmall),
 847                    ),
 848            )
 849            .on_hover(self.on_hover)
 850            .end_slot::<IconButton>(if (self.hovered || self.selected) && self.supports_delete {
 851                Some(
 852                    IconButton::new("delete", IconName::Trash)
 853                        .shape(IconButtonShape::Square)
 854                        .icon_size(IconSize::XSmall)
 855                        .icon_color(Color::Muted)
 856                        .tooltip(move |_window, cx| {
 857                            Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
 858                        })
 859                        .on_click({
 860                            let thread_view = self.thread_view.clone();
 861                            let entry = self.entry.clone();
 862
 863                            move |_event, _window, cx| {
 864                                if let Some(thread_view) = thread_view.upgrade() {
 865                                    thread_view.update(cx, |thread_view, cx| {
 866                                        thread_view.delete_history_entry(entry.clone(), cx);
 867                                    });
 868                                }
 869                            }
 870                        }),
 871                )
 872            } else {
 873                None
 874            })
 875            .on_click({
 876                let thread_view = self.thread_view.clone();
 877                let entry = self.entry;
 878
 879                move |_event, window, cx| {
 880                    if let Some(workspace) = thread_view
 881                        .upgrade()
 882                        .and_then(|view| view.read(cx).workspace().upgrade())
 883                    {
 884                        if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
 885                            panel.update(cx, |panel, cx| {
 886                                panel.load_agent_thread(entry.clone(), window, cx);
 887                            });
 888                        }
 889                    }
 890                }
 891            })
 892    }
 893}
 894
 895#[derive(Clone, Copy)]
 896pub enum EntryTimeFormat {
 897    DateAndTime,
 898    TimeOnly,
 899}
 900
 901impl EntryTimeFormat {
 902    fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
 903        let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
 904
 905        match self {
 906            EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
 907                timestamp,
 908                OffsetDateTime::now_utc(),
 909                timezone,
 910                time_format::TimestampFormat::EnhancedAbsolute,
 911            ),
 912            EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
 913        }
 914    }
 915}
 916
 917impl From<TimeBucket> for EntryTimeFormat {
 918    fn from(bucket: TimeBucket) -> Self {
 919        match bucket {
 920            TimeBucket::Today => EntryTimeFormat::TimeOnly,
 921            TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
 922            TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
 923            TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
 924            TimeBucket::All => EntryTimeFormat::DateAndTime,
 925        }
 926    }
 927}
 928
 929#[derive(PartialEq, Eq, Clone, Copy, Debug)]
 930enum TimeBucket {
 931    Today,
 932    Yesterday,
 933    ThisWeek,
 934    PastWeek,
 935    All,
 936}
 937
 938impl TimeBucket {
 939    fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
 940        if date == reference {
 941            return TimeBucket::Today;
 942        }
 943
 944        if date == reference - TimeDelta::days(1) {
 945            return TimeBucket::Yesterday;
 946        }
 947
 948        let week = date.iso_week();
 949
 950        if reference.iso_week() == week {
 951            return TimeBucket::ThisWeek;
 952        }
 953
 954        let last_week = (reference - TimeDelta::days(7)).iso_week();
 955
 956        if week == last_week {
 957            return TimeBucket::PastWeek;
 958        }
 959
 960        TimeBucket::All
 961    }
 962}
 963
 964impl Display for TimeBucket {
 965    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 966        match self {
 967            TimeBucket::Today => write!(f, "Today"),
 968            TimeBucket::Yesterday => write!(f, "Yesterday"),
 969            TimeBucket::ThisWeek => write!(f, "This Week"),
 970            TimeBucket::PastWeek => write!(f, "Past Week"),
 971            TimeBucket::All => write!(f, "All"),
 972        }
 973    }
 974}
 975
 976#[cfg(test)]
 977mod tests {
 978    use super::*;
 979    use chrono::NaiveDate;
 980
 981    #[test]
 982    fn test_time_bucket_from_dates() {
 983        let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
 984
 985        let date = today;
 986        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
 987
 988        let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
 989        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
 990
 991        let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
 992        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
 993
 994        let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
 995        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
 996
 997        let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
 998        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
 999
1000        let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
1001        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
1002
1003        // All: not in this week or last week
1004        let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
1005        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
1006
1007        // Test year boundary cases
1008        let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
1009
1010        let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
1011        assert_eq!(
1012            TimeBucket::from_dates(new_year, date),
1013            TimeBucket::Yesterday
1014        );
1015
1016        let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
1017        assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
1018    }
1019}