//! The thread sidebar, laid out like T3 Code's default inbox: a chrome row, //! search with the project filter, then unsent drafts, pinned and active //! cards, and the Working, Snoozed and Settled shelves, with the undo notice //! and Settings and Usage at the foot. The legacy layout, threads grouped //! under their projects, lives in `legacy`. use gpui_kit::component::{ Icon, IconName, Selectable as _, h_flex, input::{Input, InputEvent, InputState}, menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenuItem}, tooltip::Tooltip, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use super::actions::{snooze_presets, wake_countdown}; use super::{ThreadEntry, Workspace, relative_time}; use crate::session::{ChatItem, PermissionState, Session, SessionStatus, ToolCall}; use crate::settings; use crate::ui::icons::Lucide; use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette}; use crate::ui::pickers; use crate::{SelectNext, SelectPrevious, Dismiss}; mod legacy; /// How many settled threads show at first, and how many more each "Show /// more" adds. pub(super) const SETTLED_PAGE: usize = 10; const SETTLED_MORE: usize = 25; /// The card rows' height, T3's `h-[4.875rem]`. const CARD_HEIGHT: f32 = 78.; const SLIM_HEIGHT: f32 = 36.; /// The modifier shown on the jump hints. pub(super) const JUMP_MODIFIER: &str = "Ctrl"; /// What a row's status slot says, in T3's order of precedence. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum TopStatus { Approval, Input, Working, Waiting, Limited, Failed, Woke, Done, } /// The status a thread's row shows, if any. pub(super) fn top_status(state: &Session) -> Option { // Claude's questions wait for input, not an approval. let approval = state.items.iter().any(|item| { matches!(item, ChatItem::Tool(ToolCall { permission: Some(prompt), ask: None, .. }) if prompt.state == PermissionState::Pending) }); if approval { return Some(TopStatus::Approval); } if state.needs_attention() || state.pending_ask().is_some() { return Some(TopStatus::Input); } if state.is_working() || state.preparing { return Some(TopStatus::Working); } if state.status == SessionStatus::Idle && !state.background_tasks.is_empty() { return Some(TopStatus::Waiting); } if state.limited_until().is_some() { return Some(TopStatus::Limited); } if state.ended_with_error() && !state.items.is_empty() { return Some(TopStatus::Failed); } if state.woke { return Some(TopStatus::Woke); } if state.unread { return Some(TopStatus::Done); } None } /// How long the running turn has worked: `12s`, `4m`, `1h 5m`. fn working_for(state: &Session) -> String { let secs = state.turn_started.map_or(0, |started| started.elapsed().as_secs()); match secs { 0..60 => format!("{secs}s"), 60..3_600 => format!("{}m", secs / 60), _ => format!("{}h {}m", secs / 3_600, secs % 3_600 / 60), } } /// The threads the inbox shows, in its sections. #[derive(Default)] pub(super) struct Inbox { pub drafts: Vec, pub pinned: Vec, pub active: Vec, pub working: Vec, pub snoozed: Vec, pub settled: Vec, } impl Inbox { fn is_empty(&self) -> bool { self.drafts.is_empty() && self.pinned.is_empty() && self.active.is_empty() && self.working.is_empty() && self.snoozed.is_empty() && self.settled.is_empty() } } /// One of the search results: the thread and, for a match in the /// conversation rather than the title, an excerpt around it. struct SearchHit { entry: ThreadEntry, excerpt: Option, } /// A shelf's look: muted, or blue for Snoozed. #[derive(Clone, Copy, PartialEq, Eq)] enum ShelfTone { Muted, Info, } impl Workspace { // ---- what shows --------------------------------------------------------- fn in_scope(&self, project_ix: usize) -> bool { self.project_scope.as_ref().is_none_or(|key| { let project = &self.projects[project_ix]; project.path == key.0 && project.environment == key.1 }) } /// What's typed in a thread nothing was sent in yet, if anything. pub(super) fn draft_of(&self, session: &Entity, cx: &App) -> Option<(String, usize)> { let state = session.read(cx); if !state.is_blank() { return None; } let (text, attachments) = self.chats.get(&session.entity_id())?.read(cx).draft(cx); (!text.trim().is_empty() || attachments > 0).then_some((text, attachments)) } /// Whether a thread's composer holds something typed but not sent. pub(super) fn has_unsent(&self, session: &Entity, cx: &App) -> bool { self.chats.get(&session.entity_id()).is_some_and(|chat| { let (text, attachments) = chat.read(cx).draft(cx); !text.trim().is_empty() || attachments > 0 }) } /// The inbox's threads in their sections, within the project filter. pub(super) fn inbox(&self, cx: &App) -> Inbox { let working_shelf = settings::get(|settings| settings.working_section); let mut inbox = Inbox::default(); for (ix, project) in self.projects.iter().enumerate() { if !self.in_scope(ix) { continue; } for session in &project.sessions { let state = session.read(cx); let entry = (ix, session.clone()); // Subagents' threads open from the subagents panel. if state.archived || state.is_subagent() { continue; } if state.is_blank() { // The draft being typed stays out of the list until you // leave it; coming back to it hides it again. if self.active.as_ref() != Some(session) && self.draft_of(session, cx).is_some() { inbox.drafts.push(entry); } } else if state.settled { inbox.settled.push(entry); } else if state.is_snoozed() { inbox.snoozed.push(entry); } else if state.pinned { inbox.pinned.push(entry); } else if working_shelf && state.is_working() && !state.needs_attention() { inbox.working.push(entry); } else { inbox.active.push(entry); } } } let newest = |(_, session): &ThreadEntry| std::cmp::Reverse(session.read(cx).updated); inbox.drafts.sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).created)); inbox.pinned.sort_by_key(newest); inbox.active.sort_by_key(newest); inbox.working.sort_by_key(newest); inbox.snoozed.sort_by_key(|(_, session)| session.read(cx).snoozed_until); inbox .settled .sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).settled_at.unwrap_or(session.read(cx).updated))); inbox } /// The pinned and active cards, in order: where settling the open thread /// moves to. pub(super) fn inbox_cards(&self, cx: &App) -> Vec { let inbox = self.inbox(cx); inbox.pinned.into_iter().chain(inbox.active).collect() } /// The thread rows showing, top to bottom: what Ctrl+1–9 jump to, and /// what Ctrl+Shift+[ and ] step through. pub(super) fn visible_rows(&self, cx: &App) -> Vec { if self.legacy_sidebar() { return self.legacy_rows(cx); } let searching = !self.search_query(cx).is_empty(); if searching { return self.search_hits(cx).into_iter().map(|hit| hit.entry).collect(); } let inbox = self.inbox(cx); let active = self.active.clone(); let is_active = |(_, session): &ThreadEntry| active.as_ref() == Some(session); let mut rows: Vec = inbox.pinned.into_iter().chain(inbox.active).collect(); let shelves = [ (inbox.working, self.working_open, usize::MAX), (inbox.snoozed, self.snoozed_open, usize::MAX), (inbox.settled, self.settled_open, self.settled_shown), ]; for (threads, open, limit) in shelves { for (ix, entry) in threads.into_iter().enumerate() { if (open && ix < limit) || is_active(&entry) { rows.push(entry); } } } rows } pub(super) fn legacy_sidebar(&self) -> bool { settings::get(|settings| settings.legacy_sidebar || settings.group_by_project) } fn search_query(&self, cx: &App) -> String { self.search.read(cx).value().trim().to_lowercase() } /// Threads matching the search: title matches first, then matches in /// the conversation (from two characters on), each in shelf order. fn search_hits(&self, cx: &App) -> Vec { let query = self.search_query(cx); if query.is_empty() { return Vec::new(); } let inbox = self.inbox(cx); let ordered = inbox .pinned .into_iter() .chain(inbox.active) .chain(inbox.working) .chain(inbox.snoozed) .chain(inbox.settled); let mut titles = Vec::new(); let mut contents = Vec::new(); for entry in ordered { let state = entry.1.read(cx); let pr = state.pull_request.as_ref().map(|pr| format!("#{}", pr.number)).unwrap_or_default(); if state.title.to_lowercase().contains(&query) || (!pr.is_empty() && pr.contains(&query)) { titles.push(SearchHit { entry, excerpt: None }); } else if query.chars().count() >= 2 && let Some(excerpt) = content_match(state, &query) { contents.push(SearchHit { entry, excerpt: Some(excerpt) }); } } titles.extend(contents); titles } /// Opens the highlighted search result (Enter in the search field). pub(super) fn open_search_result(&mut self, window: &mut Window, cx: &mut Context) { let hits = self.search_hits(cx); if let Some(hit) = hits.get(self.search_highlight.min(hits.len().saturating_sub(1))) { let (ix, session) = hit.entry.clone(); self.select(ix, session, window, cx); } } fn move_search_highlight(&mut self, delta: isize, cx: &mut Context) { let count = self.search_hits(cx).len(); if count == 0 { return; } let current = self.search_highlight.min(count - 1) as isize; self.search_highlight = (current + delta).rem_euclid(count as isize) as usize; cx.notify(); } fn clear_search(&mut self, window: &mut Window, cx: &mut Context) { self.search.update(cx, |search, cx| search.set_value("", window, cx)); self.search_highlight = 0; cx.notify(); } // ---- clicking rows --------------------------------------------------- /// A click on a row: opens the thread, or with Ctrl or Shift held, picks /// rows for the multi-select menu. A double click renames. fn click_row(&mut self, project_ix: usize, session: Entity, event: &ClickEvent, window: &mut Window, cx: &mut Context) { let id = session.entity_id(); let modifiers = event.modifiers(); if modifiers.control || modifiers.platform { if let Some(ix) = self.selection.iter().position(|selected| *selected == id) { self.selection.remove(ix); } else { self.selection.push(id); } self.selection_anchor = Some(id); cx.notify(); return; } if modifiers.shift { let rows = self.visible_rows(cx); let anchor = self.selection_anchor.or(self.active.as_ref().map(|active| active.entity_id())); let from = anchor.and_then(|anchor| rows.iter().position(|(_, row)| row.entity_id() == anchor)); let to = rows.iter().position(|(_, row)| row.entity_id() == id); if let (Some(from), Some(to)) = (from, to) { let (start, end) = if from <= to { (from, to) } else { (to, from) }; self.selection = rows[start..=end].iter().map(|(_, row)| row.entity_id()).collect(); cx.notify(); return; } } self.selection.clear(); self.selection_anchor = Some(id); if event.click_count() >= 2 { self.start_rename(session, true, window, cx); return; } self.select(project_ix, session, window, cx); } /// The threads picked for the multi-select menu that still show. fn selected_rows(&self, cx: &App) -> Vec> { let rows = self.visible_rows(cx); self.selection .iter() .filter_map(|id| rows.iter().find(|(_, session)| session.entity_id() == *id).map(|(_, session)| session.clone())) .collect() } /// The right-click menu for a row: the multi-select menu when the row is /// among several picked, otherwise the thread action menu. fn row_menu(&self, session: &Entity, cx: &mut Context) -> impl Fn(gpui_kit::component::menu::PopupMenu, &mut Window, &mut Context) -> gpui_kit::component::menu::PopupMenu + 'static { let workspace = cx.weak_entity(); let session = session.clone(); move |menu, window, cx| { let selected = workspace.upgrade().map(|this| { let this = this.read(cx); (this.selection.contains(&session.entity_id()), this.selected_rows(cx), this.legacy_sidebar()) }); match selected { Some((true, rows, legacy)) if !rows.is_empty() => { if legacy { Workspace::legacy_multi_select_menu(workspace.clone(), rows, menu, window, cx) } else { Workspace::multi_select_menu(workspace.clone(), rows, menu, window, cx) } } Some((_, _, true)) => Workspace::legacy_thread_menu(workspace.clone(), session.clone(), menu, window, cx), _ => Workspace::thread_action_menu(workspace.clone(), session.clone(), true, menu, window, cx), } } } // ---- the frame --------------------------------------------------------- pub(super) fn render_sidebar(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let legacy = self.legacy_sidebar(); v_flex() .w(px(self.sidebar_width)) .h_full() .flex_none() .relative() .child(self.render_sidebar_chrome(cx)) .map(|this| { if legacy { this.child(self.render_legacy_header(cx)).child(self.render_legacy_list(cx)) } else { this.child(self.render_thread_header(window, cx)).child(self.render_inbox(cx)) } }) .child(self.render_sidebar_footer(cx)) .child(self.render_resize_rail(cx)) } /// The sidebar's right edge: drag to resize, double-click for the /// default width. fn render_resize_rail(&self, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let resizing = self.resizing_sidebar; div() .id("sidebar-rail") .absolute() .top_0() .bottom_0() .right(px(-8.)) .w(px(16.)) .flex() .justify_center() .cursor_col_resize() .group("sidebar-rail") .tooltip(|window, cx| Tooltip::new("Drag to resize sidebar").build(window, cx)) .on_mouse_down( MouseButton::Left, cx.listener(|this, event: &MouseDownEvent, _, cx| { if event.click_count >= 2 { this.sidebar_width = super::SIDEBAR_WIDTH; } else { this.resizing_sidebar = true; } cx.stop_propagation(); cx.notify(); }), ) .child( div() .w(px(2.)) .h_full() .when(!resizing, |this| this.invisible().group_hover("sidebar-rail", |style| style.visible())) .bg(p.rule), ) } /// The brand row, with the sidebar toggle. fn render_sidebar_chrome(&self, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); h_flex() .flex_none() .h(px(52.)) .pl(px(10.)) .pr(px(12.)) .gap(px(8.)) .child(super::header::sidebar_toggle(true, cx)) .child( h_flex() .id("brand") .h(px(28.)) .gap(px(8.)) .cursor_pointer() .on_click(cx.listener(|this, _, _, cx| { this.set_project_scope(None, cx); })) .child(Icon::default().data(metal::LOGO_SVG).size(px(18.)).text_color(p.ink)) .child(div().text_size(px(15.)).font_weight(FontWeight::SEMIBOLD).child("Vectrum")) .child(metal::tag("Code", p)), ) } /// Search, then the project filter, Add project and New thread. fn render_thread_header(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let has_projects = !self.projects.is_empty(); let searching = !self.search.read(cx).value().is_empty(); let scoped = self.project_scope.as_ref().and_then(|key| self.project_by_key(key)); let groups = self.projects.len(); let new_thread_tip = SharedString::from("New thread (Ctrl+N)"); let picker_open = self.scope_picker.is_some(); let search = h_flex() .key_context("SidebarSearch") .on_action(cx.listener(|this, _: &SelectPrevious, _, cx| this.move_search_highlight(-1, cx))) .on_action(cx.listener(|this, _: &SelectNext, _, cx| this.move_search_highlight(1, cx))) .on_action(cx.listener(|this, _: &Dismiss, window, cx| this.clear_search(window, cx))) .flex_1() .min_w_0() .h(px(32.)) .pl(px(10.)) .pr(px(6.)) .gap(px(8.)) .rounded(px(12.)) .well(p) .text_size(px(13.)) .text_color(p.ink) .child(Icon::new(IconName::Search).size(px(14.)).text_color(p.field_ink)) .child(div().flex_1().min_w_0().child(Input::new(&self.search).appearance(false))) .when(searching, |this| { this.child( MetalButton::new("clear-search") .ghost() .mini() .size(px(18.)) .icon(Icon::new(Lucide::X).size(px(12.))) .tooltip("Clear thread search") .on_click(cx.listener(|this, _, window, cx| { this.clear_search(window, cx); this.search.update(cx, |search, cx| search.focus(window, cx)); })), ) }); let scope_button = has_projects.then(|| { let tip: SharedString = match scoped { Some(ix) => format!("Filter threads by project: {}", self.projects[ix].name).into(), None => "Filter threads by project".into(), }; match scoped { // The scoped project's badge stands in for the folder. Some(ix) => div() .id("project-scope") .flex() .flex_none() .items_center() .justify_center() .size(px(26.)) .rounded_full() .cursor_pointer() .hover(|style| style.bg(p.row_hover)) .when(picker_open, |this| this.bg(metal::vertical(p.pressed)).shadow(p.pressed_sh.clone())) .tooltip(move |window, cx| Tooltip::new(tip.clone()).build(window, cx)) .on_click(cx.listener(|this, _, window, cx| this.toggle_scope_picker(window, cx))) .child(metal::badge(self.projects[ix].initials.clone(), self.projects[ix].tint, p)) .into_any_element(), None => MetalButton::new("project-scope") .ghost() .compact() .icon(IconName::Folder) .tooltip(tip) .selected(picker_open) .on_click(cx.listener(|this, _, window, cx| this.toggle_scope_picker(window, cx))) .into_any_element(), } }); v_flex() .flex_none() .relative() .px(px(8.)) .pb(px(8.)) .child( h_flex() .gap(px(4.)) .child(search) .child( h_flex() .flex_none() .children(scope_button) .when(has_projects, |this| { this.child( MetalButton::new("add-project") .ghost() .compact() .icon(Lucide::FolderPlus) .tooltip("Add project") .on_click(cx.listener(|this, _, window, cx| this.prompt_for_project(window, cx))), ) }) .child( div() .id("new-thread-tip") .tooltip(move |window, cx| { let tip = new_thread_tip.clone(); Tooltip::element(move |_, cx| { let p = metal::palette(cx); v_flex().gap(px(2.)).child(tip.clone()).when(groups > 1, |this| { this.child( div() .text_color(p.ink3) .child("New thread in current project: Shift+click (Ctrl+Shift+N)"), ) }) }) .build(window, cx) }) .child( MetalButton::new("new-thread") .ghost() .compact() .icon(Lucide::SquarePen) .disabled(!has_projects) .on_click(cx.listener(|this, event: &ClickEvent, window, cx| { this.new_thread_button(event.modifiers().shift, window, cx) })), ), ), ), ) .when(picker_open, |this| this.child(self.render_scope_picker(window, cx))) } /// The sidebar's New thread: with one project, or Shift held, a thread /// in the current project; otherwise the palette's project list. pub(super) fn new_thread_button(&mut self, in_current: bool, window: &mut Window, cx: &mut Context) { if self.projects.is_empty() { self.prompt_for_project(window, cx); } else if in_current || self.projects.len() <= 1 { let project = self.contextual_project(); self.new_thread(project, window, cx); } else { self.open_palette(super::palette::PaletteView::NewThreadIn, window, cx); } } /// The project a new thread starts in: the open thread's, or the first. pub(super) fn contextual_project(&self) -> usize { if self.active.is_some() && self.active_project < self.projects.len() { return self.active_project; } self.project_scope.as_ref().and_then(|key| self.project_by_key(key)).unwrap_or(0) } // ---- the project filter -------------------------------------------------- fn toggle_scope_picker(&mut self, window: &mut Window, cx: &mut Context) { if self.scope_picker.take().is_some() { cx.notify(); return; } let input = cx.new(|cx| InputState::new(window, cx).placeholder("Search projects...")); input.update(cx, |input, cx| input.focus(window, cx)); let subscription = cx.subscribe_in(&input, window, |this, _, event: &InputEvent, window, cx| match event { InputEvent::Change => { this.search_highlight = 0; cx.notify(); } InputEvent::PressEnter { .. } => this.pick_scope(this.search_highlight, window, cx), _ => {} }); self.search_highlight = 0; self.scope_picker = Some((input, subscription)); cx.notify(); } /// The scope picker's choices: `None` for All projects, then projects. fn scope_choices(&self, cx: &App) -> Vec> { let query = self .scope_picker .as_ref() .map(|(input, _)| input.read(cx).value().trim().to_lowercase()) .unwrap_or_default(); let mut choices = Vec::new(); if query.is_empty() { choices.push(None); } for ix in self.ordered_projects(cx) { let project = &self.projects[ix]; if query.is_empty() || project.name.to_lowercase().contains(&query) { choices.push(Some(ix)); } } choices } fn pick_scope(&mut self, choice_ix: usize, window: &mut Window, cx: &mut Context) { let choices = self.scope_choices(cx); let Some(choice) = choices.get(choice_ix.min(choices.len().saturating_sub(1))) else { return }; let scope = choice.map(|ix| self.project_key(ix)); self.scope_picker = None; self.set_project_scope(scope, cx); self.focus_handle.focus(window, cx); } fn render_scope_picker(&self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let Some((input, _)) = &self.scope_picker else { return div().into_any_element() }; let choices = self.scope_choices(cx); let highlight = self.search_highlight.min(choices.len().saturating_sub(1)); let hosts = self.projects.iter().any(|project| project.environment.is_some()); let rows: Vec = choices .iter() .enumerate() .map(|(row, choice)| { let selected = row == highlight; let base = h_flex() .id(("scope-choice", row)) .h(px(32.)) .px(px(8.)) .gap(px(8.)) .rounded(px(10.)) .cursor_pointer() .text_size(px(13.5)) .text_color(p.ink) .when(selected, |this| this.bg(p.menu_row_hover)) .hover(|style| style.bg(p.menu_row_hover)) .on_click(cx.listener(move |this, _, window, cx| this.pick_scope(row, window, cx))); match choice { None => base .child(Icon::new(IconName::Folder).size(px(15.)).text_color(p.icon)) .child(div().flex_1().child("All projects")) .into_any_element(), Some(ix) => { let ix = *ix; let project = &self.projects[ix]; let host = project.environment.as_deref().map(|env| self.environment_name(env)); let name = project.name.clone(); base.on_mouse_down( MouseButton::Right, cx.listener(move |this, _, window, cx| { this.scope_picker = None; this.open_project_settings(ix, window, cx); }), ) .child(metal::badge(project.initials.clone(), project.tint, p)) .child(div().flex_1().min_w_0().truncate().child(project.name.clone())) .when(hosts, |this| this.child(metal::tag(host.unwrap_or_else(|| "Local".into()), p))) .child( MetalButton::new(("scope-settings", ix)) .ghost() .mini() .icon(IconName::Settings) .tooltip(format!("Project settings for {name}")) .on_click(cx.listener(move |this, _, window, cx| { cx.stop_propagation(); this.scope_picker = None; this.open_project_settings(ix, window, cx); })), ) .into_any_element() } } }) .collect(); let empty = rows.is_empty(); div() .absolute() .top_full() .left(px(8.)) .child( deferred( anchored().snap_to_window_with_margin(px(8.)).child( pickers::panel(p) .key_context("ScopePicker") .on_action(cx.listener(|this, _: &SelectPrevious, _, cx| { let count = this.scope_choices(cx).len().max(1); this.search_highlight = (this.search_highlight + count - 1) % count; cx.notify(); })) .on_action(cx.listener(|this, _: &SelectNext, _, cx| { let count = this.scope_choices(cx).len().max(1); this.search_highlight = (this.search_highlight + 1) % count; cx.notify(); })) .on_action(cx.listener(|this, _: &Dismiss, _, cx| { this.scope_picker = None; cx.notify(); })) .on_mouse_down_out(cx.listener(|this, _, _, cx| { this.scope_picker = None; cx.notify(); })) .w(px(self.sidebar_width - 16.)) .max_w(px(288.)) .child( h_flex() .h(px(32.)) .px(px(8.)) .gap(px(8.)) .mb(px(4.)) .rounded(px(10.)) .well(p) .text_size(px(13.)) .child(Icon::new(IconName::Search).size(px(14.)).text_color(p.field_ink)) .child(div().flex_1().child(Input::new(input).appearance(false))), ) .when(empty, |this| { this.child(div().px(px(8.)).py(px(8.)).text_size(px(13.)).text_color(p.ink3).child("No matching projects.")) }) .child(v_flex().id("scope-choices").max_h(px(320.)).overflow_y_scroll().gap(px(1.)).children(rows)), ), ) .with_priority(1), ) .into_any_element() } // ---- the inbox ----------------------------------------------------------- fn render_inbox(&self, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let query = self.search_query(cx); if !query.is_empty() { return self.render_search_results(cx).into_any_element(); } let inbox = self.inbox(cx); let mut children: Vec = Vec::new(); let rows = self.visible_rows(cx); let jump_ix = |session: &Entity| rows.iter().take(9).position(|(_, row)| row == session); if !inbox.drafts.is_empty() { for (ix, session) in &inbox.drafts { children.push(self.render_draft_row(*ix, session, cx)); } children.push(div().mx(px(10.)).my(px(6.)).h(px(1.)).bg(p.rule.opacity(0.6)).into_any_element()); } let compact = settings::get(|settings| settings.compact_threads); for (ix, session) in inbox.pinned.iter().chain(&inbox.active) { let jump = jump_ix(session); children.push(if compact { self.render_slim_row(*ix, session, SlimKind::Card, jump, cx) } else { self.render_card_row(*ix, session, jump, cx) }); } let empty = inbox.is_empty(); let mut first_shelf = true; let active = self.active.clone(); let shelves: [(&'static str, &str, Vec, bool, ShelfTone, fn(&mut Self)); 3] = [ ("working-shelf", "Working", inbox.working, self.working_open, ShelfTone::Muted, |this| { this.working_open = !this.working_open }), ("snoozed-shelf", "Snoozed", inbox.snoozed, self.snoozed_open, ShelfTone::Info, |this| { this.snoozed_open = !this.snoozed_open }), ("settled-shelf", "Settled", inbox.settled, self.settled_open, ShelfTone::Muted, |this| { this.settled_open = !this.settled_open }), ]; for (id, label, threads, open, tone, toggle) in shelves { let always = id == "settled-shelf" && !empty; if threads.is_empty() && !always { continue; } let label = if open { label.to_string() } else { format!("{label} ({})", threads.len()) }; children.push(self.render_shelf_header(id, label, open, tone, first_shelf, toggle, cx)); first_shelf = false; let settled = id == "settled-shelf"; let limit = if settled { self.settled_shown } else { usize::MAX }; let mut hidden = 0; for (row, (ix, session)) in threads.iter().enumerate() { let is_active = active.as_ref() == Some(session); if !(open && row < limit) && !is_active { if open { hidden += 1; } continue; } let jump = jump_ix(session); children.push(match id { "working-shelf" if !compact => self.render_card_row(*ix, session, jump, cx), "working-shelf" => self.render_slim_row(*ix, session, SlimKind::Card, jump, cx), "snoozed-shelf" => self.render_slim_row(*ix, session, SlimKind::Snoozed, jump, cx), _ => self.render_slim_row(*ix, session, SlimKind::Settled, jump, cx), }); } if settled && hidden > 0 { let more = hidden.min(SETTLED_MORE); children.push( h_flex() .id("settled-more") .flex_none() .h(px(SLIM_HEIGHT)) .px(px(10.)) .gap(px(10.)) .rounded(px(12.)) .cursor_pointer() .text_size(px(13.5)) .text_color(p.ink3) .hover(|style| style.bg(p.row_hover).text_color(p.ink)) .on_click(cx.listener(|this, _, _, cx| { this.settled_shown += SETTLED_MORE; cx.notify(); })) .child(Icon::new(IconName::Plus).size(px(16.))) .child(format!("Show {more} more")) .into_any_element(), ); } } v_flex() .id("threads") .flex_1() .min_h_0() .overflow_y_scroll() .px(px(8.)) .pb(px(8.)) .gap(px(1.)) .when(empty, |this| this.child(self.render_inbox_empty(cx))) .children(children) .into_any_element() } fn render_inbox_empty(&self, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let message: SharedString = if self.projects.is_empty() { "No projects yet".into() } else if let Some(ix) = self.project_scope.as_ref().and_then(|key| self.project_by_key(key)) { format!("No threads in {} yet", self.projects[ix].name).into() } else { "No threads yet".into() }; v_flex() .items_center() .gap(px(10.)) .px(px(8.)) .py(px(24.)) .text_size(px(12.)) .text_color(p.ink3) .child(message) .when(self.projects.is_empty(), |this| { this.child( MetalButton::new("empty-add-project") .compact() .icon(IconName::Plus) .label("Add project") .on_click(cx.listener(|this, _, window, cx| this.prompt_for_project(window, cx))), ) }) .into_any_element() } /// A shelf header: the label, a rule across, and a chevron. #[allow(clippy::too_many_arguments)] fn render_shelf_header( &self, id: &'static str, label: String, open: bool, tone: ShelfTone, first: bool, toggle: fn(&mut Self), cx: &mut Context, ) -> AnyElement { let p = metal::palette(cx); let (ink, rule) = match tone { ShelfTone::Muted => (p.ink3, p.rule), ShelfTone::Info => (p.blue_ink, p.blue_ink.opacity(0.25)), }; h_flex() .id(id) .when(first, |this| this.mt_auto()) .flex_none() .h(px(32.)) .px(px(8.)) .gap(px(8.)) .rounded(px(10.)) .cursor_pointer() .text_size(px(12.)) .font_weight(FontWeight::MEDIUM) .text_color(ink) .hover(|style| style.bg(p.row_hover)) .when(id == "settled-shelf", |this| { let workspace = cx.weak_entity(); let hover = p.menu_row_hover; this.drag_over::(move |style, dragged, _, cx| { if dragged.session.read(cx).settled { style } else { style.bg(hover) } }) .on_drop(move |dragged: &DraggedThread, window, cx| { let session = dragged.session.clone(); let _ = workspace.update(cx, |this, cx| this.drop_thread(session, DropZone::Settled, window, cx)); }) }) .on_click(cx.listener(move |this, _, _, cx| { toggle(this); if !this.settled_open { this.settled_shown = SETTLED_PAGE; } cx.notify(); })) .child(label) .child(div().flex_1().h(px(1.)).bg(rule)) .child(Icon::new(if open { IconName::ChevronUp } else { IconName::ChevronDown }).size(px(12.))) .into_any_element() } // ---- rows ------------------------------------------------------------ /// Whether a row steps back: an idle thread with nothing new, or one /// that's busy on its own. fn recedes(&self, session: &Entity, status: Option, cx: &App) -> bool { let state = session.read(cx); let active = self.active.as_ref() == Some(session); let selected = self.selection.contains(&session.entity_id()); !active && !selected && status != Some(TopStatus::Input) && (matches!(status, Some(TopStatus::Working | TopStatus::Waiting)) || (matches!(status, None | Some(TopStatus::Approval)) && !state.unread && !state.woke)) } /// The status label in a card's corner, mapped onto the status lights. fn render_status(&self, session: &Entity, status: TopStatus, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let state = session.read(cx); let id = session.entity_id().as_u64() as usize; let (light, pulse, label, ink): (Option, bool, String, Hsla) = match status { TopStatus::Working => (Some(Led::Live), true, format!("Working {}", working_for(state)), p.blue_ink), TopStatus::Waiting => (None, false, "Waiting".into(), p.ink3), TopStatus::Approval => (Some(Led::Waiting), false, "Approval".into(), p.amber_ink), TopStatus::Input => (Some(Led::Waiting), true, "Input".into(), p.ink), TopStatus::Limited => (Some(Led::Waiting), false, "Limited".into(), p.amber), TopStatus::Failed => (Some(Led::Failed), false, "Failed".into(), p.red), TopStatus::Woke => (None, false, "Woke".into(), p.amber), TopStatus::Done => (Some(Led::Live), false, "Done".into(), p.green_ink), }; let content = h_flex() .gap(px(5.)) .text_size(px(12.)) .font_weight(FontWeight::MEDIUM) .text_color(ink) .when_some(light, |this, light| this.child(metal::led(("row-status", id), light, pulse, p))) .when(status == TopStatus::Woke, |this| this.child(Icon::new(Lucide::AlarmClock).size(px(13.)))) .child(label); if status == TopStatus::Woke { let session = session.clone(); return div() .id(("dismiss-woke", id)) .cursor_pointer() .tooltip(|window, cx| Tooltip::new("Dismiss Woke notification").build(window, cx)) .on_click(cx.listener(move |_, _, _, cx| { cx.stop_propagation(); session.update(cx, |session, cx| session.dismiss_woke(cx)); })) .child(content) .into_any_element(); } content.into_any_element() } /// The pill a row shows while Ctrl is held: `Ctrl+1`. fn jump_pill(jump: Option, show: bool, p: &Palette) -> Option { let ix = jump.filter(|_| show)?; Some( div() .absolute() .right(px(6.)) .top_0() .bottom_0() .flex() .items_center() .child(metal::keycap(format!("{JUMP_MODIFIER}+{}", ix + 1), p)) .into_any_element(), ) } /// The row's title, or the rename field while it's being renamed. fn row_title(&self, session: &Entity, size: f32, weight: FontWeight, ink: Hsla, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); if let Some(rename) = self.renaming.as_ref().filter(|rename| rename.sidebar && &rename.session == session) { return div() .key_context("InlineRename") .on_action(cx.listener(|this, _: &Dismiss, _, cx| this.cancel_rename(cx))) .flex_1() .min_w_0() .h(px(24.)) .px(px(6.)) .flex() .items_center() .rounded(px(6.)) .well(p) .text_size(px(size)) .font_weight(FontWeight::MEDIUM) .child(Input::new(&rename.input).appearance(false)) .into_any_element(); } let state = session.read(cx); div() .flex_1() .min_w_0() .truncate() .text_size(px(size)) .font_weight(weight) .text_color(ink) .when(state.regenerating_title, |this| this.opacity(0.55)) .child(state.title.clone()) .into_any_element() } /// Gives a row its hover card, except right after it was right-clicked: /// its menu is open then, and the card stays away until the pointer /// leaves the row. fn with_hover_card(&self, row: Stateful
, project_ix: usize, session: &Entity, cx: &mut Context) -> Stateful
{ let id = session.entity_id(); let suppressed = self.hover_card_hidden == Some(id); row.when(!suppressed, |row| row.tooltip(self.hover_card(project_ix, session, cx))) .on_mouse_down( MouseButton::Right, cx.listener(move |this, _, _, cx| { this.hover_card_hidden = Some(id); cx.notify(); }), ) .on_hover(cx.listener(move |this, hovered: &bool, _, cx| { if !hovered && this.hover_card_hidden == Some(id) { this.hover_card_hidden = None; cx.notify(); } })) } /// The hover card a row shows after a beat: title, project, host, /// branch, model, and how the last run went. fn hover_card(&self, project_ix: usize, session: &Entity, cx: &App) -> impl Fn(&mut Window, &mut App) -> AnyView + 'static { let state = session.read(cx); let project = &self.projects[project_ix]; let title = state.title.clone(); let project_name = project.name.clone(); let (initials, tint) = (project.initials.clone(), project.tint); let host = match &project.environment { Some(env) => self.environment_name(env), None => self.hostname.to_string(), }; let remote = project.environment.is_some(); let branch = self.thread_branch(session, cx); let checkout_differs = state.worktree.is_none() && project.git.as_ref().and_then(|git| git.branch.clone()) != branch && branch.is_some(); let model = state.active_model.clone().or(state.model.clone()).map(|model| { format!("{model} · {}", state.provider.label()) }); let tasks = state.background_tasks.len(); let failure = match top_status(state) { Some(TopStatus::Limited) => Some(("Usage limit reached", true)), Some(TopStatus::Failed) => Some(("Error occurred", false)), _ => None, }; let pull_request = state.pull_request.as_ref().map(|pr| (pr.number, pr.state.clone())); move |window, cx| { let (title, project_name, initials, host, branch, model, pull_request) = (title.clone(), project_name.clone(), initials.clone(), host.clone(), branch.clone(), model.clone(), pull_request.clone()); Tooltip::element(move |_, cx| { let p = metal::palette(cx); let line = |icon: Icon, text: String| { h_flex().gap(px(6.)).text_size(px(12.)).text_color(p.ink2).child(icon.size(px(12.)).text_color(p.ink3)).child(text) }; v_flex() .max_w(px(320.)) .gap(px(4.)) .child(div().text_size(px(12.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(title.clone())) .child( h_flex() .gap(px(6.)) .text_size(px(12.)) .text_color(p.ink2) .child(metal::badge(initials.clone(), tint, p)) .child(project_name.clone()), ) .child(line(Icon::new(if remote { Lucide::Server } else { Lucide::Laptop }), host.clone())) .when_some(branch.clone(), |this, branch| this.child(line(Icon::new(Lucide::GitBranch), branch))) .when(checkout_differs, |this| { this.child( h_flex() .gap(px(6.)) .text_size(px(12.)) .text_color(p.amber_ink) .child(Icon::new(IconName::CircleAlert).size(px(12.))) .child("You're currently checked out on another branch."), ) }) .when_some(model.clone(), |this, model| this.child(line(Icon::new(Lucide::Sparkles), model))) .when(tasks > 0, |this| { this.child(line( Icon::new(Lucide::Terminal), format!("{tasks} terminal process{} running", if tasks == 1 { "" } else { "es" }), )) }) .when_some(failure, |this, (text, warning)| { this.child( h_flex() .gap(px(6.)) .text_size(px(12.)) .text_color(if warning { p.amber_ink } else { p.red }) .child(Icon::new(IconName::CircleAlert).size(px(12.))) .child(text), ) }) .when_some(pull_request.clone(), |this, (number, state)| { this.child(div().pt(px(4.)).child(pr_badge(number, &state, p))) }) }) .build(window, cx) } } /// A pinned, active or working thread: three lines, with the status (or /// how long ago) in the corner, giving way to Snooze and Settle on hover. fn render_card_row(&self, project_ix: usize, session: &Entity, jump: Option, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let project = &self.projects[project_ix]; let state = session.read(cx); let id = session.entity_id(); let key = id.as_u64() as usize; let active = self.active.as_ref() == Some(session); let selected = self.selection.contains(&id); let status = top_status(state); let recedes = self.recedes(session, status, cx); let group = SharedString::from(format!("thread-{id}")); let branch = self.thread_branch(session, cx); let in_worktree = state.worktree.is_some() || state.pending_worktree.is_some(); let worktree_tip = state.worktree.as_ref().map(|worktree| { format!("Worktree: {} ({})", super::display_path(&worktree.root), worktree.branch) }); let host = project.environment.as_deref().map(|env| self.environment_name(env)); let tasks = state.background_tasks.len(); let pull_request = state.pull_request.as_ref().map(|pr| (pr.number, pr.state.clone())); let pinned = state.pinned; let can_snooze = Self::can_snooze(state); let unread_or_woke = state.unread || state.woke || status == Some(TopStatus::Input); let title_ink = if recedes { p.ink2 } else if unread_or_woke { p.ink } else { p.ink.opacity(0.9) }; let weight = if recedes { FontWeight::NORMAL } else { FontWeight::MEDIUM }; let time = relative_time(state.last_message.max(state.created)); let provider = state.provider; let armed = self.settle_armed.is_some_and(|(armed, at)| armed == id && at.elapsed() < super::SETTLE_ARMED); let working_dim = recedes && status == Some(TopStatus::Working); // The corner: status or time at rest, the hover actions on hover. let at_rest = match status { Some(status) => self.render_status(session, status, cx), None => div().font_family(metal::MONO).text_size(px(11.)).text_color(p.ink3).child(time).into_any_element(), }; let snooze_button = can_snooze.then(|| { let workspace = cx.weak_entity(); let session = session.clone(); MetalButton::new(("snooze-thread", key)) .ghost() .mini() .icon(Icon::new(Lucide::Clock).size(px(12.))) .tooltip("Snooze thread") .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, cx| { let p = metal::palette(cx); for preset in snooze_presets() { let workspace = workspace.clone(); let session = session.clone(); let until = preset.until; let (label, when) = (preset.label, preset.when.clone()); menu = menu.item( PopupMenuItem::element(move |_, _| { h_flex() .w_full() .gap(px(16.)) .child(div().flex_1().child(label)) .child(div().text_size(px(12.)).text_color(p.ink3).child(when.clone())) }) .on_click(move |_, window, cx| { let session = session.clone(); let _ = workspace.update(cx, |this, cx| this.snooze_threads(vec![session], until, window, cx)); }), ); } let workspace = workspace.clone(); let session = session.clone(); menu.min_w(px(200.)).separator().item(PopupMenuItem::new("Custom…").on_click(move |_, window, cx| { let session = session.clone(); let _ = workspace.update(cx, |this, cx| this.request_custom_snooze(vec![session], window, cx)); })) }) }); let settle_session = session.clone(); let settle_button = MetalButton::new(("settle-thread", key)) .ghost() .mini() .icon(Icon::new(IconName::Check).size(px(14.))) .label(if armed { "Confirm" } else { "Settle" }) .when(armed, |this| this.danger()) .tooltip("Settle thread") .on_click(cx.listener(move |this, _, window, cx| { cx.stop_propagation(); this.press_settle(settle_session.clone(), window, cx); })); let draft = !active && self.has_unsent(session, cx); let discard_session = session.clone(); let discard_button = draft.then(|| { MetalButton::new(("discard-unsent", key)) .ghost() .mini() .icon(Icon::new(Lucide::X).size(px(14.))) .tooltip("Discard draft") .on_click(cx.listener(move |this, _, window, cx| { cx.stop_propagation(); if let Some(chat) = this.chats.get(&discard_session.entity_id()).cloned() { chat.update(cx, |chat, cx| chat.clear_draft(window, cx)); } })) }); let hover_actions = h_flex().gap(px(1.)).children(discard_button).children(snooze_button).child(settle_button); let cover = if active { metal::vertical(p.row_on) } else { p.page.into() }; let corner = h_flex().ml_auto().flex_none().min_w(px(32.)).justify_end().child(if armed { hover_actions.into_any_element() } else { hover_swap(group.clone(), at_rest, hover_actions, Some((cover, (!active).then_some(p.row_hover)))) .into_any_element() }); let unpin = session.clone(); let line_one = h_flex() .h(px(20.)) .gap(px(6.)) .when(draft && !active, |this| { this.child(Icon::new(Lucide::SquarePen).size(px(12.)).text_color(p.amber_ink)) }) .child(metal::badge(project.initials.clone(), project.tint, p)) .child( div() .min_w_0() .truncate() .text_size(px(12.)) .font_weight(if recedes { FontWeight::NORMAL } else { FontWeight::MEDIUM }) .text_color(p.ink2) .child(project.name.clone()), ) .when(pinned, |this| { this.child( div() .id(("unpin", key)) .group(format!("unpin-{key}")) .flex_none() .cursor_pointer() .tooltip(|window, cx| Tooltip::new("Unpin thread").build(window, cx)) .on_click(cx.listener(move |this, _, window, cx| { cx.stop_propagation(); this.unpin_thread(unpin.clone(), window, cx); })) .child(hover_swap( format!("unpin-{key}").into(), Icon::new(Lucide::Pin).size(px(12.)).text_color(p.ink3), Icon::new(Lucide::PinOff).size(px(12.)).text_color(p.ink2), None, )), ) }) .child(corner); let line_three = h_flex() .mt(px(2.)) .h(px(16.)) .gap(px(6.)) .text_size(px(12.)) .text_color(p.ink2) .map(|this| match &branch { Some(branch) => this .when(in_worktree, |this| { this.child( div() .id(("worktree-icon", key)) .when_some(worktree_tip.clone(), |this, tip| { this.tooltip(move |window, cx| Tooltip::new(tip.clone()).build(window, cx)) }) .child(Icon::new(Lucide::FolderGit2).size(px(12.)).text_color(p.ink3.opacity(0.7))), ) }) .child( div() .min_w_0() .truncate() .font_family(metal::MONO) .text_size(px(11.)) .text_color(p.ink3) .child(middle_truncate(branch, 32)), ), None => this .child(Icon::new(if host.is_some() { Lucide::Server } else { Lucide::Laptop }).size(px(12.)).text_color(p.ink3)) .child(div().min_w_0().truncate().text_color(p.ink3).child(host.clone().unwrap_or_else(|| self.hostname.to_string()))), }) .when(tasks > 0, |this| this.child(terminal_icon(key, tasks, p))) .when_some(pull_request, |this, (number, state)| this.child(pr_badge(number, &state, p))) .child(div().flex_1()) .when(host.is_some() && branch.is_some(), |this| { this.child(Icon::new(Lucide::Server).size(px(13.)).text_color(p.ink3)) }) .child(div().opacity(0.6).child(pickers::provider_icon(provider, px(13.), p))); let show_hints = self.jump_hints; let surface = v_flex() .id(("thread", key)) .group(group) .relative() .h(px(CARD_HEIGHT)) .px(px(10.)) .py(px(8.)) .rounded(px(14.)) .cursor_pointer() .map(|this| { if active { this.raised(p) } else if selected { this.bg(metal::vertical(p.pressed)).shadow(p.pressed_sh.clone()) } else if draft { this.bg(p.amber.opacity(0.06)) } else { this } }) .when(working_dim, |this| this.opacity(0.7)) // A row takes one hover style, so the tint and the undimming share it. .map(|this| { let tint = match (active || selected, draft) { (true, _) => None, (false, true) => Some(p.amber.opacity(0.12)), (false, false) => Some(p.row_hover), }; if tint.is_none() && !working_dim { return this; } this.hover(move |style| { let style = match tint { Some(tint) => style.bg(tint), None => style, }; if working_dim { style.opacity(1.) } else { style } }) }) .map(|this| self.drag_and_drop(this, session, if pinned { DropZone::Pinned } else { DropZone::Active }, cx)) .map(|this| self.with_hover_card(this, project_ix, session, cx)) .on_click({ let session = session.clone(); cx.listener(move |this, event: &ClickEvent, window, cx| this.click_row(project_ix, session.clone(), event, window, cx)) }) .child(line_one) .child(div().mt(px(4.)).h(px(20.)).flex().items_center().child(self.row_title(session, 14., weight, title_ink, cx))) .child(line_three) .children(Self::jump_pill(jump, show_hints, p)); div() .flex_none() .py(px(1.)) .child(surface.context_menu(self.row_menu(session, cx))) .into_any_element() } /// A snoozed or settled thread (or any card, in compact mode): one line /// with the countdown, the time, or Woke at the end. fn render_slim_row( &self, project_ix: usize, session: &Entity, kind: SlimKind, jump: Option, cx: &mut Context, ) -> AnyElement { let p = metal::palette(cx); let project = &self.projects[project_ix]; let state = session.read(cx); let id = session.entity_id(); let key = id.as_u64() as usize; let active = self.active.as_ref() == Some(session); let selected = self.selection.contains(&id); let status = top_status(state); let recedes = self.recedes(session, status, cx); let group = SharedString::from(format!("slim-{id}")); let settled = kind == SlimKind::Settled; let tasks = state.background_tasks.len(); let pull_request = state.pull_request.as_ref().map(|pr| (pr.number, pr.state.clone())); let title_ink = if active || state.woke || status == Some(TopStatus::Input) { p.ink } else if state.unread && !recedes { p.ink3 } else { p.ink2.opacity(0.7) }; let woke = state.woke && kind != SlimKind::Snoozed; let pinned = state.pinned; let snoozed_until = state.snoozed_until; let settled_at = state.settled_at.unwrap_or(state.updated); let last_message = state.last_message.max(state.created); let draft = !active && self.has_unsent(session, cx); let time: AnyElement = match kind { SlimKind::Snoozed => div() .text_size(px(12.)) .text_color(p.blue_ink) .child(snoozed_until.map(wake_countdown).unwrap_or_else(|| "now".into())) .into_any_element(), _ if woke => self.render_status(session, TopStatus::Woke, cx), SlimKind::Settled => div() .font_family(metal::MONO) .text_size(px(11.)) .text_color(p.ink3) .child(relative_time(settled_at)) .into_any_element(), SlimKind::Card => match status { Some(status) => self.render_status(session, status, cx), None => div() .font_family(metal::MONO) .text_size(px(11.)) .text_color(p.ink3) .child(relative_time(last_message)) .into_any_element(), }, }; let action_session = session.clone(); let action = match kind { SlimKind::Snoozed => MetalButton::new(("wake-thread", key)) .ghost() .mini() .icon(Icon::new(Lucide::AlarmClockOff).size(px(12.))) .on_click(cx.listener(move |this, _, _, cx| { cx.stop_propagation(); this.wake_thread(action_session.clone(), cx); })), SlimKind::Settled => MetalButton::new(("unsettle-thread", key)) .ghost() .mini() .icon(Icon::new(IconName::Undo2).size(px(14.))) .tooltip("Un-settle thread") .on_click(cx.listener(move |this, _, _, cx| { cx.stop_propagation(); this.unsettle_thread(action_session.clone(), cx); })), SlimKind::Card => MetalButton::new(("settle-thread", key)) .ghost() .mini() .icon(Icon::new(IconName::Check).size(px(12.))) .on_click(cx.listener(move |this, _, window, cx| { cx.stop_propagation(); this.press_settle(action_session.clone(), window, cx); })), }; let show_hints = self.jump_hints; let surface = h_flex() .id(("thread", key)) .group(group.clone()) .relative() .flex_none() .h(px(SLIM_HEIGHT)) .px(px(10.)) .gap(px(10.)) .rounded(px(12.)) .cursor_pointer() .map(|this| { if active { this.raised(p) } else if selected { this.bg(metal::vertical(p.pressed)).shadow(p.pressed_sh.clone()) } else { this.hover(|style| style.bg(p.row_hover)) } }) .map(|this| { let zone = match kind { SlimKind::Settled => DropZone::Settled, SlimKind::Snoozed => DropZone::None, SlimKind::Card if pinned => DropZone::Pinned, SlimKind::Card => DropZone::Active, }; self.drag_and_drop(this, session, zone, cx) }) .map(|this| self.with_hover_card(this, project_ix, session, cx)) .on_click({ let session = session.clone(); cx.listener(move |this, event: &ClickEvent, window, cx| this.click_row(project_ix, session.clone(), event, window, cx)) }) .child( div() .when(!active, |this| this.opacity(0.4).group_hover(group.clone(), |style| style.opacity(1.))) .child(metal::badge(project.initials.clone(), project.tint, p)), ) .when(draft && !active, |this| this.child(Icon::new(Lucide::SquarePen).size(px(12.)).text_color(p.amber_ink))) .child( div() .flex_1() .min_w_0() .flex() .items_center() .when(settled && !active, |this| this.opacity(0.85)) .child(self.row_title(session, 13.5, FontWeight::NORMAL, title_ink, cx)), ) .when(pinned, |this| this.child(Icon::new(Lucide::Pin).size(px(12.)).text_color(p.ink3))) .when(tasks > 0, |this| this.child(terminal_icon(key, tasks, p))) .when_some(pull_request, |this, (number, state)| this.child(pr_badge(number, &state, p))) .child( h_flex() .flex_none() .h(px(24.)) .min_w(px(32.)) .justify_end() .child(hover_swap(group.clone(), time, action, None)), ) .children(Self::jump_pill(jump, show_hints, p)); surface.context_menu(self.row_menu(session, cx)).into_any_element() } /// An unsent draft: the project, the first line typed, and a Discard /// button on hover. fn render_draft_row(&self, project_ix: usize, session: &Entity, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let project = &self.projects[project_ix]; let id = session.entity_id(); let key = id.as_u64() as usize; let active = self.active.as_ref() == Some(session); let group = SharedString::from(format!("draft-{id}")); let (text, attachments) = self.draft_of(session, cx).unwrap_or_default(); let first_line = text.lines().map(str::trim).find(|line| !line.is_empty()).map(str::to_string); let summary = first_line.unwrap_or_else(|| format!("{attachments} attachment{}", if attachments == 1 { "" } else { "s" })); let discard = session.clone(); let workspace = cx.weak_entity(); let menu_session = session.clone(); h_flex() .flex_none() .py(px(1.)) .child( v_flex() .id(("draft", key)) .group(group.clone()) .flex_1() // A long first line truncates instead of widening the card. .min_w_0() .overflow_hidden() .h(px(CARD_HEIGHT)) .px(px(10.)) .py(px(8.)) .rounded(px(14.)) .cursor_pointer() .map(|this| { if active { this.raised(p) } else { this.bg(p.amber.opacity(0.06)).hover(|style| style.bg(p.amber.opacity(0.12))) } }) .on_click({ let session = session.clone(); cx.listener(move |this, _, window, cx| this.select(project_ix, session.clone(), window, cx)) }) .child( h_flex() .h(px(20.)) .gap(px(6.)) .child(Icon::new(Lucide::SquarePen).size(px(12.)).text_color(p.amber_ink)) .child(metal::badge(project.initials.clone(), project.tint, p)) .child( div() .flex_1() .min_w_0() .truncate() .text_size(px(12.)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink2) .child(project.name.clone()), ) .child( div().invisible().group_hover(group.clone(), |style| style.visible()).child( MetalButton::new(("discard-draft", key)) .ghost() .mini() .icon(Icon::new(Lucide::X).size(px(12.))) .tooltip("Discard draft") .on_click(cx.listener(move |this, _, window, cx| { cx.stop_propagation(); this.discard_draft(discard.clone(), window, cx); })), ), ), ) .child( div() .mt(px(4.)) .w_full() .min_w_0() .truncate() .text_size(px(14.)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink.opacity(0.9)) .child(summary), ) .context_menu(move |menu, window, cx| { Workspace::draft_menu(workspace.clone(), menu_session.clone(), menu, window, cx) }), ) .into_any_element() } /// The search results: favicon, title, how long ago, and where in the /// conversation it matched. fn render_search_results(&self, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let hits = self.search_hits(cx); let highlight = self.search_highlight.min(hits.len().saturating_sub(1)); let rows: Vec = hits .iter() .enumerate() .map(|(row, hit)| { let (ix, session) = hit.entry.clone(); let project = &self.projects[ix]; let state = session.read(cx); let highlighted = row == highlight; h_flex() .id(("search-hit", row)) .flex_none() .min_h(px(36.)) .px(px(10.)) .py(px(4.)) .gap(px(10.)) .rounded(px(12.)) .cursor_pointer() .text_size(px(14.)) .map(|this| if highlighted { this.raised(p).text_color(p.ink) } else { this.text_color(p.ink2) }) .hover(|style| style.bg(p.row_hover)) .on_click(cx.listener(move |this, _, window, cx| { this.search_highlight = row; this.select(ix, session.clone(), window, cx); })) .child(metal::badge(project.initials.clone(), project.tint, p)) .child( v_flex() .flex_1() .min_w_0() .child( h_flex() .gap(px(8.)) .child(div().flex_1().min_w_0().truncate().child(state.title.clone())) .child( div() .flex_none() .font_family(metal::MONO) .text_size(px(11.)) .text_color(p.ink3) .child(relative_time(state.updated)), ), ) .when_some(hit.excerpt.clone(), |this, excerpt| { this.child(div().truncate().text_size(px(12.)).text_color(p.ink3).child(excerpt)) }), ) .into_any_element() }) .collect(); v_flex() .id("thread-search-results") .flex_1() .min_h_0() .overflow_y_scroll() .px(px(8.)) .gap(px(1.)) .when(rows.is_empty(), |this| { this.child(div().px(px(8.)).py(px(24.)).text_size(px(12.)).text_color(p.ink3).flex().justify_center().child("No threads found")) }) .children(rows) } // ---- the foot ------------------------------------------------------------ fn render_sidebar_footer(&self, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let notice = self.undo_notice(); v_flex() .flex_none() .px(px(8.)) .py(px(4.)) .gap(px(8.)) .when_some(notice, |this, notice| { this.child( h_flex() .flex_wrap() .px(px(12.)) .py(px(8.)) .rounded(px(12.)) .well(p) .text_size(px(12.5)) .text_color(p.ink2) .child(format!("{notice},\u{a0}")) .child( div() .id("undo-thread-action") .cursor_pointer() .font_weight(FontWeight::MEDIUM) .text_color(p.ink) .hover(|style| style.underline()) .on_click(cx.listener(|this, _, window, cx| { this.undo_latest(window, cx); })) .child("Ctrl+Z to undo"), ), ) }) .child( h_flex() .h(px(40.)) .gap(px(2.)) .child( MetalButton::new("open-settings") .ghost() .icon(IconName::Settings) .tooltip("Settings") .on_click(cx.listener(|this, _, window, cx| this.open_settings(window, cx))), ) .child( MetalButton::new("open-usage") .ghost() .icon(Lucide::ChartNoAxesColumn) .tooltip("Usage") .selected(self.usage_open) .on_click(cx.listener(|this, _, window, cx| this.toggle_usage(window, cx))), ), ) } } /// Two things in one spot: `rest`, and `hover` while the `group` is /// hovered. It swaps visibility rather than display, so the layout stays /// the same from frame to frame. `cover` backs the hover side (a base and /// an overlay) where it may run over text beside it. pub(super) fn hover_swap( group: SharedString, rest: impl IntoElement, hover: impl IntoElement, cover: Option<(Background, Option)>, ) -> Div { let hover = div().flex().items_center().child(hover); let hover = match cover { Some((base, overlay)) => div() .rounded(px(8.)) .bg(base) .child(div().rounded(px(8.)).when_some(overlay, |this, overlay| this.bg(overlay)).child(hover)), None => hover, }; div() .relative() .flex() .items_center() .justify_end() .child(div().flex().items_center().group_hover(group.clone(), |style| style.invisible()).child(rest)) .child( div() .absolute() .right_0() .top_0() .bottom_0() .flex() .items_center() .invisible() .group_hover(group, |style| style.visible()) .child(hover), ) } /// A thread row being dragged to another shelf. #[derive(Clone)] pub(super) struct DraggedThread { session: Entity, title: SharedString, } impl Render for DraggedThread { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); div() .max_w(px(240.)) .px(px(12.)) .py(px(6.)) .rounded(px(12.)) .raised(p) .truncate() .text_size(px(13.)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink) .child(self.title.clone()) } } /// Where a dragged thread lands, and so what dropping it does: pin, unpin /// (or wake, or un-settle) among the active cards, or settle. #[derive(Clone, Copy, PartialEq, Eq)] pub(super) enum DropZone { Pinned, Active, Settled, None, } impl DropZone { /// Whether dropping this thread here changes anything. fn accepts(self, session: &Session) -> bool { match self { DropZone::Pinned => !session.pinned || session.settled || session.is_snoozed(), DropZone::Active => session.pinned || session.settled || session.is_snoozed(), DropZone::Settled => !session.settled, DropZone::None => false, } } } impl Workspace { /// Makes a row draggable, and a place to drop other rows. fn drag_and_drop(&self, row: Stateful
, session: &Entity, zone: DropZone, cx: &mut Context) -> Stateful
{ let p = metal::palette(cx); let dragged = DraggedThread { session: session.clone(), title: session.read(cx).title.clone() }; let row = row.on_drag(dragged, |dragged, _, _, cx| cx.new(|_| dragged.clone())); if zone == DropZone::None { return row; } let workspace = cx.weak_entity(); let ring = p.green_ink.opacity(0.7); let own = session.entity_id(); row.drag_over::(move |style, dragged, _, cx| { if dragged.session.entity_id() != own && zone.accepts(dragged.session.read(cx)) { style.border_1().border_color(ring) } else { style } }) .on_drop(move |dragged: &DraggedThread, window, cx| { let session = dragged.session.clone(); let _ = workspace.update(cx, |this, cx| this.drop_thread(session, zone, window, cx)); }) } /// Applies a drop: pins, unpins, wakes, un-settles or settles. fn drop_thread(&mut self, session: Entity, zone: DropZone, window: &mut Window, cx: &mut Context) { let (pinned, settled, snoozed) = { let state = session.read(cx); (state.pinned, state.settled, state.is_snoozed()) }; match zone { DropZone::Pinned | DropZone::Active => { if settled { self.unsettle_thread(session.clone(), cx); } if snoozed { self.wake_thread(session.clone(), cx); } if zone == DropZone::Pinned && !pinned { session.update(cx, |session, cx| session.set_pinned(true, cx)); } else if zone == DropZone::Active && pinned { self.unpin_thread(session, window, cx); } } DropZone::Settled if !settled => self.settle_threads(vec![session], window, cx), _ => {} } } } /// Which slim row: a snoozed or settled thread, or a card in compact mode. #[derive(Clone, Copy, PartialEq, Eq)] pub(super) enum SlimKind { Card, Snoozed, Settled, } /// A thread's pull request: the state's icon and color, and its number. pub(super) fn pr_badge(number: u64, state: &str, p: &Palette) -> AnyElement { let (icon, ink) = match state { "MERGED" => (Lucide::GitMerge, p.blue_ink), "CLOSED" => (Lucide::GitPullRequestClosed, p.red), "DRAFT" => (Lucide::GitPullRequestDraft, p.ink3), _ => (Lucide::GitPullRequestArrow, p.green_ink), }; h_flex() .flex_none() .gap(px(3.)) .text_size(px(12.)) .text_color(ink) .child(Icon::new(icon).size(px(12.))) .child(format!("#{number}")) .into_any_element() } /// The pulsing terminal mark for threads with processes still running. fn terminal_icon(key: usize, tasks: usize, p: &Palette) -> AnyElement { let tip = format!("{tasks} terminal process{} running", if tasks == 1 { "" } else { "es" }); div() .id(("terminal-processes", key)) .flex_none() .tooltip(move |window, cx| Tooltip::new(tip.clone()).build(window, cx)) .child( Icon::new(Lucide::Terminal).size(px(14.)).text_color(p.green_ink).with_animation( ("terminal-pulse", key), Animation::new(std::time::Duration::from_millis(1600)).repeat().with_easing(pulsating_between(0.4, 1.)), |icon, delta| icon.opacity(delta), ), ) .into_any_element() } /// Shortens a long branch name in the middle: `feature/…/sidebar`. pub(super) fn middle_truncate(text: &str, max: usize) -> String { let count = text.chars().count(); if count <= max { return text.to_string(); } let head = (max - 1) / 2; let tail = max - 1 - head; let start: String = text.chars().take(head).collect(); let end: String = text.chars().skip(count - tail).collect(); format!("{start}…{end}") } /// Where the query appears in the conversation, as a short excerpt. fn content_match(state: &Session, query: &str) -> Option { for item in &state.items { let text: &str = match item { ChatItem::User { text, .. } => text, ChatItem::Assistant(assistant) => &assistant.text, _ => continue, }; // ASCII folding keeps byte offsets lined up with `text`. let lower = text.to_ascii_lowercase(); if let Some(at) = lower.find(&query.to_ascii_lowercase()) { let start = lower[..at].char_indices().rev().nth(24).map_or(0, |(ix, _)| ix); let excerpt: String = text[start..].chars().take(80).collect(); let excerpt = excerpt.split_whitespace().collect::>().join(" "); return Some(if start > 0 { format!("…{excerpt}") } else { excerpt }); } } None }