thread_history.rs

   1use crate::ConnectionView;
   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 ThreadHistory {
  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    _visible_items_task: Task<()>,
  42    _refresh_task: Task<()>,
  43    _watch_task: Option<Task<()>>,
  44    _subscriptions: Vec<gpui::Subscription>,
  45}
  46
  47enum ListItemType {
  48    BucketSeparator(TimeBucket),
  49    Entry {
  50        entry: AgentSessionInfo,
  51        format: EntryTimeFormat,
  52    },
  53    SearchResult {
  54        entry: AgentSessionInfo,
  55        positions: Vec<usize>,
  56    },
  57}
  58
  59impl ListItemType {
  60    fn history_entry(&self) -> Option<&AgentSessionInfo> {
  61        match self {
  62            ListItemType::Entry { entry, .. } => Some(entry),
  63            ListItemType::SearchResult { entry, .. } => Some(entry),
  64            _ => None,
  65        }
  66    }
  67}
  68
  69pub enum ThreadHistoryEvent {
  70    Open(AgentSessionInfo),
  71}
  72
  73impl EventEmitter<ThreadHistoryEvent> for ThreadHistory {}
  74
  75impl ThreadHistory {
  76    pub fn new(
  77        session_list: Option<Rc<dyn AgentSessionList>>,
  78        window: &mut Window,
  79        cx: &mut Context<Self>,
  80    ) -> Self {
  81        let search_editor = cx.new(|cx| {
  82            let mut editor = Editor::single_line(window, cx);
  83            editor.set_placeholder_text("Search threads...", window, cx);
  84            editor
  85        });
  86
  87        let search_editor_subscription =
  88            cx.subscribe(&search_editor, |this, search_editor, event, cx| {
  89                if let EditorEvent::BufferEdited = event {
  90                    let query = search_editor.read(cx).text(cx);
  91                    if this.search_query != query {
  92                        this.search_query = query.into();
  93                        this.update_visible_items(false, cx);
  94                    }
  95                }
  96            });
  97
  98        let scroll_handle = UniformListScrollHandle::default();
  99
 100        let mut this = Self {
 101            session_list: None,
 102            sessions: Vec::new(),
 103            scroll_handle,
 104            selected_index: 0,
 105            hovered_index: None,
 106            visible_items: Default::default(),
 107            search_editor,
 108            local_timezone: UtcOffset::from_whole_seconds(
 109                chrono::Local::now().offset().local_minus_utc(),
 110            )
 111            .unwrap(),
 112            search_query: SharedString::default(),
 113            confirming_delete_history: false,
 114            _subscriptions: vec![search_editor_subscription],
 115            _visible_items_task: Task::ready(()),
 116            _refresh_task: Task::ready(()),
 117            _watch_task: None,
 118        };
 119        this.set_session_list(session_list, cx);
 120        this
 121    }
 122
 123    fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
 124        let entries = self.sessions.clone();
 125        let new_list_items = if self.search_query.is_empty() {
 126            self.add_list_separators(entries, cx)
 127        } else {
 128            self.filter_search_results(entries, cx)
 129        };
 130        let selected_history_entry = if preserve_selected_item {
 131            self.selected_history_entry().cloned()
 132        } else {
 133            None
 134        };
 135
 136        self._visible_items_task = cx.spawn(async move |this, cx| {
 137            let new_visible_items = new_list_items.await;
 138            this.update(cx, |this, cx| {
 139                let new_selected_index = if let Some(history_entry) = selected_history_entry {
 140                    new_visible_items
 141                        .iter()
 142                        .position(|visible_entry| {
 143                            visible_entry
 144                                .history_entry()
 145                                .is_some_and(|entry| entry.session_id == history_entry.session_id)
 146                        })
 147                        .unwrap_or(0)
 148                } else {
 149                    0
 150                };
 151
 152                this.visible_items = new_visible_items;
 153                this.set_selected_index(new_selected_index, Bias::Right, cx);
 154                cx.notify();
 155            })
 156            .ok();
 157        });
 158    }
 159
 160    pub fn set_session_list(
 161        &mut self,
 162        session_list: Option<Rc<dyn AgentSessionList>>,
 163        cx: &mut Context<Self>,
 164    ) {
 165        if let (Some(current), Some(next)) = (&self.session_list, &session_list)
 166            && Rc::ptr_eq(current, next)
 167        {
 168            return;
 169        }
 170
 171        self.session_list = session_list;
 172        self.sessions.clear();
 173        self.visible_items.clear();
 174        self.selected_index = 0;
 175        self._visible_items_task = Task::ready(());
 176        self._refresh_task = Task::ready(());
 177
 178        let Some(session_list) = self.session_list.as_ref() else {
 179            self._watch_task = None;
 180            cx.notify();
 181            return;
 182        };
 183        let Some(rx) = session_list.watch(cx) else {
 184            // No watch support - do a one-time refresh
 185            self._watch_task = None;
 186            self.refresh_sessions(false, false, cx);
 187            return;
 188        };
 189        session_list.notify_refresh();
 190
 191        self._watch_task = Some(cx.spawn(async move |this, cx| {
 192            while let Ok(first_update) = rx.recv().await {
 193                let mut updates = vec![first_update];
 194                // Collect any additional updates that are already in the channel
 195                while let Ok(update) = rx.try_recv() {
 196                    updates.push(update);
 197                }
 198
 199                this.update(cx, |this, cx| {
 200                    let needs_refresh = updates
 201                        .iter()
 202                        .any(|u| matches!(u, SessionListUpdate::Refresh));
 203
 204                    if needs_refresh {
 205                        this.refresh_sessions(true, false, cx);
 206                    } else {
 207                        for update in updates {
 208                            if let SessionListUpdate::SessionInfo { session_id, update } = update {
 209                                this.apply_info_update(session_id, update, cx);
 210                            }
 211                        }
 212                    }
 213                })
 214                .ok();
 215            }
 216        }));
 217    }
 218
 219    pub(crate) fn refresh_full_history(&mut self, cx: &mut Context<Self>) {
 220        self.refresh_sessions(true, true, cx);
 221    }
 222
 223    fn apply_info_update(
 224        &mut self,
 225        session_id: acp::SessionId,
 226        info_update: acp::SessionInfoUpdate,
 227        cx: &mut Context<Self>,
 228    ) {
 229        let Some(session) = self
 230            .sessions
 231            .iter_mut()
 232            .find(|s| s.session_id == session_id)
 233        else {
 234            return;
 235        };
 236
 237        match info_update.title {
 238            acp::MaybeUndefined::Value(title) => {
 239                session.title = Some(title.into());
 240            }
 241            acp::MaybeUndefined::Null => {
 242                session.title = None;
 243            }
 244            acp::MaybeUndefined::Undefined => {}
 245        }
 246        match info_update.updated_at {
 247            acp::MaybeUndefined::Value(date_str) => {
 248                if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&date_str) {
 249                    session.updated_at = Some(dt.with_timezone(&chrono::Utc));
 250                }
 251            }
 252            acp::MaybeUndefined::Null => {
 253                session.updated_at = None;
 254            }
 255            acp::MaybeUndefined::Undefined => {}
 256        }
 257        if let Some(meta) = info_update.meta {
 258            session.meta = Some(meta);
 259        }
 260
 261        self.update_visible_items(true, cx);
 262    }
 263
 264    fn refresh_sessions(
 265        &mut self,
 266        preserve_selected_item: bool,
 267        load_all_pages: bool,
 268        cx: &mut Context<Self>,
 269    ) {
 270        let Some(session_list) = self.session_list.clone() else {
 271            self.update_visible_items(preserve_selected_item, cx);
 272            return;
 273        };
 274
 275        // If a new refresh arrives while pagination is in progress, the previous
 276        // `_refresh_task` is cancelled. This is intentional (latest refresh wins),
 277        // but means sessions may be in a partial state until the new refresh completes.
 278        self._refresh_task = cx.spawn(async move |this, cx| {
 279            let mut cursor: Option<String> = None;
 280            let mut is_first_page = true;
 281
 282            loop {
 283                let request = AgentSessionListRequest {
 284                    cursor: cursor.clone(),
 285                    ..Default::default()
 286                };
 287                let task = cx.update(|cx| session_list.list_sessions(request, cx));
 288                let response = match task.await {
 289                    Ok(response) => response,
 290                    Err(error) => {
 291                        log::error!("Failed to load session history: {error:#}");
 292                        return;
 293                    }
 294                };
 295
 296                let acp_thread::AgentSessionListResponse {
 297                    sessions: page_sessions,
 298                    next_cursor,
 299                    ..
 300                } = response;
 301
 302                this.update(cx, |this, cx| {
 303                    if is_first_page {
 304                        this.sessions = page_sessions;
 305                    } else {
 306                        this.sessions.extend(page_sessions);
 307                    }
 308                    this.update_visible_items(preserve_selected_item, cx);
 309                })
 310                .ok();
 311
 312                is_first_page = false;
 313                if !load_all_pages {
 314                    break;
 315                }
 316
 317                match next_cursor {
 318                    Some(next_cursor) => {
 319                        if cursor.as_ref() == Some(&next_cursor) {
 320                            log::warn!(
 321                                "Session list pagination returned the same cursor; stopping to avoid a loop."
 322                            );
 323                            break;
 324                        }
 325                        cursor = Some(next_cursor);
 326                    }
 327                    None => break,
 328                }
 329            }
 330        });
 331    }
 332
 333    pub(crate) fn is_empty(&self) -> bool {
 334        self.sessions.is_empty()
 335    }
 336
 337    pub fn has_session_list(&self) -> bool {
 338        self.session_list.is_some()
 339    }
 340
 341    pub fn refresh(&mut self, _cx: &mut Context<Self>) {
 342        if let Some(session_list) = &self.session_list {
 343            session_list.notify_refresh();
 344        }
 345    }
 346
 347    pub fn session_for_id(&self, session_id: &acp::SessionId) -> Option<AgentSessionInfo> {
 348        self.sessions
 349            .iter()
 350            .find(|entry| &entry.session_id == session_id)
 351            .cloned()
 352    }
 353
 354    pub(crate) fn sessions(&self) -> &[AgentSessionInfo] {
 355        &self.sessions
 356    }
 357
 358    pub(crate) fn get_recent_sessions(&self, limit: usize) -> Vec<AgentSessionInfo> {
 359        self.sessions.iter().take(limit).cloned().collect()
 360    }
 361
 362    pub fn supports_delete(&self) -> bool {
 363        self.session_list
 364            .as_ref()
 365            .map(|sl| sl.supports_delete())
 366            .unwrap_or(false)
 367    }
 368
 369    pub(crate) fn delete_session(
 370        &self,
 371        session_id: &acp::SessionId,
 372        cx: &mut App,
 373    ) -> Task<anyhow::Result<()>> {
 374        if let Some(session_list) = self.session_list.as_ref() {
 375            session_list.delete_session(session_id, cx)
 376        } else {
 377            Task::ready(Ok(()))
 378        }
 379    }
 380
 381    fn add_list_separators(
 382        &self,
 383        entries: Vec<AgentSessionInfo>,
 384        cx: &App,
 385    ) -> Task<Vec<ListItemType>> {
 386        cx.background_spawn(async move {
 387            let mut items = Vec::with_capacity(entries.len() + 1);
 388            let mut bucket = None;
 389            let today = Local::now().naive_local().date();
 390
 391            for entry in entries.into_iter() {
 392                let entry_bucket = entry
 393                    .updated_at
 394                    .map(|timestamp| {
 395                        let entry_date = timestamp.with_timezone(&Local).naive_local().date();
 396                        TimeBucket::from_dates(today, entry_date)
 397                    })
 398                    .unwrap_or(TimeBucket::All);
 399
 400                if Some(entry_bucket) != bucket {
 401                    bucket = Some(entry_bucket);
 402                    items.push(ListItemType::BucketSeparator(entry_bucket));
 403                }
 404
 405                items.push(ListItemType::Entry {
 406                    entry,
 407                    format: entry_bucket.into(),
 408                });
 409            }
 410            items
 411        })
 412    }
 413
 414    fn filter_search_results(
 415        &self,
 416        entries: Vec<AgentSessionInfo>,
 417        cx: &App,
 418    ) -> Task<Vec<ListItemType>> {
 419        let query = self.search_query.clone();
 420        cx.background_spawn({
 421            let executor = cx.background_executor().clone();
 422            async move {
 423                let mut candidates = Vec::with_capacity(entries.len());
 424
 425                for (idx, entry) in entries.iter().enumerate() {
 426                    candidates.push(StringMatchCandidate::new(idx, thread_title(entry)));
 427                }
 428
 429                const MAX_MATCHES: usize = 100;
 430
 431                let matches = fuzzy::match_strings(
 432                    &candidates,
 433                    &query,
 434                    false,
 435                    true,
 436                    MAX_MATCHES,
 437                    &Default::default(),
 438                    executor,
 439                )
 440                .await;
 441
 442                matches
 443                    .into_iter()
 444                    .map(|search_match| ListItemType::SearchResult {
 445                        entry: entries[search_match.candidate_id].clone(),
 446                        positions: search_match.positions,
 447                    })
 448                    .collect()
 449            }
 450        })
 451    }
 452
 453    fn search_produced_no_matches(&self) -> bool {
 454        self.visible_items.is_empty() && !self.search_query.is_empty()
 455    }
 456
 457    fn selected_history_entry(&self) -> Option<&AgentSessionInfo> {
 458        self.get_history_entry(self.selected_index)
 459    }
 460
 461    fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> {
 462        self.visible_items.get(visible_items_ix)?.history_entry()
 463    }
 464
 465    fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context<Self>) {
 466        if self.visible_items.len() == 0 {
 467            self.selected_index = 0;
 468            return;
 469        }
 470        while matches!(
 471            self.visible_items.get(index),
 472            None | Some(ListItemType::BucketSeparator(..))
 473        ) {
 474            index = match bias {
 475                Bias::Left => {
 476                    if index == 0 {
 477                        self.visible_items.len() - 1
 478                    } else {
 479                        index - 1
 480                    }
 481                }
 482                Bias::Right => {
 483                    if index >= self.visible_items.len() - 1 {
 484                        0
 485                    } else {
 486                        index + 1
 487                    }
 488                }
 489            };
 490        }
 491        self.selected_index = index;
 492        self.scroll_handle
 493            .scroll_to_item(index, ScrollStrategy::Top);
 494        cx.notify()
 495    }
 496
 497    pub fn select_previous(
 498        &mut self,
 499        _: &menu::SelectPrevious,
 500        _window: &mut Window,
 501        cx: &mut Context<Self>,
 502    ) {
 503        if self.selected_index == 0 {
 504            self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
 505        } else {
 506            self.set_selected_index(self.selected_index - 1, Bias::Left, cx);
 507        }
 508    }
 509
 510    pub fn select_next(
 511        &mut self,
 512        _: &menu::SelectNext,
 513        _window: &mut Window,
 514        cx: &mut Context<Self>,
 515    ) {
 516        if self.selected_index == self.visible_items.len() - 1 {
 517            self.set_selected_index(0, Bias::Right, cx);
 518        } else {
 519            self.set_selected_index(self.selected_index + 1, Bias::Right, cx);
 520        }
 521    }
 522
 523    fn select_first(
 524        &mut self,
 525        _: &menu::SelectFirst,
 526        _window: &mut Window,
 527        cx: &mut Context<Self>,
 528    ) {
 529        self.set_selected_index(0, Bias::Right, cx);
 530    }
 531
 532    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
 533        self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
 534    }
 535
 536    fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
 537        self.confirm_entry(self.selected_index, cx);
 538    }
 539
 540    fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
 541        let Some(entry) = self.get_history_entry(ix) else {
 542            return;
 543        };
 544        cx.emit(ThreadHistoryEvent::Open(entry.clone()));
 545    }
 546
 547    fn remove_selected_thread(
 548        &mut self,
 549        _: &RemoveSelectedThread,
 550        _window: &mut Window,
 551        cx: &mut Context<Self>,
 552    ) {
 553        self.remove_thread(self.selected_index, cx)
 554    }
 555
 556    fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
 557        let Some(entry) = self.get_history_entry(visible_item_ix) else {
 558            return;
 559        };
 560        let Some(session_list) = self.session_list.as_ref() else {
 561            return;
 562        };
 563        if !session_list.supports_delete() {
 564            return;
 565        }
 566        let task = session_list.delete_session(&entry.session_id, cx);
 567        task.detach_and_log_err(cx);
 568    }
 569
 570    fn remove_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
 571        let Some(session_list) = self.session_list.as_ref() else {
 572            return;
 573        };
 574        if !session_list.supports_delete() {
 575            return;
 576        }
 577        session_list.delete_sessions(cx).detach_and_log_err(cx);
 578        self.confirming_delete_history = false;
 579        cx.notify();
 580    }
 581
 582    fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
 583        self.confirming_delete_history = true;
 584        cx.notify();
 585    }
 586
 587    fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
 588        self.confirming_delete_history = false;
 589        cx.notify();
 590    }
 591
 592    fn render_list_items(
 593        &mut self,
 594        range: Range<usize>,
 595        _window: &mut Window,
 596        cx: &mut Context<Self>,
 597    ) -> Vec<AnyElement> {
 598        self.visible_items
 599            .get(range.clone())
 600            .into_iter()
 601            .flatten()
 602            .enumerate()
 603            .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx))
 604            .collect()
 605    }
 606
 607    fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context<Self>) -> AnyElement {
 608        match item {
 609            ListItemType::Entry { entry, format } => self
 610                .render_history_entry(entry, *format, ix, Vec::default(), cx)
 611                .into_any(),
 612            ListItemType::SearchResult { entry, positions } => self.render_history_entry(
 613                entry,
 614                EntryTimeFormat::DateAndTime,
 615                ix,
 616                positions.clone(),
 617                cx,
 618            ),
 619            ListItemType::BucketSeparator(bucket) => div()
 620                .px(DynamicSpacing::Base06.rems(cx))
 621                .pt_2()
 622                .pb_1()
 623                .child(
 624                    Label::new(bucket.to_string())
 625                        .size(LabelSize::XSmall)
 626                        .color(Color::Muted),
 627                )
 628                .into_any_element(),
 629        }
 630    }
 631
 632    fn render_history_entry(
 633        &self,
 634        entry: &AgentSessionInfo,
 635        format: EntryTimeFormat,
 636        ix: usize,
 637        highlight_positions: Vec<usize>,
 638        cx: &Context<Self>,
 639    ) -> AnyElement {
 640        let selected = ix == self.selected_index;
 641        let hovered = Some(ix) == self.hovered_index;
 642        let entry_time = entry.updated_at;
 643        let display_text = match (format, entry_time) {
 644            (EntryTimeFormat::DateAndTime, Some(entry_time)) => {
 645                let now = Utc::now();
 646                let duration = now.signed_duration_since(entry_time);
 647                let days = duration.num_days();
 648
 649                format!("{}d", days)
 650            }
 651            (EntryTimeFormat::TimeOnly, Some(entry_time)) => {
 652                format.format_timestamp(entry_time.timestamp(), self.local_timezone)
 653            }
 654            (_, None) => "".to_string(),
 655        };
 656
 657        let title = thread_title(entry).clone();
 658        let full_date = entry_time
 659            .map(|time| {
 660                EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone)
 661            })
 662            .unwrap_or_else(|| "Unknown".to_string());
 663
 664        h_flex()
 665            .w_full()
 666            .pb_1()
 667            .child(
 668                ListItem::new(ix)
 669                    .rounded()
 670                    .toggle_state(selected)
 671                    .spacing(ListItemSpacing::Sparse)
 672                    .start_slot(
 673                        h_flex()
 674                            .w_full()
 675                            .gap_2()
 676                            .justify_between()
 677                            .child(
 678                                HighlightedLabel::new(thread_title(entry), highlight_positions)
 679                                    .size(LabelSize::Small)
 680                                    .truncate(),
 681                            )
 682                            .child(
 683                                Label::new(display_text)
 684                                    .color(Color::Muted)
 685                                    .size(LabelSize::XSmall),
 686                            ),
 687                    )
 688                    .tooltip(move |_, cx| {
 689                        Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
 690                    })
 691                    .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
 692                        if *is_hovered {
 693                            this.hovered_index = Some(ix);
 694                        } else if this.hovered_index == Some(ix) {
 695                            this.hovered_index = None;
 696                        }
 697
 698                        cx.notify();
 699                    }))
 700                    .end_slot::<IconButton>(if hovered && self.supports_delete() {
 701                        Some(
 702                            IconButton::new("delete", IconName::Trash)
 703                                .shape(IconButtonShape::Square)
 704                                .icon_size(IconSize::XSmall)
 705                                .icon_color(Color::Muted)
 706                                .tooltip(move |_window, cx| {
 707                                    Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
 708                                })
 709                                .on_click(cx.listener(move |this, _, _, cx| {
 710                                    this.remove_thread(ix, cx);
 711                                    cx.stop_propagation()
 712                                })),
 713                        )
 714                    } else {
 715                        None
 716                    })
 717                    .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
 718            )
 719            .into_any_element()
 720    }
 721}
 722
 723impl Focusable for ThreadHistory {
 724    fn focus_handle(&self, cx: &App) -> FocusHandle {
 725        self.search_editor.focus_handle(cx)
 726    }
 727}
 728
 729impl Render for ThreadHistory {
 730    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 731        let has_no_history = self.is_empty();
 732
 733        v_flex()
 734            .key_context("ThreadHistory")
 735            .size_full()
 736            .bg(cx.theme().colors().panel_background)
 737            .on_action(cx.listener(Self::select_previous))
 738            .on_action(cx.listener(Self::select_next))
 739            .on_action(cx.listener(Self::select_first))
 740            .on_action(cx.listener(Self::select_last))
 741            .on_action(cx.listener(Self::confirm))
 742            .on_action(cx.listener(Self::remove_selected_thread))
 743            .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| {
 744                this.remove_history(window, cx);
 745            }))
 746            .child(
 747                h_flex()
 748                    .h(Tab::container_height(cx))
 749                    .w_full()
 750                    .py_1()
 751                    .px_2()
 752                    .gap_2()
 753                    .justify_between()
 754                    .border_b_1()
 755                    .border_color(cx.theme().colors().border)
 756                    .child(
 757                        Icon::new(IconName::MagnifyingGlass)
 758                            .color(Color::Muted)
 759                            .size(IconSize::Small),
 760                    )
 761                    .child(self.search_editor.clone()),
 762            )
 763            .child({
 764                let view = v_flex()
 765                    .id("list-container")
 766                    .relative()
 767                    .overflow_hidden()
 768                    .flex_grow();
 769
 770                if has_no_history {
 771                    view.justify_center().items_center().child(
 772                        Label::new("You don't have any past threads yet.")
 773                            .size(LabelSize::Small)
 774                            .color(Color::Muted),
 775                    )
 776                } else if self.search_produced_no_matches() {
 777                    view.justify_center()
 778                        .items_center()
 779                        .child(Label::new("No threads match your search.").size(LabelSize::Small))
 780                } else {
 781                    view.child(
 782                        uniform_list(
 783                            "thread-history",
 784                            self.visible_items.len(),
 785                            cx.processor(|this, range: Range<usize>, window, cx| {
 786                                this.render_list_items(range, window, cx)
 787                            }),
 788                        )
 789                        .p_1()
 790                        .pr_4()
 791                        .track_scroll(&self.scroll_handle)
 792                        .flex_grow(),
 793                    )
 794                    .vertical_scrollbar_for(&self.scroll_handle, window, cx)
 795                }
 796            })
 797            .when(!has_no_history && self.supports_delete(), |this| {
 798                this.child(
 799                    h_flex()
 800                        .p_2()
 801                        .border_t_1()
 802                        .border_color(cx.theme().colors().border_variant)
 803                        .when(!self.confirming_delete_history, |this| {
 804                            this.child(
 805                                Button::new("delete_history", "Delete All History")
 806                                    .full_width()
 807                                    .style(ButtonStyle::Outlined)
 808                                    .label_size(LabelSize::Small)
 809                                    .on_click(cx.listener(|this, _, window, cx| {
 810                                        this.prompt_delete_history(window, cx);
 811                                    })),
 812                            )
 813                        })
 814                        .when(self.confirming_delete_history, |this| {
 815                            this.w_full()
 816                                .gap_2()
 817                                .flex_wrap()
 818                                .justify_between()
 819                                .child(
 820                                    h_flex()
 821                                        .flex_wrap()
 822                                        .gap_1()
 823                                        .child(
 824                                            Label::new("Delete all threads?")
 825                                                .size(LabelSize::Small),
 826                                        )
 827                                        .child(
 828                                            Label::new("You won't be able to recover them later.")
 829                                                .size(LabelSize::Small)
 830                                                .color(Color::Muted),
 831                                        ),
 832                                )
 833                                .child(
 834                                    h_flex()
 835                                        .gap_1()
 836                                        .child(
 837                                            Button::new("cancel_delete", "Cancel")
 838                                                .label_size(LabelSize::Small)
 839                                                .on_click(cx.listener(|this, _, window, cx| {
 840                                                    this.cancel_delete_history(window, cx);
 841                                                })),
 842                                        )
 843                                        .child(
 844                                            Button::new("confirm_delete", "Delete")
 845                                                .style(ButtonStyle::Tinted(ui::TintColor::Error))
 846                                                .color(Color::Error)
 847                                                .label_size(LabelSize::Small)
 848                                                .on_click(cx.listener(|_, _, window, cx| {
 849                                                    window.dispatch_action(
 850                                                        Box::new(RemoveHistory),
 851                                                        cx,
 852                                                    );
 853                                                })),
 854                                        ),
 855                                )
 856                        }),
 857                )
 858            })
 859    }
 860}
 861
 862#[derive(IntoElement)]
 863pub struct HistoryEntryElement {
 864    entry: AgentSessionInfo,
 865    thread_view: WeakEntity<ConnectionView>,
 866    selected: bool,
 867    hovered: bool,
 868    supports_delete: bool,
 869    on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
 870}
 871
 872impl HistoryEntryElement {
 873    pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity<ConnectionView>) -> Self {
 874        Self {
 875            entry,
 876            thread_view,
 877            selected: false,
 878            hovered: false,
 879            supports_delete: false,
 880            on_hover: Box::new(|_, _, _| {}),
 881        }
 882    }
 883
 884    pub fn supports_delete(mut self, supports_delete: bool) -> Self {
 885        self.supports_delete = supports_delete;
 886        self
 887    }
 888
 889    pub fn hovered(mut self, hovered: bool) -> Self {
 890        self.hovered = hovered;
 891        self
 892    }
 893
 894    pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
 895        self.on_hover = Box::new(on_hover);
 896        self
 897    }
 898}
 899
 900impl RenderOnce for HistoryEntryElement {
 901    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
 902        let id = ElementId::Name(self.entry.session_id.0.clone().into());
 903        let title = thread_title(&self.entry).clone();
 904        let formatted_time = self
 905            .entry
 906            .updated_at
 907            .map(|timestamp| {
 908                let now = chrono::Utc::now();
 909                let duration = now.signed_duration_since(timestamp);
 910
 911                if duration.num_days() > 0 {
 912                    format!("{}d", duration.num_days())
 913                } else if duration.num_hours() > 0 {
 914                    format!("{}h ago", duration.num_hours())
 915                } else if duration.num_minutes() > 0 {
 916                    format!("{}m ago", duration.num_minutes())
 917                } else {
 918                    "Just now".to_string()
 919                }
 920            })
 921            .unwrap_or_else(|| "Unknown".to_string());
 922
 923        ListItem::new(id)
 924            .rounded()
 925            .toggle_state(self.selected)
 926            .spacing(ListItemSpacing::Sparse)
 927            .start_slot(
 928                h_flex()
 929                    .w_full()
 930                    .gap_2()
 931                    .justify_between()
 932                    .child(Label::new(title).size(LabelSize::Small).truncate())
 933                    .child(
 934                        Label::new(formatted_time)
 935                            .color(Color::Muted)
 936                            .size(LabelSize::XSmall),
 937                    ),
 938            )
 939            .on_hover(self.on_hover)
 940            .end_slot::<IconButton>(if (self.hovered || self.selected) && self.supports_delete {
 941                Some(
 942                    IconButton::new("delete", IconName::Trash)
 943                        .shape(IconButtonShape::Square)
 944                        .icon_size(IconSize::XSmall)
 945                        .icon_color(Color::Muted)
 946                        .tooltip(move |_window, cx| {
 947                            Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
 948                        })
 949                        .on_click({
 950                            let thread_view = self.thread_view.clone();
 951                            let session_id = self.entry.session_id.clone();
 952
 953                            move |_event, _window, cx| {
 954                                if let Some(thread_view) = thread_view.upgrade() {
 955                                    thread_view.update(cx, |thread_view, cx| {
 956                                        thread_view.delete_history_entry(&session_id, cx);
 957                                    });
 958                                }
 959                            }
 960                        }),
 961                )
 962            } else {
 963                None
 964            })
 965            .on_click({
 966                let thread_view = self.thread_view.clone();
 967                let entry = self.entry;
 968
 969                move |_event, window, cx| {
 970                    if let Some(workspace) = thread_view
 971                        .upgrade()
 972                        .and_then(|view| view.read(cx).workspace().upgrade())
 973                    {
 974                        if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
 975                            panel.update(cx, |panel, cx| {
 976                                panel.load_agent_thread(
 977                                    entry.session_id.clone(),
 978                                    entry.cwd.clone(),
 979                                    entry.title.clone(),
 980                                    window,
 981                                    cx,
 982                                );
 983                            });
 984                        }
 985                    }
 986                }
 987            })
 988    }
 989}
 990
 991#[derive(Clone, Copy)]
 992pub enum EntryTimeFormat {
 993    DateAndTime,
 994    TimeOnly,
 995}
 996
 997impl EntryTimeFormat {
 998    fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
 999        let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
1000
1001        match self {
1002            EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
1003                timestamp,
1004                OffsetDateTime::now_utc(),
1005                timezone,
1006                time_format::TimestampFormat::EnhancedAbsolute,
1007            ),
1008            EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
1009        }
1010    }
1011}
1012
1013impl From<TimeBucket> for EntryTimeFormat {
1014    fn from(bucket: TimeBucket) -> Self {
1015        match bucket {
1016            TimeBucket::Today => EntryTimeFormat::TimeOnly,
1017            TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
1018            TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
1019            TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
1020            TimeBucket::All => EntryTimeFormat::DateAndTime,
1021        }
1022    }
1023}
1024
1025#[derive(PartialEq, Eq, Clone, Copy, Debug)]
1026enum TimeBucket {
1027    Today,
1028    Yesterday,
1029    ThisWeek,
1030    PastWeek,
1031    All,
1032}
1033
1034impl TimeBucket {
1035    fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
1036        if date == reference {
1037            return TimeBucket::Today;
1038        }
1039
1040        if date == reference - TimeDelta::days(1) {
1041            return TimeBucket::Yesterday;
1042        }
1043
1044        let week = date.iso_week();
1045
1046        if reference.iso_week() == week {
1047            return TimeBucket::ThisWeek;
1048        }
1049
1050        let last_week = (reference - TimeDelta::days(7)).iso_week();
1051
1052        if week == last_week {
1053            return TimeBucket::PastWeek;
1054        }
1055
1056        TimeBucket::All
1057    }
1058}
1059
1060impl Display for TimeBucket {
1061    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1062        match self {
1063            TimeBucket::Today => write!(f, "Today"),
1064            TimeBucket::Yesterday => write!(f, "Yesterday"),
1065            TimeBucket::ThisWeek => write!(f, "This Week"),
1066            TimeBucket::PastWeek => write!(f, "Past Week"),
1067            TimeBucket::All => write!(f, "All"),
1068        }
1069    }
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074    use super::*;
1075    use acp_thread::AgentSessionListResponse;
1076    use chrono::NaiveDate;
1077    use gpui::TestAppContext;
1078    use std::{
1079        any::Any,
1080        sync::{Arc, Mutex},
1081    };
1082
1083    fn init_test(cx: &mut TestAppContext) {
1084        cx.update(|cx| {
1085            let settings_store = settings::SettingsStore::test(cx);
1086            cx.set_global(settings_store);
1087            theme::init(theme::LoadThemes::JustBase, cx);
1088        });
1089    }
1090
1091    #[derive(Clone)]
1092    struct TestSessionList {
1093        sessions: Vec<AgentSessionInfo>,
1094        updates_tx: smol::channel::Sender<SessionListUpdate>,
1095        updates_rx: smol::channel::Receiver<SessionListUpdate>,
1096    }
1097
1098    impl TestSessionList {
1099        fn new(sessions: Vec<AgentSessionInfo>) -> Self {
1100            let (tx, rx) = smol::channel::unbounded();
1101            Self {
1102                sessions,
1103                updates_tx: tx,
1104                updates_rx: rx,
1105            }
1106        }
1107
1108        fn send_update(&self, update: SessionListUpdate) {
1109            self.updates_tx.try_send(update).ok();
1110        }
1111    }
1112
1113    impl AgentSessionList for TestSessionList {
1114        fn list_sessions(
1115            &self,
1116            _request: AgentSessionListRequest,
1117            _cx: &mut App,
1118        ) -> Task<anyhow::Result<AgentSessionListResponse>> {
1119            Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone())))
1120        }
1121
1122        fn watch(&self, _cx: &mut App) -> Option<smol::channel::Receiver<SessionListUpdate>> {
1123            Some(self.updates_rx.clone())
1124        }
1125
1126        fn notify_refresh(&self) {
1127            self.send_update(SessionListUpdate::Refresh);
1128        }
1129
1130        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
1131            self
1132        }
1133    }
1134
1135    #[derive(Clone)]
1136    struct PaginatedTestSessionList {
1137        first_page_sessions: Vec<AgentSessionInfo>,
1138        second_page_sessions: Vec<AgentSessionInfo>,
1139        requested_cursors: Arc<Mutex<Vec<Option<String>>>>,
1140        async_responses: bool,
1141        updates_tx: smol::channel::Sender<SessionListUpdate>,
1142        updates_rx: smol::channel::Receiver<SessionListUpdate>,
1143    }
1144
1145    impl PaginatedTestSessionList {
1146        fn new(
1147            first_page_sessions: Vec<AgentSessionInfo>,
1148            second_page_sessions: Vec<AgentSessionInfo>,
1149        ) -> Self {
1150            let (tx, rx) = smol::channel::unbounded();
1151            Self {
1152                first_page_sessions,
1153                second_page_sessions,
1154                requested_cursors: Arc::new(Mutex::new(Vec::new())),
1155                async_responses: false,
1156                updates_tx: tx,
1157                updates_rx: rx,
1158            }
1159        }
1160
1161        fn with_async_responses(mut self) -> Self {
1162            self.async_responses = true;
1163            self
1164        }
1165
1166        fn requested_cursors(&self) -> Vec<Option<String>> {
1167            self.requested_cursors.lock().unwrap().clone()
1168        }
1169
1170        fn clear_requested_cursors(&self) {
1171            self.requested_cursors.lock().unwrap().clear()
1172        }
1173
1174        fn send_update(&self, update: SessionListUpdate) {
1175            self.updates_tx.try_send(update).ok();
1176        }
1177    }
1178
1179    impl AgentSessionList for PaginatedTestSessionList {
1180        fn list_sessions(
1181            &self,
1182            request: AgentSessionListRequest,
1183            cx: &mut App,
1184        ) -> Task<anyhow::Result<AgentSessionListResponse>> {
1185            let requested_cursors = self.requested_cursors.clone();
1186            let first_page_sessions = self.first_page_sessions.clone();
1187            let second_page_sessions = self.second_page_sessions.clone();
1188
1189            let respond = move || {
1190                requested_cursors
1191                    .lock()
1192                    .unwrap()
1193                    .push(request.cursor.clone());
1194
1195                match request.cursor.as_deref() {
1196                    None => AgentSessionListResponse {
1197                        sessions: first_page_sessions,
1198                        next_cursor: Some("page-2".to_string()),
1199                        meta: None,
1200                    },
1201                    Some("page-2") => AgentSessionListResponse::new(second_page_sessions),
1202                    _ => AgentSessionListResponse::new(Vec::new()),
1203                }
1204            };
1205
1206            if self.async_responses {
1207                cx.foreground_executor().spawn(async move {
1208                    smol::future::yield_now().await;
1209                    Ok(respond())
1210                })
1211            } else {
1212                Task::ready(Ok(respond()))
1213            }
1214        }
1215
1216        fn watch(&self, _cx: &mut App) -> Option<smol::channel::Receiver<SessionListUpdate>> {
1217            Some(self.updates_rx.clone())
1218        }
1219
1220        fn notify_refresh(&self) {
1221            self.send_update(SessionListUpdate::Refresh);
1222        }
1223
1224        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
1225            self
1226        }
1227    }
1228
1229    fn test_session(session_id: &str, title: &str) -> AgentSessionInfo {
1230        AgentSessionInfo {
1231            session_id: acp::SessionId::new(session_id),
1232            cwd: None,
1233            title: Some(title.to_string().into()),
1234            updated_at: None,
1235            meta: None,
1236        }
1237    }
1238
1239    #[gpui::test]
1240    async fn test_refresh_only_loads_first_page_by_default(cx: &mut TestAppContext) {
1241        init_test(cx);
1242
1243        let session_list = Rc::new(PaginatedTestSessionList::new(
1244            vec![test_session("session-1", "First")],
1245            vec![test_session("session-2", "Second")],
1246        ));
1247
1248        let (history, cx) = cx.add_window_view(|window, cx| {
1249            ThreadHistory::new(Some(session_list.clone()), window, cx)
1250        });
1251        cx.run_until_parked();
1252
1253        history.update(cx, |history, _cx| {
1254            assert_eq!(history.sessions.len(), 1);
1255            assert_eq!(
1256                history.sessions[0].session_id,
1257                acp::SessionId::new("session-1")
1258            );
1259        });
1260        assert_eq!(session_list.requested_cursors(), vec![None]);
1261    }
1262
1263    #[gpui::test]
1264    async fn test_enabling_full_pagination_loads_all_pages(cx: &mut TestAppContext) {
1265        init_test(cx);
1266
1267        let session_list = Rc::new(PaginatedTestSessionList::new(
1268            vec![test_session("session-1", "First")],
1269            vec![test_session("session-2", "Second")],
1270        ));
1271
1272        let (history, cx) = cx.add_window_view(|window, cx| {
1273            ThreadHistory::new(Some(session_list.clone()), window, cx)
1274        });
1275        cx.run_until_parked();
1276        session_list.clear_requested_cursors();
1277
1278        history.update(cx, |history, cx| history.refresh_full_history(cx));
1279        cx.run_until_parked();
1280
1281        history.update(cx, |history, _cx| {
1282            assert_eq!(history.sessions.len(), 2);
1283            assert_eq!(
1284                history.sessions[0].session_id,
1285                acp::SessionId::new("session-1")
1286            );
1287            assert_eq!(
1288                history.sessions[1].session_id,
1289                acp::SessionId::new("session-2")
1290            );
1291        });
1292        assert_eq!(
1293            session_list.requested_cursors(),
1294            vec![None, Some("page-2".to_string())]
1295        );
1296    }
1297
1298    #[gpui::test]
1299    async fn test_standard_refresh_replaces_with_first_page_after_full_history_refresh(
1300        cx: &mut TestAppContext,
1301    ) {
1302        init_test(cx);
1303
1304        let session_list = Rc::new(PaginatedTestSessionList::new(
1305            vec![test_session("session-1", "First")],
1306            vec![test_session("session-2", "Second")],
1307        ));
1308
1309        let (history, cx) = cx.add_window_view(|window, cx| {
1310            ThreadHistory::new(Some(session_list.clone()), window, cx)
1311        });
1312        cx.run_until_parked();
1313
1314        history.update(cx, |history, cx| history.refresh_full_history(cx));
1315        cx.run_until_parked();
1316        session_list.clear_requested_cursors();
1317
1318        history.update(cx, |history, cx| {
1319            history.refresh(cx);
1320        });
1321        cx.run_until_parked();
1322
1323        history.update(cx, |history, _cx| {
1324            assert_eq!(history.sessions.len(), 1);
1325            assert_eq!(
1326                history.sessions[0].session_id,
1327                acp::SessionId::new("session-1")
1328            );
1329        });
1330        assert_eq!(session_list.requested_cursors(), vec![None]);
1331    }
1332
1333    #[gpui::test]
1334    async fn test_re_entering_full_pagination_reloads_all_pages(cx: &mut TestAppContext) {
1335        init_test(cx);
1336
1337        let session_list = Rc::new(PaginatedTestSessionList::new(
1338            vec![test_session("session-1", "First")],
1339            vec![test_session("session-2", "Second")],
1340        ));
1341
1342        let (history, cx) = cx.add_window_view(|window, cx| {
1343            ThreadHistory::new(Some(session_list.clone()), window, cx)
1344        });
1345        cx.run_until_parked();
1346
1347        history.update(cx, |history, cx| history.refresh_full_history(cx));
1348        cx.run_until_parked();
1349        session_list.clear_requested_cursors();
1350
1351        history.update(cx, |history, cx| history.refresh_full_history(cx));
1352        cx.run_until_parked();
1353
1354        history.update(cx, |history, _cx| {
1355            assert_eq!(history.sessions.len(), 2);
1356        });
1357        assert_eq!(
1358            session_list.requested_cursors(),
1359            vec![None, Some("page-2".to_string())]
1360        );
1361    }
1362
1363    #[gpui::test]
1364    async fn test_partial_refresh_batch_drops_non_first_page_sessions(cx: &mut TestAppContext) {
1365        init_test(cx);
1366
1367        let second_page_session_id = acp::SessionId::new("session-2");
1368        let session_list = Rc::new(PaginatedTestSessionList::new(
1369            vec![test_session("session-1", "First")],
1370            vec![test_session("session-2", "Second")],
1371        ));
1372
1373        let (history, cx) = cx.add_window_view(|window, cx| {
1374            ThreadHistory::new(Some(session_list.clone()), window, cx)
1375        });
1376        cx.run_until_parked();
1377
1378        history.update(cx, |history, cx| history.refresh_full_history(cx));
1379        cx.run_until_parked();
1380
1381        session_list.clear_requested_cursors();
1382
1383        session_list.send_update(SessionListUpdate::SessionInfo {
1384            session_id: second_page_session_id.clone(),
1385            update: acp::SessionInfoUpdate::new().title("Updated Second"),
1386        });
1387        session_list.send_update(SessionListUpdate::Refresh);
1388        cx.run_until_parked();
1389
1390        history.update(cx, |history, _cx| {
1391            assert_eq!(history.sessions.len(), 1);
1392            assert_eq!(
1393                history.sessions[0].session_id,
1394                acp::SessionId::new("session-1")
1395            );
1396            assert!(
1397                history
1398                    .sessions
1399                    .iter()
1400                    .all(|session| session.session_id != second_page_session_id)
1401            );
1402        });
1403        assert_eq!(session_list.requested_cursors(), vec![None]);
1404    }
1405
1406    #[gpui::test]
1407    async fn test_full_pagination_works_with_async_page_fetches(cx: &mut TestAppContext) {
1408        init_test(cx);
1409
1410        let session_list = Rc::new(
1411            PaginatedTestSessionList::new(
1412                vec![test_session("session-1", "First")],
1413                vec![test_session("session-2", "Second")],
1414            )
1415            .with_async_responses(),
1416        );
1417
1418        let (history, cx) = cx.add_window_view(|window, cx| {
1419            ThreadHistory::new(Some(session_list.clone()), window, cx)
1420        });
1421        cx.run_until_parked();
1422        session_list.clear_requested_cursors();
1423
1424        history.update(cx, |history, cx| history.refresh_full_history(cx));
1425        cx.run_until_parked();
1426
1427        history.update(cx, |history, _cx| {
1428            assert_eq!(history.sessions.len(), 2);
1429        });
1430        assert_eq!(
1431            session_list.requested_cursors(),
1432            vec![None, Some("page-2".to_string())]
1433        );
1434    }
1435
1436    #[gpui::test]
1437    async fn test_apply_info_update_title(cx: &mut TestAppContext) {
1438        init_test(cx);
1439
1440        let session_id = acp::SessionId::new("test-session");
1441        let sessions = vec![AgentSessionInfo {
1442            session_id: session_id.clone(),
1443            cwd: None,
1444            title: Some("Original Title".into()),
1445            updated_at: None,
1446            meta: None,
1447        }];
1448        let session_list = Rc::new(TestSessionList::new(sessions));
1449
1450        let (history, cx) = cx.add_window_view(|window, cx| {
1451            ThreadHistory::new(Some(session_list.clone()), window, cx)
1452        });
1453        cx.run_until_parked();
1454
1455        // Send a title update
1456        session_list.send_update(SessionListUpdate::SessionInfo {
1457            session_id: session_id.clone(),
1458            update: acp::SessionInfoUpdate::new().title("New Title"),
1459        });
1460        cx.run_until_parked();
1461
1462        // Check that the title was updated
1463        history.update(cx, |history, _cx| {
1464            let session = history.sessions.iter().find(|s| s.session_id == session_id);
1465            assert_eq!(
1466                session.unwrap().title.as_ref().map(|s| s.as_ref()),
1467                Some("New Title")
1468            );
1469        });
1470    }
1471
1472    #[gpui::test]
1473    async fn test_apply_info_update_clears_title_with_null(cx: &mut TestAppContext) {
1474        init_test(cx);
1475
1476        let session_id = acp::SessionId::new("test-session");
1477        let sessions = vec![AgentSessionInfo {
1478            session_id: session_id.clone(),
1479            cwd: None,
1480            title: Some("Original Title".into()),
1481            updated_at: None,
1482            meta: None,
1483        }];
1484        let session_list = Rc::new(TestSessionList::new(sessions));
1485
1486        let (history, cx) = cx.add_window_view(|window, cx| {
1487            ThreadHistory::new(Some(session_list.clone()), window, cx)
1488        });
1489        cx.run_until_parked();
1490
1491        // Send an update that clears the title (null)
1492        session_list.send_update(SessionListUpdate::SessionInfo {
1493            session_id: session_id.clone(),
1494            update: acp::SessionInfoUpdate::new().title(None::<String>),
1495        });
1496        cx.run_until_parked();
1497
1498        // Check that the title was cleared
1499        history.update(cx, |history, _cx| {
1500            let session = history.sessions.iter().find(|s| s.session_id == session_id);
1501            assert_eq!(session.unwrap().title, None);
1502        });
1503    }
1504
1505    #[gpui::test]
1506    async fn test_apply_info_update_ignores_undefined_fields(cx: &mut TestAppContext) {
1507        init_test(cx);
1508
1509        let session_id = acp::SessionId::new("test-session");
1510        let sessions = vec![AgentSessionInfo {
1511            session_id: session_id.clone(),
1512            cwd: None,
1513            title: Some("Original Title".into()),
1514            updated_at: None,
1515            meta: None,
1516        }];
1517        let session_list = Rc::new(TestSessionList::new(sessions));
1518
1519        let (history, cx) = cx.add_window_view(|window, cx| {
1520            ThreadHistory::new(Some(session_list.clone()), window, cx)
1521        });
1522        cx.run_until_parked();
1523
1524        // Send an update with no fields set (all undefined)
1525        session_list.send_update(SessionListUpdate::SessionInfo {
1526            session_id: session_id.clone(),
1527            update: acp::SessionInfoUpdate::new(),
1528        });
1529        cx.run_until_parked();
1530
1531        // Check that the title is unchanged
1532        history.update(cx, |history, _cx| {
1533            let session = history.sessions.iter().find(|s| s.session_id == session_id);
1534            assert_eq!(
1535                session.unwrap().title.as_ref().map(|s| s.as_ref()),
1536                Some("Original Title")
1537            );
1538        });
1539    }
1540
1541    #[gpui::test]
1542    async fn test_multiple_info_updates_applied_in_order(cx: &mut TestAppContext) {
1543        init_test(cx);
1544
1545        let session_id = acp::SessionId::new("test-session");
1546        let sessions = vec![AgentSessionInfo {
1547            session_id: session_id.clone(),
1548            cwd: None,
1549            title: None,
1550            updated_at: None,
1551            meta: None,
1552        }];
1553        let session_list = Rc::new(TestSessionList::new(sessions));
1554
1555        let (history, cx) = cx.add_window_view(|window, cx| {
1556            ThreadHistory::new(Some(session_list.clone()), window, cx)
1557        });
1558        cx.run_until_parked();
1559
1560        // Send multiple updates before the executor runs
1561        session_list.send_update(SessionListUpdate::SessionInfo {
1562            session_id: session_id.clone(),
1563            update: acp::SessionInfoUpdate::new().title("First Title"),
1564        });
1565        session_list.send_update(SessionListUpdate::SessionInfo {
1566            session_id: session_id.clone(),
1567            update: acp::SessionInfoUpdate::new().title("Second Title"),
1568        });
1569        cx.run_until_parked();
1570
1571        // Check that the final title is "Second Title" (both applied in order)
1572        history.update(cx, |history, _cx| {
1573            let session = history.sessions.iter().find(|s| s.session_id == session_id);
1574            assert_eq!(
1575                session.unwrap().title.as_ref().map(|s| s.as_ref()),
1576                Some("Second Title")
1577            );
1578        });
1579    }
1580
1581    #[gpui::test]
1582    async fn test_refresh_supersedes_info_updates(cx: &mut TestAppContext) {
1583        init_test(cx);
1584
1585        let session_id = acp::SessionId::new("test-session");
1586        let sessions = vec![AgentSessionInfo {
1587            session_id: session_id.clone(),
1588            cwd: None,
1589            title: Some("Server Title".into()),
1590            updated_at: None,
1591            meta: None,
1592        }];
1593        let session_list = Rc::new(TestSessionList::new(sessions));
1594
1595        let (history, cx) = cx.add_window_view(|window, cx| {
1596            ThreadHistory::new(Some(session_list.clone()), window, cx)
1597        });
1598        cx.run_until_parked();
1599
1600        // Send an info update followed by a refresh
1601        session_list.send_update(SessionListUpdate::SessionInfo {
1602            session_id: session_id.clone(),
1603            update: acp::SessionInfoUpdate::new().title("Local Update"),
1604        });
1605        session_list.send_update(SessionListUpdate::Refresh);
1606        cx.run_until_parked();
1607
1608        // The refresh should have fetched from server, getting "Server Title"
1609        history.update(cx, |history, _cx| {
1610            let session = history.sessions.iter().find(|s| s.session_id == session_id);
1611            assert_eq!(
1612                session.unwrap().title.as_ref().map(|s| s.as_ref()),
1613                Some("Server Title")
1614            );
1615        });
1616    }
1617
1618    #[gpui::test]
1619    async fn test_info_update_for_unknown_session_is_ignored(cx: &mut TestAppContext) {
1620        init_test(cx);
1621
1622        let session_id = acp::SessionId::new("known-session");
1623        let sessions = vec![AgentSessionInfo {
1624            session_id,
1625            cwd: None,
1626            title: Some("Original".into()),
1627            updated_at: None,
1628            meta: None,
1629        }];
1630        let session_list = Rc::new(TestSessionList::new(sessions));
1631
1632        let (history, cx) = cx.add_window_view(|window, cx| {
1633            ThreadHistory::new(Some(session_list.clone()), window, cx)
1634        });
1635        cx.run_until_parked();
1636
1637        // Send an update for an unknown session
1638        session_list.send_update(SessionListUpdate::SessionInfo {
1639            session_id: acp::SessionId::new("unknown-session"),
1640            update: acp::SessionInfoUpdate::new().title("Should Be Ignored"),
1641        });
1642        cx.run_until_parked();
1643
1644        // Check that the known session is unchanged and no crash occurred
1645        history.update(cx, |history, _cx| {
1646            assert_eq!(history.sessions.len(), 1);
1647            assert_eq!(
1648                history.sessions[0].title.as_ref().map(|s| s.as_ref()),
1649                Some("Original")
1650            );
1651        });
1652    }
1653
1654    #[test]
1655    fn test_time_bucket_from_dates() {
1656        let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
1657
1658        let date = today;
1659        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
1660
1661        let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
1662        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
1663
1664        let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
1665        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
1666
1667        let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
1668        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
1669
1670        let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
1671        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
1672
1673        let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
1674        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
1675
1676        // All: not in this week or last week
1677        let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
1678        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
1679
1680        // Test year boundary cases
1681        let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
1682
1683        let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
1684        assert_eq!(
1685            TimeBucket::from_dates(new_year, date),
1686            TimeBucket::Yesterday
1687        );
1688
1689        let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
1690        assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
1691    }
1692}