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