Vectrum
Sign In
2164 lines · 94.5 KB · Rust
//! 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::update::{NOTES_SHOWN, Stage, Updater};
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> {
    // 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<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());
                // 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<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))
                } 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<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()
                    // 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<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")
                            .selected(self.usage_open)
                            .on_click(cx.listener(|this, _, window, cx| this.toggle_usage(window, cx))),
                    )
                    // Empryo's map of the open thread's code, while the thread runs on Empryo.
                    .when(self.genome_available(cx), |this| {
                        this.child(
                            MetalButton::new("open-genome")
                                .ghost()
                                .icon(Lucide::Dna)
                                .tooltip("Genome map")
                                .selected(self.surface_open(super::surfaces::Surface::Genome))
                                .on_click(cx.listener(|this, _, window, cx| this.toggle_genome(window, cx))),
                        )
                    })
                    .child(div().flex_1())
                    .child(self.render_update_button(cx)),
            )
    }

    /// T3's update button in the corner: check for updates, then download
    /// one, then restart into it; with this app up to date, update the
    /// connected devices that are behind. Its hover card says what changed.
    fn render_update_button(&self, cx: &mut Context<Self>) -> AnyElement {
        let p = metal::palette(cx);
        let updating = self.updating_environments();
        let servers_updating = updating.len();
        // One that's updating says how far it got.
        let updating_stage = match updating.as_slice() {
            [environment] => environment.updating.as_ref().map(|update| match (update.state.as_str(), update.progress) {
                ("downloading", Some(progress)) => format!(": downloading, {}%", (progress * 100.) as u32),
                ("downloading", None) => ": downloading".to_string(),
                ("restarting", _) => ": restarting".to_string(),
                _ => String::new(),
            }),
            _ => None,
        };
        let servers = self.outdated_environments().iter().filter(|env| env.updating.is_none()).count();
        // Each state is its own button, so a change drops the old one's
        // tooltip rather than leaving it up with stale words.
        let button = |id: &'static str| MetalButton::new(id).ghost();
        let spinner = |id: &'static str| {
            Icon::new(IconName::LoaderCircle).size(px(16.)).with_animation(
                id,
                Animation::new(std::time::Duration::from_millis(900)).repeat(),
                |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
            )
        };
        let with_dot = |icon: Icon, color: Hsla| {
            div()
                .relative()
                .size(px(16.))
                .child(icon.size(px(16.)))
                .child(div().absolute().top(px(-2.)).right(px(-2.)).size(px(6.)).rounded_full().bg(color))
        };
        match &Updater::global(cx).stage {
            Stage::Downloading { .. } => {
                button("update-downloading").leading(spinner("update-downloading")).hoverable_tooltip(update_card).into_any_element()
            }
            Stage::Ready(_) => button("update-restart")
                .leading(with_dot(Icon::new(Lucide::RotateCw), p.green_deep))
                .hoverable_tooltip(update_card)
                .on_click(cx.listener(|this, _, window, cx| this.restart_to_update(window, cx)))
                .into_any_element(),
            Stage::Available(_) | Stage::Failed { release: Some(_), .. } => {
                let failed = matches!(Updater::global(cx).stage, Stage::Failed { .. });
                button(if failed { "update-retry" } else { "update-download" })
                    .leading(with_dot(Icon::new(Lucide::Download), if failed { p.red } else { p.ink }))
                    .hoverable_tooltip(update_card)
                    .on_click(cx.listener(|_, _, _, cx| Updater::download(cx)))
                    .into_any_element()
            }
            _ if servers_updating > 0 => button("servers-updating")
                .leading(spinner("servers-updating"))
                .tooltip(format!(
                    "Updating {servers_updating} server{}{}…",
                    if servers_updating == 1 { "" } else { "s" },
                    updating_stage.unwrap_or_default()
                ))
                .into_any_element(),
            _ if servers > 0 => button("update-servers")
                .icon(Lucide::CircleArrowUp)
                .tooltip(format!("Update {servers} server{}", if servers == 1 { "" } else { "s" }))
                .on_click(cx.listener(|this, _, _, cx| this.update_environments(cx)))
                .into_any_element(),
            Stage::Checking => button("update-checking").leading(spinner("update-checking")).tooltip("Checking for updates…").into_any_element(),
            _ => button("update-check")
                .icon(IconName::RefreshCw)
                .tooltip("Check for updates")
                .on_click(cx.listener(|_, _, _, cx| Updater::check(true, cx)))
                .into_any_element(),
        }
    }

    /// Restarts into the downloaded update, asking first while threads work.
    fn restart_to_update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let working = self
            .projects
            .iter()
            .flat_map(|project| &project.sessions)
            .filter(|session| session.read(cx).remote.is_none() && session.read(cx).is_working())
            .count();
        if working == 0 {
            Updater::restart(cx);
            return;
        }
        let message = format!(
            "Restart to update?\n{working} thread{} still working; restarting stops {}.",
            if working == 1 { " is" } else { "s are" },
            if working == 1 { "it" } else { "them" },
        );
        super::actions::confirm(&message, false, window, cx, |_, cx| Updater::restart(cx));
    }
}

/// The update button's hover card: what a click does, and the release's
/// notes with a link to the rest. It reads the updater as it draws, so a
/// download's progress ticks while it's open.
fn update_card(window: &mut Window, cx: &mut App) -> AnyView {
    Tooltip::element(|_, cx| {
        let p = metal::palette(cx);
        let stage = &Updater::global(cx).stage;
        let Some(release) = stage.release() else { return v_flex() };
        let version = &release.version;
        let title = match stage {
            Stage::Downloading { done, total, .. } => {
                let percent = if *total == 0 { 0 } else { done * 100 / total };
                format!("Downloading update {version}… {percent}%")
            }
            Stage::Ready(_) => format!("Update {version} downloaded. Click to restart and install."),
            Stage::Failed { error, .. } => format!("Couldn't download update {version}: {error}. Click to try again."),
            _ if Updater::can_install() => format!("Update {version} available. Click to download."),
            _ => format!("Update {version} available. Click to open its download page."),
        };
        let shown = release.notes.len().min(NOTES_SHOWN);
        let more = release.notes.len() - shown;
        let page = release.page.clone();
        let label = match more {
            0 => "Release on Vectrum".to_string(),
            1 => "1 more change on Vectrum".to_string(),
            more => format!("{more} more changes on Vectrum"),
        };
        v_flex()
            .max_w(px(340.))
            .gap(px(8.))
            .child(div().text_size(px(13.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(title))
            .when(shown > 0, |this| {
                this.child(div().text_size(px(12.)).font_weight(FontWeight::SEMIBOLD).text_color(p.ink).child("What's changed"))
                    .child(v_flex().gap(px(4.)).children(release.notes[..shown].iter().map(|note| {
                        h_flex()
                            .items_start()
                            .gap(px(6.))
                            .text_size(px(12.))
                            .text_color(p.ink2)
                            .child("•")
                            .child(div().flex_1().min_w_0().child(note.clone()))
                    })))
            })
            .child(
                h_flex()
                    .id("update-release-page")
                    .gap(px(4.))
                    .cursor_pointer()
                    .text_size(px(12.))
                    .text_color(p.ink2)
                    .underline()
                    .hover(|style| style.text_color(p.ink))
                    .on_click(move |_, _, _| super::open_url(&page))
                    .child(label)
                    .child(Icon::new(IconName::ExternalLink).size(px(12.))),
            )
    })
    .build(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
}