thread_history.rs

   1use crate::acp::AcpThreadView;
   2use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread};
   3use acp_thread::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest, SessionListUpdate};
   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 rx = session_list.watch(cx)?;
 177            Some(cx.spawn(async move |this, cx| {
 178                while let Ok(first_update) = rx.recv().await {
 179                    let mut updates = vec![first_update];
 180                    while let Ok(update) = rx.try_recv() {
 181                        updates.push(update);
 182                    }
 183
 184                    let needs_refresh = updates
 185                        .iter()
 186                        .any(|u| matches!(u, SessionListUpdate::Refresh));
 187
 188                    this.update(cx, |this, cx| {
 189                        // We will refresh the whole list anyway, so no need to apply incremental updates or do several refreshes
 190                        if needs_refresh {
 191                            this.refresh_sessions(true, cx);
 192                        } else {
 193                            for update in updates {
 194                                if let SessionListUpdate::SessionInfo { session_id, update } =
 195                                    update
 196                                {
 197                                    this.apply_info_update(session_id, update, cx);
 198                                }
 199                            }
 200                        }
 201                    })
 202                    .ok();
 203                }
 204            }))
 205        });
 206    }
 207
 208    fn apply_info_update(
 209        &mut self,
 210        session_id: acp::SessionId,
 211        info_update: acp::SessionInfoUpdate,
 212        cx: &mut Context<Self>,
 213    ) {
 214        let Some(session) = self
 215            .sessions
 216            .iter_mut()
 217            .find(|s| s.session_id == session_id)
 218        else {
 219            return;
 220        };
 221
 222        match info_update.title {
 223            acp::MaybeUndefined::Value(title) => {
 224                session.title = Some(title.into());
 225            }
 226            acp::MaybeUndefined::Null => {
 227                session.title = None;
 228            }
 229            acp::MaybeUndefined::Undefined => {}
 230        }
 231        match info_update.updated_at {
 232            acp::MaybeUndefined::Value(date_str) => {
 233                if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&date_str) {
 234                    session.updated_at = Some(dt.with_timezone(&chrono::Utc));
 235                }
 236            }
 237            acp::MaybeUndefined::Null => {
 238                session.updated_at = None;
 239            }
 240            acp::MaybeUndefined::Undefined => {}
 241        }
 242        if let Some(meta) = info_update.meta {
 243            session.meta = Some(meta);
 244        }
 245
 246        self.update_visible_items(true, cx);
 247    }
 248
 249    fn refresh_sessions(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
 250        let Some(session_list) = self.session_list.clone() else {
 251            self.update_visible_items(preserve_selected_item, cx);
 252            return;
 253        };
 254
 255        self._update_task = cx.spawn(async move |this, cx| {
 256            let mut cursor: Option<String> = None;
 257            let mut is_first_page = true;
 258
 259            loop {
 260                let request = AgentSessionListRequest {
 261                    cursor: cursor.clone(),
 262                    ..Default::default()
 263                };
 264                let task = cx.update(|cx| session_list.list_sessions(request, cx));
 265                let response = match task.await {
 266                    Ok(response) => response,
 267                    Err(error) => {
 268                        log::error!("Failed to load session history: {error:#}");
 269                        return;
 270                    }
 271                };
 272
 273                let acp_thread::AgentSessionListResponse {
 274                    sessions: page_sessions,
 275                    next_cursor,
 276                    ..
 277                } = response;
 278
 279                this.update(cx, |this, cx| {
 280                    if is_first_page {
 281                        this.sessions = page_sessions;
 282                    } else {
 283                        this.sessions.extend(page_sessions);
 284                    }
 285                    this.update_visible_items(preserve_selected_item, cx);
 286                })
 287                .ok();
 288
 289                is_first_page = false;
 290                match next_cursor {
 291                    Some(next_cursor) => {
 292                        if cursor.as_ref() == Some(&next_cursor) {
 293                            log::warn!(
 294                                "Session list pagination returned the same cursor; stopping to avoid a loop."
 295                            );
 296                            break;
 297                        }
 298                        cursor = Some(next_cursor);
 299                    }
 300                    None => break,
 301                }
 302            }
 303        });
 304    }
 305
 306    pub(crate) fn is_empty(&self) -> bool {
 307        self.sessions.is_empty()
 308    }
 309
 310    pub fn has_session_list(&self) -> bool {
 311        self.session_list.is_some()
 312    }
 313
 314    pub fn refresh(&mut self, cx: &mut Context<Self>) {
 315        self.refresh_sessions(true, cx);
 316    }
 317
 318    pub fn session_for_id(&self, session_id: &acp::SessionId) -> Option<AgentSessionInfo> {
 319        self.sessions
 320            .iter()
 321            .find(|entry| &entry.session_id == session_id)
 322            .cloned()
 323    }
 324
 325    pub(crate) fn sessions(&self) -> &[AgentSessionInfo] {
 326        &self.sessions
 327    }
 328
 329    pub(crate) fn get_recent_sessions(&self, limit: usize) -> Vec<AgentSessionInfo> {
 330        self.sessions.iter().take(limit).cloned().collect()
 331    }
 332
 333    pub fn supports_delete(&self) -> bool {
 334        self.session_list
 335            .as_ref()
 336            .map(|sl| sl.supports_delete())
 337            .unwrap_or(false)
 338    }
 339
 340    pub(crate) fn delete_session(
 341        &self,
 342        session_id: &acp::SessionId,
 343        cx: &mut App,
 344    ) -> Task<anyhow::Result<()>> {
 345        if let Some(session_list) = self.session_list.as_ref() {
 346            session_list.delete_session(session_id, cx)
 347        } else {
 348            Task::ready(Ok(()))
 349        }
 350    }
 351
 352    fn add_list_separators(
 353        &self,
 354        entries: Vec<AgentSessionInfo>,
 355        cx: &App,
 356    ) -> Task<Vec<ListItemType>> {
 357        cx.background_spawn(async move {
 358            let mut items = Vec::with_capacity(entries.len() + 1);
 359            let mut bucket = None;
 360            let today = Local::now().naive_local().date();
 361
 362            for entry in entries.into_iter() {
 363                let entry_bucket = entry
 364                    .updated_at
 365                    .map(|timestamp| {
 366                        let entry_date = timestamp.with_timezone(&Local).naive_local().date();
 367                        TimeBucket::from_dates(today, entry_date)
 368                    })
 369                    .unwrap_or(TimeBucket::All);
 370
 371                if Some(entry_bucket) != bucket {
 372                    bucket = Some(entry_bucket);
 373                    items.push(ListItemType::BucketSeparator(entry_bucket));
 374                }
 375
 376                items.push(ListItemType::Entry {
 377                    entry,
 378                    format: entry_bucket.into(),
 379                });
 380            }
 381            items
 382        })
 383    }
 384
 385    fn filter_search_results(
 386        &self,
 387        entries: Vec<AgentSessionInfo>,
 388        cx: &App,
 389    ) -> Task<Vec<ListItemType>> {
 390        let query = self.search_query.clone();
 391        cx.background_spawn({
 392            let executor = cx.background_executor().clone();
 393            async move {
 394                let mut candidates = Vec::with_capacity(entries.len());
 395
 396                for (idx, entry) in entries.iter().enumerate() {
 397                    candidates.push(StringMatchCandidate::new(idx, thread_title(entry)));
 398                }
 399
 400                const MAX_MATCHES: usize = 100;
 401
 402                let matches = fuzzy::match_strings(
 403                    &candidates,
 404                    &query,
 405                    false,
 406                    true,
 407                    MAX_MATCHES,
 408                    &Default::default(),
 409                    executor,
 410                )
 411                .await;
 412
 413                matches
 414                    .into_iter()
 415                    .map(|search_match| ListItemType::SearchResult {
 416                        entry: entries[search_match.candidate_id].clone(),
 417                        positions: search_match.positions,
 418                    })
 419                    .collect()
 420            }
 421        })
 422    }
 423
 424    fn search_produced_no_matches(&self) -> bool {
 425        self.visible_items.is_empty() && !self.search_query.is_empty()
 426    }
 427
 428    fn selected_history_entry(&self) -> Option<&AgentSessionInfo> {
 429        self.get_history_entry(self.selected_index)
 430    }
 431
 432    fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> {
 433        self.visible_items.get(visible_items_ix)?.history_entry()
 434    }
 435
 436    fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context<Self>) {
 437        if self.visible_items.len() == 0 {
 438            self.selected_index = 0;
 439            return;
 440        }
 441        while matches!(
 442            self.visible_items.get(index),
 443            None | Some(ListItemType::BucketSeparator(..))
 444        ) {
 445            index = match bias {
 446                Bias::Left => {
 447                    if index == 0 {
 448                        self.visible_items.len() - 1
 449                    } else {
 450                        index - 1
 451                    }
 452                }
 453                Bias::Right => {
 454                    if index >= self.visible_items.len() - 1 {
 455                        0
 456                    } else {
 457                        index + 1
 458                    }
 459                }
 460            };
 461        }
 462        self.selected_index = index;
 463        self.scroll_handle
 464            .scroll_to_item(index, ScrollStrategy::Top);
 465        cx.notify()
 466    }
 467
 468    pub fn select_previous(
 469        &mut self,
 470        _: &menu::SelectPrevious,
 471        _window: &mut Window,
 472        cx: &mut Context<Self>,
 473    ) {
 474        if self.selected_index == 0 {
 475            self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
 476        } else {
 477            self.set_selected_index(self.selected_index - 1, Bias::Left, cx);
 478        }
 479    }
 480
 481    pub fn select_next(
 482        &mut self,
 483        _: &menu::SelectNext,
 484        _window: &mut Window,
 485        cx: &mut Context<Self>,
 486    ) {
 487        if self.selected_index == self.visible_items.len() - 1 {
 488            self.set_selected_index(0, Bias::Right, cx);
 489        } else {
 490            self.set_selected_index(self.selected_index + 1, Bias::Right, cx);
 491        }
 492    }
 493
 494    fn select_first(
 495        &mut self,
 496        _: &menu::SelectFirst,
 497        _window: &mut Window,
 498        cx: &mut Context<Self>,
 499    ) {
 500        self.set_selected_index(0, Bias::Right, cx);
 501    }
 502
 503    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
 504        self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
 505    }
 506
 507    fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
 508        self.confirm_entry(self.selected_index, cx);
 509    }
 510
 511    fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
 512        let Some(entry) = self.get_history_entry(ix) else {
 513            return;
 514        };
 515        cx.emit(ThreadHistoryEvent::Open(entry.clone()));
 516    }
 517
 518    fn remove_selected_thread(
 519        &mut self,
 520        _: &RemoveSelectedThread,
 521        _window: &mut Window,
 522        cx: &mut Context<Self>,
 523    ) {
 524        self.remove_thread(self.selected_index, cx)
 525    }
 526
 527    fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
 528        let Some(entry) = self.get_history_entry(visible_item_ix) else {
 529            return;
 530        };
 531        let Some(session_list) = self.session_list.as_ref() else {
 532            return;
 533        };
 534        if !session_list.supports_delete() {
 535            return;
 536        }
 537        let task = session_list.delete_session(&entry.session_id, cx);
 538        task.detach_and_log_err(cx);
 539    }
 540
 541    fn remove_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
 542        let Some(session_list) = self.session_list.as_ref() else {
 543            return;
 544        };
 545        if !session_list.supports_delete() {
 546            return;
 547        }
 548        session_list.delete_sessions(cx).detach_and_log_err(cx);
 549        self.confirming_delete_history = false;
 550        cx.notify();
 551    }
 552
 553    fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
 554        self.confirming_delete_history = true;
 555        cx.notify();
 556    }
 557
 558    fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
 559        self.confirming_delete_history = false;
 560        cx.notify();
 561    }
 562
 563    fn render_list_items(
 564        &mut self,
 565        range: Range<usize>,
 566        _window: &mut Window,
 567        cx: &mut Context<Self>,
 568    ) -> Vec<AnyElement> {
 569        self.visible_items
 570            .get(range.clone())
 571            .into_iter()
 572            .flatten()
 573            .enumerate()
 574            .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx))
 575            .collect()
 576    }
 577
 578    fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context<Self>) -> AnyElement {
 579        match item {
 580            ListItemType::Entry { entry, format } => self
 581                .render_history_entry(entry, *format, ix, Vec::default(), cx)
 582                .into_any(),
 583            ListItemType::SearchResult { entry, positions } => self.render_history_entry(
 584                entry,
 585                EntryTimeFormat::DateAndTime,
 586                ix,
 587                positions.clone(),
 588                cx,
 589            ),
 590            ListItemType::BucketSeparator(bucket) => div()
 591                .px(DynamicSpacing::Base06.rems(cx))
 592                .pt_2()
 593                .pb_1()
 594                .child(
 595                    Label::new(bucket.to_string())
 596                        .size(LabelSize::XSmall)
 597                        .color(Color::Muted),
 598                )
 599                .into_any_element(),
 600        }
 601    }
 602
 603    fn render_history_entry(
 604        &self,
 605        entry: &AgentSessionInfo,
 606        format: EntryTimeFormat,
 607        ix: usize,
 608        highlight_positions: Vec<usize>,
 609        cx: &Context<Self>,
 610    ) -> AnyElement {
 611        let selected = ix == self.selected_index;
 612        let hovered = Some(ix) == self.hovered_index;
 613        let entry_time = entry.updated_at;
 614        let display_text = match (format, entry_time) {
 615            (EntryTimeFormat::DateAndTime, Some(entry_time)) => {
 616                let now = Utc::now();
 617                let duration = now.signed_duration_since(entry_time);
 618                let days = duration.num_days();
 619
 620                format!("{}d", days)
 621            }
 622            (EntryTimeFormat::TimeOnly, Some(entry_time)) => {
 623                format.format_timestamp(entry_time.timestamp(), self.local_timezone)
 624            }
 625            (_, None) => "".to_string(),
 626        };
 627
 628        let title = thread_title(entry).clone();
 629        let full_date = entry_time
 630            .map(|time| {
 631                EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone)
 632            })
 633            .unwrap_or_else(|| "Unknown".to_string());
 634
 635        h_flex()
 636            .w_full()
 637            .pb_1()
 638            .child(
 639                ListItem::new(ix)
 640                    .rounded()
 641                    .toggle_state(selected)
 642                    .spacing(ListItemSpacing::Sparse)
 643                    .start_slot(
 644                        h_flex()
 645                            .w_full()
 646                            .gap_2()
 647                            .justify_between()
 648                            .child(
 649                                HighlightedLabel::new(thread_title(entry), highlight_positions)
 650                                    .size(LabelSize::Small)
 651                                    .truncate(),
 652                            )
 653                            .child(
 654                                Label::new(display_text)
 655                                    .color(Color::Muted)
 656                                    .size(LabelSize::XSmall),
 657                            ),
 658                    )
 659                    .tooltip(move |_, cx| {
 660                        Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
 661                    })
 662                    .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
 663                        if *is_hovered {
 664                            this.hovered_index = Some(ix);
 665                        } else if this.hovered_index == Some(ix) {
 666                            this.hovered_index = None;
 667                        }
 668
 669                        cx.notify();
 670                    }))
 671                    .end_slot::<IconButton>(if hovered && self.supports_delete() {
 672                        Some(
 673                            IconButton::new("delete", IconName::Trash)
 674                                .shape(IconButtonShape::Square)
 675                                .icon_size(IconSize::XSmall)
 676                                .icon_color(Color::Muted)
 677                                .tooltip(move |_window, cx| {
 678                                    Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
 679                                })
 680                                .on_click(cx.listener(move |this, _, _, cx| {
 681                                    this.remove_thread(ix, cx);
 682                                    cx.stop_propagation()
 683                                })),
 684                        )
 685                    } else {
 686                        None
 687                    })
 688                    .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
 689            )
 690            .into_any_element()
 691    }
 692}
 693
 694impl Focusable for AcpThreadHistory {
 695    fn focus_handle(&self, cx: &App) -> FocusHandle {
 696        self.search_editor.focus_handle(cx)
 697    }
 698}
 699
 700impl Render for AcpThreadHistory {
 701    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 702        let has_no_history = self.is_empty();
 703
 704        v_flex()
 705            .key_context("ThreadHistory")
 706            .size_full()
 707            .bg(cx.theme().colors().panel_background)
 708            .on_action(cx.listener(Self::select_previous))
 709            .on_action(cx.listener(Self::select_next))
 710            .on_action(cx.listener(Self::select_first))
 711            .on_action(cx.listener(Self::select_last))
 712            .on_action(cx.listener(Self::confirm))
 713            .on_action(cx.listener(Self::remove_selected_thread))
 714            .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| {
 715                this.remove_history(window, cx);
 716            }))
 717            .child(
 718                h_flex()
 719                    .h(Tab::container_height(cx))
 720                    .w_full()
 721                    .py_1()
 722                    .px_2()
 723                    .gap_2()
 724                    .justify_between()
 725                    .border_b_1()
 726                    .border_color(cx.theme().colors().border)
 727                    .child(
 728                        Icon::new(IconName::MagnifyingGlass)
 729                            .color(Color::Muted)
 730                            .size(IconSize::Small),
 731                    )
 732                    .child(self.search_editor.clone()),
 733            )
 734            .child({
 735                let view = v_flex()
 736                    .id("list-container")
 737                    .relative()
 738                    .overflow_hidden()
 739                    .flex_grow();
 740
 741                if has_no_history {
 742                    view.justify_center().items_center().child(
 743                        Label::new("You don't have any past threads yet.")
 744                            .size(LabelSize::Small)
 745                            .color(Color::Muted),
 746                    )
 747                } else if self.search_produced_no_matches() {
 748                    view.justify_center()
 749                        .items_center()
 750                        .child(Label::new("No threads match your search.").size(LabelSize::Small))
 751                } else {
 752                    view.child(
 753                        uniform_list(
 754                            "thread-history",
 755                            self.visible_items.len(),
 756                            cx.processor(|this, range: Range<usize>, window, cx| {
 757                                this.render_list_items(range, window, cx)
 758                            }),
 759                        )
 760                        .p_1()
 761                        .pr_4()
 762                        .track_scroll(&self.scroll_handle)
 763                        .flex_grow(),
 764                    )
 765                    .vertical_scrollbar_for(&self.scroll_handle, window, cx)
 766                }
 767            })
 768            .when(!has_no_history && self.supports_delete(), |this| {
 769                this.child(
 770                    h_flex()
 771                        .p_2()
 772                        .border_t_1()
 773                        .border_color(cx.theme().colors().border_variant)
 774                        .when(!self.confirming_delete_history, |this| {
 775                            this.child(
 776                                Button::new("delete_history", "Delete All History")
 777                                    .full_width()
 778                                    .style(ButtonStyle::Outlined)
 779                                    .label_size(LabelSize::Small)
 780                                    .on_click(cx.listener(|this, _, window, cx| {
 781                                        this.prompt_delete_history(window, cx);
 782                                    })),
 783                            )
 784                        })
 785                        .when(self.confirming_delete_history, |this| {
 786                            this.w_full()
 787                                .gap_2()
 788                                .flex_wrap()
 789                                .justify_between()
 790                                .child(
 791                                    h_flex()
 792                                        .flex_wrap()
 793                                        .gap_1()
 794                                        .child(
 795                                            Label::new("Delete all threads?")
 796                                                .size(LabelSize::Small),
 797                                        )
 798                                        .child(
 799                                            Label::new("You won't be able to recover them later.")
 800                                                .size(LabelSize::Small)
 801                                                .color(Color::Muted),
 802                                        ),
 803                                )
 804                                .child(
 805                                    h_flex()
 806                                        .gap_1()
 807                                        .child(
 808                                            Button::new("cancel_delete", "Cancel")
 809                                                .label_size(LabelSize::Small)
 810                                                .on_click(cx.listener(|this, _, window, cx| {
 811                                                    this.cancel_delete_history(window, cx);
 812                                                })),
 813                                        )
 814                                        .child(
 815                                            Button::new("confirm_delete", "Delete")
 816                                                .style(ButtonStyle::Tinted(ui::TintColor::Error))
 817                                                .color(Color::Error)
 818                                                .label_size(LabelSize::Small)
 819                                                .on_click(cx.listener(|_, _, window, cx| {
 820                                                    window.dispatch_action(
 821                                                        Box::new(RemoveHistory),
 822                                                        cx,
 823                                                    );
 824                                                })),
 825                                        ),
 826                                )
 827                        }),
 828                )
 829            })
 830    }
 831}
 832
 833#[derive(IntoElement)]
 834pub struct AcpHistoryEntryElement {
 835    entry: AgentSessionInfo,
 836    thread_view: WeakEntity<AcpThreadView>,
 837    selected: bool,
 838    hovered: bool,
 839    supports_delete: bool,
 840    on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
 841}
 842
 843impl AcpHistoryEntryElement {
 844    pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity<AcpThreadView>) -> Self {
 845        Self {
 846            entry,
 847            thread_view,
 848            selected: false,
 849            hovered: false,
 850            supports_delete: false,
 851            on_hover: Box::new(|_, _, _| {}),
 852        }
 853    }
 854
 855    pub fn supports_delete(mut self, supports_delete: bool) -> Self {
 856        self.supports_delete = supports_delete;
 857        self
 858    }
 859
 860    pub fn hovered(mut self, hovered: bool) -> Self {
 861        self.hovered = hovered;
 862        self
 863    }
 864
 865    pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
 866        self.on_hover = Box::new(on_hover);
 867        self
 868    }
 869}
 870
 871impl RenderOnce for AcpHistoryEntryElement {
 872    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
 873        let id = ElementId::Name(self.entry.session_id.0.clone().into());
 874        let title = thread_title(&self.entry).clone();
 875        let formatted_time = self
 876            .entry
 877            .updated_at
 878            .map(|timestamp| {
 879                let now = chrono::Utc::now();
 880                let duration = now.signed_duration_since(timestamp);
 881
 882                if duration.num_days() > 0 {
 883                    format!("{}d", duration.num_days())
 884                } else if duration.num_hours() > 0 {
 885                    format!("{}h ago", duration.num_hours())
 886                } else if duration.num_minutes() > 0 {
 887                    format!("{}m ago", duration.num_minutes())
 888                } else {
 889                    "Just now".to_string()
 890                }
 891            })
 892            .unwrap_or_else(|| "Unknown".to_string());
 893
 894        ListItem::new(id)
 895            .rounded()
 896            .toggle_state(self.selected)
 897            .spacing(ListItemSpacing::Sparse)
 898            .start_slot(
 899                h_flex()
 900                    .w_full()
 901                    .gap_2()
 902                    .justify_between()
 903                    .child(Label::new(title).size(LabelSize::Small).truncate())
 904                    .child(
 905                        Label::new(formatted_time)
 906                            .color(Color::Muted)
 907                            .size(LabelSize::XSmall),
 908                    ),
 909            )
 910            .on_hover(self.on_hover)
 911            .end_slot::<IconButton>(if (self.hovered || self.selected) && self.supports_delete {
 912                Some(
 913                    IconButton::new("delete", IconName::Trash)
 914                        .shape(IconButtonShape::Square)
 915                        .icon_size(IconSize::XSmall)
 916                        .icon_color(Color::Muted)
 917                        .tooltip(move |_window, cx| {
 918                            Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
 919                        })
 920                        .on_click({
 921                            let thread_view = self.thread_view.clone();
 922                            let entry = self.entry.clone();
 923
 924                            move |_event, _window, cx| {
 925                                if let Some(thread_view) = thread_view.upgrade() {
 926                                    thread_view.update(cx, |thread_view, cx| {
 927                                        thread_view.delete_history_entry(entry.clone(), cx);
 928                                    });
 929                                }
 930                            }
 931                        }),
 932                )
 933            } else {
 934                None
 935            })
 936            .on_click({
 937                let thread_view = self.thread_view.clone();
 938                let entry = self.entry;
 939
 940                move |_event, window, cx| {
 941                    if let Some(workspace) = thread_view
 942                        .upgrade()
 943                        .and_then(|view| view.read(cx).workspace().upgrade())
 944                    {
 945                        if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
 946                            panel.update(cx, |panel, cx| {
 947                                panel.load_agent_thread(entry.clone(), window, cx);
 948                            });
 949                        }
 950                    }
 951                }
 952            })
 953    }
 954}
 955
 956#[derive(Clone, Copy)]
 957pub enum EntryTimeFormat {
 958    DateAndTime,
 959    TimeOnly,
 960}
 961
 962impl EntryTimeFormat {
 963    fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
 964        let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
 965
 966        match self {
 967            EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
 968                timestamp,
 969                OffsetDateTime::now_utc(),
 970                timezone,
 971                time_format::TimestampFormat::EnhancedAbsolute,
 972            ),
 973            EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
 974        }
 975    }
 976}
 977
 978impl From<TimeBucket> for EntryTimeFormat {
 979    fn from(bucket: TimeBucket) -> Self {
 980        match bucket {
 981            TimeBucket::Today => EntryTimeFormat::TimeOnly,
 982            TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
 983            TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
 984            TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
 985            TimeBucket::All => EntryTimeFormat::DateAndTime,
 986        }
 987    }
 988}
 989
 990#[derive(PartialEq, Eq, Clone, Copy, Debug)]
 991enum TimeBucket {
 992    Today,
 993    Yesterday,
 994    ThisWeek,
 995    PastWeek,
 996    All,
 997}
 998
 999impl TimeBucket {
1000    fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
1001        if date == reference {
1002            return TimeBucket::Today;
1003        }
1004
1005        if date == reference - TimeDelta::days(1) {
1006            return TimeBucket::Yesterday;
1007        }
1008
1009        let week = date.iso_week();
1010
1011        if reference.iso_week() == week {
1012            return TimeBucket::ThisWeek;
1013        }
1014
1015        let last_week = (reference - TimeDelta::days(7)).iso_week();
1016
1017        if week == last_week {
1018            return TimeBucket::PastWeek;
1019        }
1020
1021        TimeBucket::All
1022    }
1023}
1024
1025impl Display for TimeBucket {
1026    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1027        match self {
1028            TimeBucket::Today => write!(f, "Today"),
1029            TimeBucket::Yesterday => write!(f, "Yesterday"),
1030            TimeBucket::ThisWeek => write!(f, "This Week"),
1031            TimeBucket::PastWeek => write!(f, "Past Week"),
1032            TimeBucket::All => write!(f, "All"),
1033        }
1034    }
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039    use super::*;
1040    use acp_thread::AgentSessionListResponse;
1041    use chrono::NaiveDate;
1042    use gpui::TestAppContext;
1043    use std::any::Any;
1044
1045    fn init_test(cx: &mut TestAppContext) {
1046        cx.update(|cx| {
1047            let settings_store = settings::SettingsStore::test(cx);
1048            cx.set_global(settings_store);
1049            theme::init(theme::LoadThemes::JustBase, cx);
1050        });
1051    }
1052
1053    #[derive(Clone)]
1054    struct TestSessionList {
1055        sessions: Vec<AgentSessionInfo>,
1056        updates_tx: smol::channel::Sender<SessionListUpdate>,
1057        updates_rx: smol::channel::Receiver<SessionListUpdate>,
1058    }
1059
1060    impl TestSessionList {
1061        fn new(sessions: Vec<AgentSessionInfo>) -> Self {
1062            let (tx, rx) = smol::channel::unbounded();
1063            Self {
1064                sessions,
1065                updates_tx: tx,
1066                updates_rx: rx,
1067            }
1068        }
1069
1070        fn send_update(&self, update: SessionListUpdate) {
1071            self.updates_tx.try_send(update).ok();
1072        }
1073    }
1074
1075    impl AgentSessionList for TestSessionList {
1076        fn list_sessions(
1077            &self,
1078            _request: AgentSessionListRequest,
1079            _cx: &mut App,
1080        ) -> Task<anyhow::Result<AgentSessionListResponse>> {
1081            Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone())))
1082        }
1083
1084        fn watch(&self, _cx: &mut App) -> Option<smol::channel::Receiver<SessionListUpdate>> {
1085            Some(self.updates_rx.clone())
1086        }
1087
1088        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
1089            self
1090        }
1091    }
1092
1093    #[gpui::test]
1094    async fn test_apply_info_update_title(cx: &mut TestAppContext) {
1095        init_test(cx);
1096
1097        let session_id = acp::SessionId::new("test-session");
1098        let sessions = vec![AgentSessionInfo {
1099            session_id: session_id.clone(),
1100            cwd: None,
1101            title: Some("Original Title".into()),
1102            updated_at: None,
1103            meta: None,
1104        }];
1105        let session_list = Rc::new(TestSessionList::new(sessions));
1106
1107        let (history, cx) = cx.add_window_view(|window, cx| {
1108            AcpThreadHistory::new(Some(session_list.clone()), window, cx)
1109        });
1110        cx.run_until_parked();
1111
1112        // Send a title update
1113        session_list.send_update(SessionListUpdate::SessionInfo {
1114            session_id: session_id.clone(),
1115            update: acp::SessionInfoUpdate::new().title("New Title"),
1116        });
1117        cx.run_until_parked();
1118
1119        // Check that the title was updated
1120        history.update(cx, |history, _cx| {
1121            let session = history.sessions.iter().find(|s| s.session_id == session_id);
1122            assert_eq!(
1123                session.unwrap().title.as_ref().map(|s| s.as_ref()),
1124                Some("New Title")
1125            );
1126        });
1127    }
1128
1129    #[gpui::test]
1130    async fn test_apply_info_update_clears_title_with_null(cx: &mut TestAppContext) {
1131        init_test(cx);
1132
1133        let session_id = acp::SessionId::new("test-session");
1134        let sessions = vec![AgentSessionInfo {
1135            session_id: session_id.clone(),
1136            cwd: None,
1137            title: Some("Original Title".into()),
1138            updated_at: None,
1139            meta: None,
1140        }];
1141        let session_list = Rc::new(TestSessionList::new(sessions));
1142
1143        let (history, cx) = cx.add_window_view(|window, cx| {
1144            AcpThreadHistory::new(Some(session_list.clone()), window, cx)
1145        });
1146        cx.run_until_parked();
1147
1148        // Send an update that clears the title (null)
1149        session_list.send_update(SessionListUpdate::SessionInfo {
1150            session_id: session_id.clone(),
1151            update: acp::SessionInfoUpdate::new().title(None::<String>),
1152        });
1153        cx.run_until_parked();
1154
1155        // Check that the title was cleared
1156        history.update(cx, |history, _cx| {
1157            let session = history.sessions.iter().find(|s| s.session_id == session_id);
1158            assert_eq!(session.unwrap().title, None);
1159        });
1160    }
1161
1162    #[gpui::test]
1163    async fn test_apply_info_update_ignores_undefined_fields(cx: &mut TestAppContext) {
1164        init_test(cx);
1165
1166        let session_id = acp::SessionId::new("test-session");
1167        let sessions = vec![AgentSessionInfo {
1168            session_id: session_id.clone(),
1169            cwd: None,
1170            title: Some("Original Title".into()),
1171            updated_at: None,
1172            meta: None,
1173        }];
1174        let session_list = Rc::new(TestSessionList::new(sessions));
1175
1176        let (history, cx) = cx.add_window_view(|window, cx| {
1177            AcpThreadHistory::new(Some(session_list.clone()), window, cx)
1178        });
1179        cx.run_until_parked();
1180
1181        // Send an update with no fields set (all undefined)
1182        session_list.send_update(SessionListUpdate::SessionInfo {
1183            session_id: session_id.clone(),
1184            update: acp::SessionInfoUpdate::new(),
1185        });
1186        cx.run_until_parked();
1187
1188        // Check that the title is unchanged
1189        history.update(cx, |history, _cx| {
1190            let session = history.sessions.iter().find(|s| s.session_id == session_id);
1191            assert_eq!(
1192                session.unwrap().title.as_ref().map(|s| s.as_ref()),
1193                Some("Original Title")
1194            );
1195        });
1196    }
1197
1198    #[gpui::test]
1199    async fn test_multiple_info_updates_applied_in_order(cx: &mut TestAppContext) {
1200        init_test(cx);
1201
1202        let session_id = acp::SessionId::new("test-session");
1203        let sessions = vec![AgentSessionInfo {
1204            session_id: session_id.clone(),
1205            cwd: None,
1206            title: None,
1207            updated_at: None,
1208            meta: None,
1209        }];
1210        let session_list = Rc::new(TestSessionList::new(sessions));
1211
1212        let (history, cx) = cx.add_window_view(|window, cx| {
1213            AcpThreadHistory::new(Some(session_list.clone()), window, cx)
1214        });
1215        cx.run_until_parked();
1216
1217        // Send multiple updates before the executor runs
1218        session_list.send_update(SessionListUpdate::SessionInfo {
1219            session_id: session_id.clone(),
1220            update: acp::SessionInfoUpdate::new().title("First Title"),
1221        });
1222        session_list.send_update(SessionListUpdate::SessionInfo {
1223            session_id: session_id.clone(),
1224            update: acp::SessionInfoUpdate::new().title("Second Title"),
1225        });
1226        cx.run_until_parked();
1227
1228        // Check that the final title is "Second Title" (both applied in order)
1229        history.update(cx, |history, _cx| {
1230            let session = history.sessions.iter().find(|s| s.session_id == session_id);
1231            assert_eq!(
1232                session.unwrap().title.as_ref().map(|s| s.as_ref()),
1233                Some("Second Title")
1234            );
1235        });
1236    }
1237
1238    #[gpui::test]
1239    async fn test_refresh_supersedes_info_updates(cx: &mut TestAppContext) {
1240        init_test(cx);
1241
1242        let session_id = acp::SessionId::new("test-session");
1243        let sessions = vec![AgentSessionInfo {
1244            session_id: session_id.clone(),
1245            cwd: None,
1246            title: Some("Server Title".into()),
1247            updated_at: None,
1248            meta: None,
1249        }];
1250        let session_list = Rc::new(TestSessionList::new(sessions));
1251
1252        let (history, cx) = cx.add_window_view(|window, cx| {
1253            AcpThreadHistory::new(Some(session_list.clone()), window, cx)
1254        });
1255        cx.run_until_parked();
1256
1257        // Send an info update followed by a refresh
1258        session_list.send_update(SessionListUpdate::SessionInfo {
1259            session_id: session_id.clone(),
1260            update: acp::SessionInfoUpdate::new().title("Local Update"),
1261        });
1262        session_list.send_update(SessionListUpdate::Refresh);
1263        cx.run_until_parked();
1264
1265        // The refresh should have fetched from server, getting "Server Title"
1266        history.update(cx, |history, _cx| {
1267            let session = history.sessions.iter().find(|s| s.session_id == session_id);
1268            assert_eq!(
1269                session.unwrap().title.as_ref().map(|s| s.as_ref()),
1270                Some("Server Title")
1271            );
1272        });
1273    }
1274
1275    #[gpui::test]
1276    async fn test_info_update_for_unknown_session_is_ignored(cx: &mut TestAppContext) {
1277        init_test(cx);
1278
1279        let session_id = acp::SessionId::new("known-session");
1280        let sessions = vec![AgentSessionInfo {
1281            session_id,
1282            cwd: None,
1283            title: Some("Original".into()),
1284            updated_at: None,
1285            meta: None,
1286        }];
1287        let session_list = Rc::new(TestSessionList::new(sessions));
1288
1289        let (history, cx) = cx.add_window_view(|window, cx| {
1290            AcpThreadHistory::new(Some(session_list.clone()), window, cx)
1291        });
1292        cx.run_until_parked();
1293
1294        // Send an update for an unknown session
1295        session_list.send_update(SessionListUpdate::SessionInfo {
1296            session_id: acp::SessionId::new("unknown-session"),
1297            update: acp::SessionInfoUpdate::new().title("Should Be Ignored"),
1298        });
1299        cx.run_until_parked();
1300
1301        // Check that the known session is unchanged and no crash occurred
1302        history.update(cx, |history, _cx| {
1303            assert_eq!(history.sessions.len(), 1);
1304            assert_eq!(
1305                history.sessions[0].title.as_ref().map(|s| s.as_ref()),
1306                Some("Original")
1307            );
1308        });
1309    }
1310
1311    #[test]
1312    fn test_time_bucket_from_dates() {
1313        let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
1314
1315        let date = today;
1316        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
1317
1318        let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
1319        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
1320
1321        let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
1322        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
1323
1324        let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
1325        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
1326
1327        let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
1328        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
1329
1330        let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
1331        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
1332
1333        // All: not in this week or last week
1334        let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
1335        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
1336
1337        // Test year boundary cases
1338        let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
1339
1340        let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
1341        assert_eq!(
1342            TimeBucket::from_dates(new_year, date),
1343            TimeBucket::Yesterday
1344        );
1345
1346        let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
1347        assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
1348    }
1349}