//! 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<TopStatus> {
let approval = state.items.iter().any(|item| {
matches!(item, ChatItem::Tool(ToolCall { permission: Some(prompt), .. }) 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<ThreadEntry>,
pub pinned: Vec<ThreadEntry>,
pub active: Vec<ThreadEntry>,
pub working: Vec<ThreadEntry>,
pub snoozed: Vec<ThreadEntry>,
pub settled: Vec<ThreadEntry>,
}
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<String>,
}
/// 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<Session>, 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<Session>, 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());
if state.archived {
continue;
}
if state.is_blank() {
if 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<ThreadEntry> {
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<ThreadEntry> {
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<ThreadEntry> = 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<SearchHit> {
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<Self>) {
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<Self>) {
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>) {
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<Session>, event: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
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<Entity<Session>> {
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<Session>, cx: &mut Context<Self>) -> impl Fn(gpui_kit::component::menu::PopupMenu, &mut Window, &mut Context<gpui_kit::component::menu::PopupMenu>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) {
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<Self>) {
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<Option<usize>> {
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<Self>) {
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<Self>) -> 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<AnyElement> = 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<Self>) -> 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<AnyElement> = Vec::new();
let rows = self.visible_rows(cx);
let jump_ix = |session: &Entity<Session>| 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<ThreadEntry>, 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<Self>) -> 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<Self>,
) -> 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::<DraggedThread>(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<Session>, status: Option<TopStatus>, 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<Session>, status: TopStatus, cx: &mut Context<Self>) -> 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<Led>, 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<usize>, show: bool, p: &Palette) -> Option<AnyElement> {
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<Session>, size: f32, weight: FontWeight, ink: Hsla, cx: &mut Context<Self>) -> 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<Div>, project_ix: usize, session: &Entity<Session>, cx: &mut Context<Self>) -> Stateful<Div> {
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<Session>, 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<Session>, jump: Option<usize>, cx: &mut Context<Self>) -> 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)).hover(|style| style.bg(p.amber.opacity(0.12)))
} else {
this.hover(|style| style.bg(p.row_hover))
}
})
.when(working_dim, |this| this.opacity(0.7).hover(|style| style.opacity(1.)))
.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<Session>,
kind: SlimKind,
jump: Option<usize>,
cx: &mut Context<Self>,
) -> 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<Session>, cx: &mut Context<Self>) -> 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()
.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.))
.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<Self>) -> 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<AnyElement> = 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<Self>) -> 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")
.on_click(cx.listener(|this, _, window, cx| this.open_settings_page("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<Hsla>)>,
) -> 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<Session>,
title: SharedString,
}
impl Render for DraggedThread {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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<Div>, session: &Entity<Session>, zone: DropZone, cx: &mut Context<Self>) -> Stateful<Div> {
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::<DraggedThread>(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<Session>, zone: DropZone, window: &mut Window, cx: &mut Context<Self>) {
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<String> {
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::<Vec<_>>().join(" ");
return Some(if start > 0 { format!("…{excerpt}") } else { excerpt });
}
}
None
}