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 entry = self.entry.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(entry.clone(), 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(entry.clone(), window, cx);
 977                            });
 978                        }
 979                    }
 980                }
 981            })
 982    }
 983}
 984
 985#[derive(Clone, Copy)]
 986pub enum EntryTimeFormat {
 987    DateAndTime,
 988    TimeOnly,
 989}
 990
 991impl EntryTimeFormat {
 992    fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
 993        let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
 994
 995        match self {
 996            EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
 997                timestamp,
 998                OffsetDateTime::now_utc(),
 999                timezone,
1000                time_format::TimestampFormat::EnhancedAbsolute,
1001            ),
1002            EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
1003        }
1004    }
1005}
1006
1007impl From<TimeBucket> for EntryTimeFormat {
1008    fn from(bucket: TimeBucket) -> Self {
1009        match bucket {
1010            TimeBucket::Today => EntryTimeFormat::TimeOnly,
1011            TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
1012            TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
1013            TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
1014            TimeBucket::All => EntryTimeFormat::DateAndTime,
1015        }
1016    }
1017}
1018
1019#[derive(PartialEq, Eq, Clone, Copy, Debug)]
1020enum TimeBucket {
1021    Today,
1022    Yesterday,
1023    ThisWeek,
1024    PastWeek,
1025    All,
1026}
1027
1028impl TimeBucket {
1029    fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
1030        if date == reference {
1031            return TimeBucket::Today;
1032        }
1033
1034        if date == reference - TimeDelta::days(1) {
1035            return TimeBucket::Yesterday;
1036        }
1037
1038        let week = date.iso_week();
1039
1040        if reference.iso_week() == week {
1041            return TimeBucket::ThisWeek;
1042        }
1043
1044        let last_week = (reference - TimeDelta::days(7)).iso_week();
1045
1046        if week == last_week {
1047            return TimeBucket::PastWeek;
1048        }
1049
1050        TimeBucket::All
1051    }
1052}
1053
1054impl Display for TimeBucket {
1055    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1056        match self {
1057            TimeBucket::Today => write!(f, "Today"),
1058            TimeBucket::Yesterday => write!(f, "Yesterday"),
1059            TimeBucket::ThisWeek => write!(f, "This Week"),
1060            TimeBucket::PastWeek => write!(f, "Past Week"),
1061            TimeBucket::All => write!(f, "All"),
1062        }
1063    }
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068    use super::*;
1069    use acp_thread::AgentSessionListResponse;
1070    use chrono::NaiveDate;
1071    use gpui::TestAppContext;
1072    use std::{
1073        any::Any,
1074        sync::{Arc, Mutex},
1075    };
1076
1077    fn init_test(cx: &mut TestAppContext) {
1078        cx.update(|cx| {
1079            let settings_store = settings::SettingsStore::test(cx);
1080            cx.set_global(settings_store);
1081            theme::init(theme::LoadThemes::JustBase, cx);
1082        });
1083    }
1084
1085    #[derive(Clone)]
1086    struct TestSessionList {
1087        sessions: Vec<AgentSessionInfo>,
1088        updates_tx: smol::channel::Sender<SessionListUpdate>,
1089        updates_rx: smol::channel::Receiver<SessionListUpdate>,
1090    }
1091
1092    impl TestSessionList {
1093        fn new(sessions: Vec<AgentSessionInfo>) -> Self {
1094            let (tx, rx) = smol::channel::unbounded();
1095            Self {
1096                sessions,
1097                updates_tx: tx,
1098                updates_rx: rx,
1099            }
1100        }
1101
1102        fn send_update(&self, update: SessionListUpdate) {
1103            self.updates_tx.try_send(update).ok();
1104        }
1105    }
1106
1107    impl AgentSessionList for TestSessionList {
1108        fn list_sessions(
1109            &self,
1110            _request: AgentSessionListRequest,
1111            _cx: &mut App,
1112        ) -> Task<anyhow::Result<AgentSessionListResponse>> {
1113            Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone())))
1114        }
1115
1116        fn watch(&self, _cx: &mut App) -> Option<smol::channel::Receiver<SessionListUpdate>> {
1117            Some(self.updates_rx.clone())
1118        }
1119
1120        fn notify_refresh(&self) {
1121            self.send_update(SessionListUpdate::Refresh);
1122        }
1123
1124        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
1125            self
1126        }
1127    }
1128
1129    #[derive(Clone)]
1130    struct PaginatedTestSessionList {
1131        first_page_sessions: Vec<AgentSessionInfo>,
1132        second_page_sessions: Vec<AgentSessionInfo>,
1133        requested_cursors: Arc<Mutex<Vec<Option<String>>>>,
1134        async_responses: bool,
1135        updates_tx: smol::channel::Sender<SessionListUpdate>,
1136        updates_rx: smol::channel::Receiver<SessionListUpdate>,
1137    }
1138
1139    impl PaginatedTestSessionList {
1140        fn new(
1141            first_page_sessions: Vec<AgentSessionInfo>,
1142            second_page_sessions: Vec<AgentSessionInfo>,
1143        ) -> Self {
1144            let (tx, rx) = smol::channel::unbounded();
1145            Self {
1146                first_page_sessions,
1147                second_page_sessions,
1148                requested_cursors: Arc::new(Mutex::new(Vec::new())),
1149                async_responses: false,
1150                updates_tx: tx,
1151                updates_rx: rx,
1152            }
1153        }
1154
1155        fn with_async_responses(mut self) -> Self {
1156            self.async_responses = true;
1157            self
1158        }
1159
1160        fn requested_cursors(&self) -> Vec<Option<String>> {
1161            self.requested_cursors.lock().unwrap().clone()
1162        }
1163
1164        fn clear_requested_cursors(&self) {
1165            self.requested_cursors.lock().unwrap().clear()
1166        }
1167
1168        fn send_update(&self, update: SessionListUpdate) {
1169            self.updates_tx.try_send(update).ok();
1170        }
1171    }
1172
1173    impl AgentSessionList for PaginatedTestSessionList {
1174        fn list_sessions(
1175            &self,
1176            request: AgentSessionListRequest,
1177            cx: &mut App,
1178        ) -> Task<anyhow::Result<AgentSessionListResponse>> {
1179            let requested_cursors = self.requested_cursors.clone();
1180            let first_page_sessions = self.first_page_sessions.clone();
1181            let second_page_sessions = self.second_page_sessions.clone();
1182
1183            let respond = move || {
1184                requested_cursors
1185                    .lock()
1186                    .unwrap()
1187                    .push(request.cursor.clone());
1188
1189                match request.cursor.as_deref() {
1190                    None => AgentSessionListResponse {
1191                        sessions: first_page_sessions,
1192                        next_cursor: Some("page-2".to_string()),
1193                        meta: None,
1194                    },
1195                    Some("page-2") => AgentSessionListResponse::new(second_page_sessions),
1196                    _ => AgentSessionListResponse::new(Vec::new()),
1197                }
1198            };
1199
1200            if self.async_responses {
1201                cx.foreground_executor().spawn(async move {
1202                    smol::future::yield_now().await;
1203                    Ok(respond())
1204                })
1205            } else {
1206                Task::ready(Ok(respond()))
1207            }
1208        }
1209
1210        fn watch(&self, _cx: &mut App) -> Option<smol::channel::Receiver<SessionListUpdate>> {
1211            Some(self.updates_rx.clone())
1212        }
1213
1214        fn notify_refresh(&self) {
1215            self.send_update(SessionListUpdate::Refresh);
1216        }
1217
1218        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
1219            self
1220        }
1221    }
1222
1223    fn test_session(session_id: &str, title: &str) -> AgentSessionInfo {
1224        AgentSessionInfo {
1225            session_id: acp::SessionId::new(session_id),
1226            cwd: None,
1227            title: Some(title.to_string().into()),
1228            updated_at: None,
1229            meta: None,
1230        }
1231    }
1232
1233    #[gpui::test]
1234    async fn test_refresh_only_loads_first_page_by_default(cx: &mut TestAppContext) {
1235        init_test(cx);
1236
1237        let session_list = Rc::new(PaginatedTestSessionList::new(
1238            vec![test_session("session-1", "First")],
1239            vec![test_session("session-2", "Second")],
1240        ));
1241
1242        let (history, cx) = cx.add_window_view(|window, cx| {
1243            ThreadHistory::new(Some(session_list.clone()), window, cx)
1244        });
1245        cx.run_until_parked();
1246
1247        history.update(cx, |history, _cx| {
1248            assert_eq!(history.sessions.len(), 1);
1249            assert_eq!(
1250                history.sessions[0].session_id,
1251                acp::SessionId::new("session-1")
1252            );
1253        });
1254        assert_eq!(session_list.requested_cursors(), vec![None]);
1255    }
1256
1257    #[gpui::test]
1258    async fn test_enabling_full_pagination_loads_all_pages(cx: &mut TestAppContext) {
1259        init_test(cx);
1260
1261        let session_list = Rc::new(PaginatedTestSessionList::new(
1262            vec![test_session("session-1", "First")],
1263            vec![test_session("session-2", "Second")],
1264        ));
1265
1266        let (history, cx) = cx.add_window_view(|window, cx| {
1267            ThreadHistory::new(Some(session_list.clone()), window, cx)
1268        });
1269        cx.run_until_parked();
1270        session_list.clear_requested_cursors();
1271
1272        history.update(cx, |history, cx| history.refresh_full_history(cx));
1273        cx.run_until_parked();
1274
1275        history.update(cx, |history, _cx| {
1276            assert_eq!(history.sessions.len(), 2);
1277            assert_eq!(
1278                history.sessions[0].session_id,
1279                acp::SessionId::new("session-1")
1280            );
1281            assert_eq!(
1282                history.sessions[1].session_id,
1283                acp::SessionId::new("session-2")
1284            );
1285        });
1286        assert_eq!(
1287            session_list.requested_cursors(),
1288            vec![None, Some("page-2".to_string())]
1289        );
1290    }
1291
1292    #[gpui::test]
1293    async fn test_standard_refresh_replaces_with_first_page_after_full_history_refresh(
1294        cx: &mut TestAppContext,
1295    ) {
1296        init_test(cx);
1297
1298        let session_list = Rc::new(PaginatedTestSessionList::new(
1299            vec![test_session("session-1", "First")],
1300            vec![test_session("session-2", "Second")],
1301        ));
1302
1303        let (history, cx) = cx.add_window_view(|window, cx| {
1304            ThreadHistory::new(Some(session_list.clone()), window, cx)
1305        });
1306        cx.run_until_parked();
1307
1308        history.update(cx, |history, cx| history.refresh_full_history(cx));
1309        cx.run_until_parked();
1310        session_list.clear_requested_cursors();
1311
1312        history.update(cx, |history, cx| {
1313            history.refresh(cx);
1314        });
1315        cx.run_until_parked();
1316
1317        history.update(cx, |history, _cx| {
1318            assert_eq!(history.sessions.len(), 1);
1319            assert_eq!(
1320                history.sessions[0].session_id,
1321                acp::SessionId::new("session-1")
1322            );
1323        });
1324        assert_eq!(session_list.requested_cursors(), vec![None]);
1325    }
1326
1327    #[gpui::test]
1328    async fn test_re_entering_full_pagination_reloads_all_pages(cx: &mut TestAppContext) {
1329        init_test(cx);
1330
1331        let session_list = Rc::new(PaginatedTestSessionList::new(
1332            vec![test_session("session-1", "First")],
1333            vec![test_session("session-2", "Second")],
1334        ));
1335
1336        let (history, cx) = cx.add_window_view(|window, cx| {
1337            ThreadHistory::new(Some(session_list.clone()), window, cx)
1338        });
1339        cx.run_until_parked();
1340
1341        history.update(cx, |history, cx| history.refresh_full_history(cx));
1342        cx.run_until_parked();
1343        session_list.clear_requested_cursors();
1344
1345        history.update(cx, |history, cx| history.refresh_full_history(cx));
1346        cx.run_until_parked();
1347
1348        history.update(cx, |history, _cx| {
1349            assert_eq!(history.sessions.len(), 2);
1350        });
1351        assert_eq!(
1352            session_list.requested_cursors(),
1353            vec![None, Some("page-2".to_string())]
1354        );
1355    }
1356
1357    #[gpui::test]
1358    async fn test_partial_refresh_batch_drops_non_first_page_sessions(cx: &mut TestAppContext) {
1359        init_test(cx);
1360
1361        let second_page_session_id = acp::SessionId::new("session-2");
1362        let session_list = Rc::new(PaginatedTestSessionList::new(
1363            vec![test_session("session-1", "First")],
1364            vec![test_session("session-2", "Second")],
1365        ));
1366
1367        let (history, cx) = cx.add_window_view(|window, cx| {
1368            ThreadHistory::new(Some(session_list.clone()), window, cx)
1369        });
1370        cx.run_until_parked();
1371
1372        history.update(cx, |history, cx| history.refresh_full_history(cx));
1373        cx.run_until_parked();
1374
1375        session_list.clear_requested_cursors();
1376
1377        session_list.send_update(SessionListUpdate::SessionInfo {
1378            session_id: second_page_session_id.clone(),
1379            update: acp::SessionInfoUpdate::new().title("Updated Second"),
1380        });
1381        session_list.send_update(SessionListUpdate::Refresh);
1382        cx.run_until_parked();
1383
1384        history.update(cx, |history, _cx| {
1385            assert_eq!(history.sessions.len(), 1);
1386            assert_eq!(
1387                history.sessions[0].session_id,
1388                acp::SessionId::new("session-1")
1389            );
1390            assert!(
1391                history
1392                    .sessions
1393                    .iter()
1394                    .all(|session| session.session_id != second_page_session_id)
1395            );
1396        });
1397        assert_eq!(session_list.requested_cursors(), vec![None]);
1398    }
1399
1400    #[gpui::test]
1401    async fn test_full_pagination_works_with_async_page_fetches(cx: &mut TestAppContext) {
1402        init_test(cx);
1403
1404        let session_list = Rc::new(
1405            PaginatedTestSessionList::new(
1406                vec![test_session("session-1", "First")],
1407                vec![test_session("session-2", "Second")],
1408            )
1409            .with_async_responses(),
1410        );
1411
1412        let (history, cx) = cx.add_window_view(|window, cx| {
1413            ThreadHistory::new(Some(session_list.clone()), window, cx)
1414        });
1415        cx.run_until_parked();
1416        session_list.clear_requested_cursors();
1417
1418        history.update(cx, |history, cx| history.refresh_full_history(cx));
1419        cx.run_until_parked();
1420
1421        history.update(cx, |history, _cx| {
1422            assert_eq!(history.sessions.len(), 2);
1423        });
1424        assert_eq!(
1425            session_list.requested_cursors(),
1426            vec![None, Some("page-2".to_string())]
1427        );
1428    }
1429
1430    #[gpui::test]
1431    async fn test_apply_info_update_title(cx: &mut TestAppContext) {
1432        init_test(cx);
1433
1434        let session_id = acp::SessionId::new("test-session");
1435        let sessions = vec![AgentSessionInfo {
1436            session_id: session_id.clone(),
1437            cwd: None,
1438            title: Some("Original Title".into()),
1439            updated_at: None,
1440            meta: None,
1441        }];
1442        let session_list = Rc::new(TestSessionList::new(sessions));
1443
1444        let (history, cx) = cx.add_window_view(|window, cx| {
1445            ThreadHistory::new(Some(session_list.clone()), window, cx)
1446        });
1447        cx.run_until_parked();
1448
1449        // Send a title update
1450        session_list.send_update(SessionListUpdate::SessionInfo {
1451            session_id: session_id.clone(),
1452            update: acp::SessionInfoUpdate::new().title("New Title"),
1453        });
1454        cx.run_until_parked();
1455
1456        // Check that the title was updated
1457        history.update(cx, |history, _cx| {
1458            let session = history.sessions.iter().find(|s| s.session_id == session_id);
1459            assert_eq!(
1460                session.unwrap().title.as_ref().map(|s| s.as_ref()),
1461                Some("New Title")
1462            );
1463        });
1464    }
1465
1466    #[gpui::test]
1467    async fn test_apply_info_update_clears_title_with_null(cx: &mut TestAppContext) {
1468        init_test(cx);
1469
1470        let session_id = acp::SessionId::new("test-session");
1471        let sessions = vec![AgentSessionInfo {
1472            session_id: session_id.clone(),
1473            cwd: None,
1474            title: Some("Original Title".into()),
1475            updated_at: None,
1476            meta: None,
1477        }];
1478        let session_list = Rc::new(TestSessionList::new(sessions));
1479
1480        let (history, cx) = cx.add_window_view(|window, cx| {
1481            ThreadHistory::new(Some(session_list.clone()), window, cx)
1482        });
1483        cx.run_until_parked();
1484
1485        // Send an update that clears the title (null)
1486        session_list.send_update(SessionListUpdate::SessionInfo {
1487            session_id: session_id.clone(),
1488            update: acp::SessionInfoUpdate::new().title(None::<String>),
1489        });
1490        cx.run_until_parked();
1491
1492        // Check that the title was cleared
1493        history.update(cx, |history, _cx| {
1494            let session = history.sessions.iter().find(|s| s.session_id == session_id);
1495            assert_eq!(session.unwrap().title, None);
1496        });
1497    }
1498
1499    #[gpui::test]
1500    async fn test_apply_info_update_ignores_undefined_fields(cx: &mut TestAppContext) {
1501        init_test(cx);
1502
1503        let session_id = acp::SessionId::new("test-session");
1504        let sessions = vec![AgentSessionInfo {
1505            session_id: session_id.clone(),
1506            cwd: None,
1507            title: Some("Original Title".into()),
1508            updated_at: None,
1509            meta: None,
1510        }];
1511        let session_list = Rc::new(TestSessionList::new(sessions));
1512
1513        let (history, cx) = cx.add_window_view(|window, cx| {
1514            ThreadHistory::new(Some(session_list.clone()), window, cx)
1515        });
1516        cx.run_until_parked();
1517
1518        // Send an update with no fields set (all undefined)
1519        session_list.send_update(SessionListUpdate::SessionInfo {
1520            session_id: session_id.clone(),
1521            update: acp::SessionInfoUpdate::new(),
1522        });
1523        cx.run_until_parked();
1524
1525        // Check that the title is unchanged
1526        history.update(cx, |history, _cx| {
1527            let session = history.sessions.iter().find(|s| s.session_id == session_id);
1528            assert_eq!(
1529                session.unwrap().title.as_ref().map(|s| s.as_ref()),
1530                Some("Original Title")
1531            );
1532        });
1533    }
1534
1535    #[gpui::test]
1536    async fn test_multiple_info_updates_applied_in_order(cx: &mut TestAppContext) {
1537        init_test(cx);
1538
1539        let session_id = acp::SessionId::new("test-session");
1540        let sessions = vec![AgentSessionInfo {
1541            session_id: session_id.clone(),
1542            cwd: None,
1543            title: None,
1544            updated_at: None,
1545            meta: None,
1546        }];
1547        let session_list = Rc::new(TestSessionList::new(sessions));
1548
1549        let (history, cx) = cx.add_window_view(|window, cx| {
1550            ThreadHistory::new(Some(session_list.clone()), window, cx)
1551        });
1552        cx.run_until_parked();
1553
1554        // Send multiple updates before the executor runs
1555        session_list.send_update(SessionListUpdate::SessionInfo {
1556            session_id: session_id.clone(),
1557            update: acp::SessionInfoUpdate::new().title("First Title"),
1558        });
1559        session_list.send_update(SessionListUpdate::SessionInfo {
1560            session_id: session_id.clone(),
1561            update: acp::SessionInfoUpdate::new().title("Second Title"),
1562        });
1563        cx.run_until_parked();
1564
1565        // Check that the final title is "Second Title" (both applied in order)
1566        history.update(cx, |history, _cx| {
1567            let session = history.sessions.iter().find(|s| s.session_id == session_id);
1568            assert_eq!(
1569                session.unwrap().title.as_ref().map(|s| s.as_ref()),
1570                Some("Second Title")
1571            );
1572        });
1573    }
1574
1575    #[gpui::test]
1576    async fn test_refresh_supersedes_info_updates(cx: &mut TestAppContext) {
1577        init_test(cx);
1578
1579        let session_id = acp::SessionId::new("test-session");
1580        let sessions = vec![AgentSessionInfo {
1581            session_id: session_id.clone(),
1582            cwd: None,
1583            title: Some("Server Title".into()),
1584            updated_at: None,
1585            meta: None,
1586        }];
1587        let session_list = Rc::new(TestSessionList::new(sessions));
1588
1589        let (history, cx) = cx.add_window_view(|window, cx| {
1590            ThreadHistory::new(Some(session_list.clone()), window, cx)
1591        });
1592        cx.run_until_parked();
1593
1594        // Send an info update followed by a refresh
1595        session_list.send_update(SessionListUpdate::SessionInfo {
1596            session_id: session_id.clone(),
1597            update: acp::SessionInfoUpdate::new().title("Local Update"),
1598        });
1599        session_list.send_update(SessionListUpdate::Refresh);
1600        cx.run_until_parked();
1601
1602        // The refresh should have fetched from server, getting "Server Title"
1603        history.update(cx, |history, _cx| {
1604            let session = history.sessions.iter().find(|s| s.session_id == session_id);
1605            assert_eq!(
1606                session.unwrap().title.as_ref().map(|s| s.as_ref()),
1607                Some("Server Title")
1608            );
1609        });
1610    }
1611
1612    #[gpui::test]
1613    async fn test_info_update_for_unknown_session_is_ignored(cx: &mut TestAppContext) {
1614        init_test(cx);
1615
1616        let session_id = acp::SessionId::new("known-session");
1617        let sessions = vec![AgentSessionInfo {
1618            session_id,
1619            cwd: None,
1620            title: Some("Original".into()),
1621            updated_at: None,
1622            meta: None,
1623        }];
1624        let session_list = Rc::new(TestSessionList::new(sessions));
1625
1626        let (history, cx) = cx.add_window_view(|window, cx| {
1627            ThreadHistory::new(Some(session_list.clone()), window, cx)
1628        });
1629        cx.run_until_parked();
1630
1631        // Send an update for an unknown session
1632        session_list.send_update(SessionListUpdate::SessionInfo {
1633            session_id: acp::SessionId::new("unknown-session"),
1634            update: acp::SessionInfoUpdate::new().title("Should Be Ignored"),
1635        });
1636        cx.run_until_parked();
1637
1638        // Check that the known session is unchanged and no crash occurred
1639        history.update(cx, |history, _cx| {
1640            assert_eq!(history.sessions.len(), 1);
1641            assert_eq!(
1642                history.sessions[0].title.as_ref().map(|s| s.as_ref()),
1643                Some("Original")
1644            );
1645        });
1646    }
1647
1648    #[test]
1649    fn test_time_bucket_from_dates() {
1650        let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
1651
1652        let date = today;
1653        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
1654
1655        let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
1656        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
1657
1658        let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
1659        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
1660
1661        let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
1662        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
1663
1664        let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
1665        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
1666
1667        let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
1668        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
1669
1670        // All: not in this week or last week
1671        let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
1672        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
1673
1674        // Test year boundary cases
1675        let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
1676
1677        let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
1678        assert_eq!(
1679            TimeBucket::from_dates(new_year, date),
1680            TimeBucket::Yesterday
1681        );
1682
1683        let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
1684        assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
1685    }
1686}