Vectrum
Sign In
2556 lines · 105.4 KB · Rust
//! The window root, laid out like T3 Code: a searchable list of threads on
//! the left, the conversation on a raised sheet, and a project panel (editor,
//! branch, commit, changes) on the right. It also keeps threads on disk and
//! looks after them in the background: waking snoozed ones, continuing ones a
//! usage limit stopped, settling idle ones, and checking on the agents.

use std::{
    collections::{HashMap, HashSet},
    hash::{Hash as _, Hasher as _},
    path::PathBuf,
    process::{Command, Stdio},
    rc::Rc,
    time::{Duration, Instant, SystemTime},
};

use gpui_kit::component::{
    ActiveTheme as _, Icon, IconName, WindowExt as _,
    button::ButtonVariant,
    h_flex,
    input::{Input, InputEvent, InputState},
    menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem},
    notification::Notification,
    v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;

use crate::claude::PermissionMode;
use crate::clock;
use crate::git::{self, GitInfo};
use crate::models::{Catalog, ModelEntry, Provider, split_key};
use crate::session::{NewSession, Reasoning, Session, SessionStatus, from_millis, to_millis};
use crate::settings::{self, BackgroundActivity, ProjectOrder, WorkspaceMode};
use crate::store::{self, AppState, SavedProject};
use crate::ui::chat::{ChatEvent, ChatView, display_path};
use crate::ui::folder_browser::{self, FolderBrowser, FolderBrowserEvent};
use crate::ui::icons::Lucide;
use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette};
use crate::ui::pickers;
use crate::ui::settings::{SettingsEvent, SettingsView};
use crate::worktree::{self, WorktreePlan};
use crate::{FocusSearch, NewThread, OpenProject, OpenSettings, ToggleProjectPanel, ToggleSidebar};

mod remote;

use remote::RemoteState;

pub(crate) const SIDEBAR_WIDTH: f32 = 280.;
const PANEL_WIDTH: f32 = 292.;
/// Below this window width the project panel stays out of the conversation's way.
const PANEL_MIN_WINDOW: f32 = 1120.;
/// The gap between the sheet and the window edge, and the sheet's rounding.
pub(crate) const SHEET_INSET: f32 = 8.;
pub(crate) const SHEET_RADIUS: f32 = 24.;
const TICK: Duration = Duration::from_secs(2);
const GIT_POLL: Duration = Duration::from_secs(4);
const HOUSEKEEPING: Duration = Duration::from_secs(15);
const PR_CHECK: Duration = Duration::from_secs(300);
/// How long a settle button stays armed for its confirming second click.
const SETTLE_ARMED: Duration = Duration::from_secs(3);
/// With balanced background activity, how long after the window was last
/// active background checks keep running.
const BALANCED_GRACE: Duration = Duration::from_secs(300);
const CONTINUE_AFTER_RESTART: &str =
    "The app restarted while you were working, which interrupted your last turn. Continue where you left off.";
const CONTINUE_AFTER_LIMIT: &str = "The usage limit has reset. Continue where you left off.";

/// The projects, as the conversation's start screen needs them: to name its
/// project, offer the others, and show where and on which branch it starts.
#[derive(Default)]
pub struct ProjectDirectory {
    pub hostname: SharedString,
    /// In the order the settings sort projects.
    pub projects: Vec<ProjectSummary>,
}

impl ProjectDirectory {
    /// The project at `path` on a remote connection (`None` for this device).
    pub fn find(&self, path: &std::path::Path, environment: Option<&str>) -> Option<&ProjectSummary> {
        self.projects.iter().find(|project| project.path == path && project.environment.as_deref() == environment)
    }
}

pub struct ProjectSummary {
    pub name: SharedString,
    pub path: PathBuf,
    /// The remote connection it's on, by id; `None` on this device.
    pub environment: Option<String>,
    /// The device's name, for projects on other devices.
    pub host: Option<SharedString>,
    /// The folder threads "without a project" run in.
    pub scratch: bool,
    /// Whether it's a git repository, so threads can start in a worktree.
    pub git: bool,
    pub branch: Option<String>,
    pub branches: Vec<String>,
}

impl Global for ProjectDirectory {}

/// What a new thread starts with: the agent, model and settings the user
/// picked last.
#[derive(Clone, Default)]
struct ThreadSettings {
    instance: String,
    provider: Provider,
    model: Option<String>,
    mode: PermissionMode,
    reasoning: Reasoning,
    fast_mode: bool,
}

struct Project {
    path: PathBuf,
    name: SharedString,
    initials: SharedString,
    /// Index into the palette's tints.
    tint: usize,
    added: SystemTime,
    sessions: Vec<Entity<Session>>,
    /// `None` until read, or when the folder isn't a git repository.
    git: Option<GitInfo>,
    /// The remote connection it's on, by id; `None` on this device.
    environment: Option<String>,
}

/// A thread in the sidebar: its project's index and the session.
type ThreadEntry = (usize, Entity<Session>);

/// A settled thread, as the settings' archive lists it.
pub struct SettledThread {
    pub session: Entity<Session>,
    pub project: SharedString,
    pub initials: SharedString,
    pub tint: usize,
    pub title: SharedString,
    pub provider: Provider,
    pub age: String,
}

/// A GUI program that can open a folder.
struct EditorApp {
    name: &'static str,
    program: PathBuf,
}

/// What a thread was doing when last seen, to notice when that changes.
#[derive(Clone, Copy, Default)]
struct Activity {
    working: bool,
    attention: bool,
}

pub struct Workspace {
    projects: Vec<Project>,
    chats: HashMap<EntityId, Entity<ChatView>>,
    active: Option<Entity<Session>>,
    active_project: usize,
    search: Entity<InputState>,
    sidebar_open: bool,
    panel_open: bool,
    settled_open: bool,
    snoozed_open: bool,
    working_open: bool,
    /// Projects folded away in the grouped thread list.
    collapsed_projects: HashSet<PathBuf>,
    editors: Rc<Vec<EditorApp>>,
    editor: usize,
    hostname: SharedString,
    settings_view: Entity<SettingsView>,
    settings_open: bool,
    /// The Add project browser, while it's open.
    folder_browser: Option<(Entity<FolderBrowser>, Subscription)>,
    activity: HashMap<EntityId, Activity>,
    /// The thread whose settle button waits for its confirming click.
    settle_armed: Option<(EntityId, Instant)>,
    /// Threads changed since they were last saved.
    dirty: HashSet<EntityId>,
    /// `state.json` as last written.
    saved_state: String,
    /// The git state of the open thread's worktree.
    worktree_git: HashMap<PathBuf, GitInfo>,
    last_active: Instant,
    last_git: Instant,
    git_busy: bool,
    last_housekeeping: Instant,
    last_health: Instant,
    last_pr_check: Option<Instant>,
    last_repaint: Instant,
    remote: RemoteState,
    focus_handle: FocusHandle,
    observers: HashMap<EntityId, Vec<Subscription>>,
    _subscriptions: Vec<Subscription>,
    _ticker: Task<()>,
    _remote_flush: Task<()>,
}

impl Workspace {
    /// Opens the window's workspace with the saved projects and threads.
    /// `initial_project`, from the command line, starts a thread there.
    pub fn new(initial_project: Option<PathBuf>, window: &mut Window, cx: &mut Context<Self>) -> Self {
        let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search"));
        let workspace = cx.weak_entity();
        let settings_view = cx.new(|cx| SettingsView::new(workspace, window, cx));
        let subscriptions = vec![
            cx.subscribe_in(&settings_view, window, |this, _, event: &SettingsEvent, window, cx| match event {
                SettingsEvent::Close => this.close_settings(window, cx),
            }),
            cx.on_app_quit(|this: &mut Self, cx| {
                this.save_everything(cx);
                for project in &this.projects {
                    for session in &project.sessions {
                        session.update(cx, |session, _| session.shutdown());
                    }
                }
                async {}
            }),
            // The System theme mode follows the desktop's light/dark setting.
            cx.observe_window_appearance(window, |_, window, cx| metal::sync_appearance(window, cx)),
            cx.observe_window_activation(window, |this, window, cx| {
                if window.is_window_active() {
                    this.last_active = Instant::now();
                    // Catch up on what was paused while the window was away.
                    this.last_git = Instant::now() - GIT_POLL;
                    cx.notify();
                }
            }),
            cx.subscribe(&search, |_, _, event: &InputEvent, cx| {
                if matches!(event, InputEvent::Change) {
                    cx.notify();
                }
            }),
        ];
        let ticker = cx.spawn_in(window, async move |this, cx| {
            loop {
                cx.background_executor().timer(TICK).await;
                if this.update_in(cx, |this, window, cx| this.tick(window, cx)).is_err() {
                    break;
                }
            }
        });

        let remote_flush = cx.spawn(async move |this, cx| {
            loop {
                cx.background_executor().timer(remote::FLUSH).await;
                if this.update(cx, |this, cx| this.flush_remote(cx)).is_err() {
                    break;
                }
            }
        });

        let editors = detect_editors();
        let saved_editor = settings::get(|settings| settings.editor.clone());
        let editor = editors.iter().position(|app| Some(app.name) == saved_editor.as_deref()).unwrap_or(0);
        let now = Instant::now();
        let mut workspace = Self {
            projects: Vec::new(),
            chats: HashMap::new(),
            active: None,
            active_project: 0,
            search,
            sidebar_open: true,
            panel_open: true,
            settled_open: false,
            snoozed_open: false,
            working_open: false,
            collapsed_projects: HashSet::new(),
            editors: Rc::new(editors),
            editor,
            hostname: hostname().into(),
            settings_view,
            settings_open: false,
            folder_browser: None,
            activity: HashMap::new(),
            settle_armed: None,
            dirty: HashSet::new(),
            saved_state: String::new(),
            worktree_git: HashMap::new(),
            last_active: now,
            last_git: now,
            git_busy: false,
            last_housekeeping: now,
            last_health: now,
            last_pr_check: None,
            last_repaint: now,
            remote: RemoteState::default(),
            focus_handle: cx.focus_handle(),
            observers: HashMap::new(),
            _subscriptions: subscriptions,
            _ticker: ticker,
            _remote_flush: remote_flush,
        };
        let active = workspace.restore(window, cx);
        match (initial_project, active) {
            (Some(path), _) => workspace.add_project(path, window, cx),
            (None, Some((ix, session))) => workspace.select(ix, session, window, cx),
            (None, None) => {
                let ix = match workspace.ordered_projects(cx).first() {
                    Some(&ix) => ix,
                    None => {
                        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
                        workspace.ensure_project(cwd.canonicalize().unwrap_or(cwd), None)
                    }
                };
                let settings = workspace.new_thread_settings(cx);
                workspace.replace_thread(ix, settings, window, cx);
            }
        }
        workspace.sync_remote(window, cx);
        workspace
    }

    // ---- keeping threads on disk -------------------------------------------

    /// Loads the saved projects and threads, and returns the thread that was
    /// open.
    fn restore(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<ThreadEntry> {
        let state = store::load_state();
        for project in &state.projects {
            let name = Some(project.name.as_str()).filter(|name| !name.is_empty());
            let ix = self.ensure_project(project.path.clone(), name);
            self.projects[ix].added = from_millis(project.added);
        }
        let continue_threads = settings::get(|settings| settings.continue_after_restart);
        let mut active = None;
        for saved in store::load_threads() {
            let ix = self.ensure_project(saved.project.clone(), None);
            let id = saved.id.clone();
            let session = cx.new(|cx| Session::restore(saved, cx));
            self.attach(ix, session.clone(), window, cx);
            if state.active.as_deref() == Some(id.as_str()) {
                active = Some((ix, session.clone()));
            }
            let interrupted = {
                let state = session.read(cx);
                state.interrupted && !state.settled
            };
            if continue_threads && interrupted {
                session.update(cx, |session, cx| session.send_message(CONTINUE_AFTER_RESTART.into(), Vec::new(), cx));
            }
        }
        self.saved_state = serde_json::to_string(&self.app_state(cx)).unwrap_or_default();
        active
    }

    fn app_state(&self, cx: &App) -> AppState {
        let scratch = scratch_dir();
        AppState {
            projects: self
                .projects
                .iter()
                .filter(|project| project.environment.is_none() && Some(&project.path) != scratch.as_ref())
                .map(|project| SavedProject {
                    path: project.path.clone(),
                    name: project.name.to_string(),
                    added: to_millis(project.added),
                })
                .collect(),
            active: self.active.as_ref().map(|session| session.read(cx).id.clone()),
        }
    }

    /// Writes the threads that changed, and the project list if it did, in
    /// the background.
    fn flush_saves(&mut self, cx: &mut Context<Self>) {
        let state = self.app_state(cx);
        let state_text = serde_json::to_string(&state).unwrap_or_default();
        let state = (state_text != self.saved_state).then(|| {
            self.saved_state = state_text;
            state
        });
        let dirty = std::mem::take(&mut self.dirty);
        let threads: Vec<_> = self
            .projects
            .iter()
            .flat_map(|project| &project.sessions)
            .filter(|session| dirty.contains(&session.entity_id()))
            .map(|session| session.read(cx))
            // A thread nothing was sent in isn't worth keeping, and another
            // device keeps its own.
            .filter(|session| !session.items.is_empty() && !session.is_remote())
            .map(Session::save)
            .collect();
        if threads.is_empty() && state.is_none() {
            return;
        }
        cx.background_executor()
            .spawn(async move {
                for thread in &threads {
                    store::save_thread(thread);
                }
                if let Some(state) = state {
                    store::save_state(&state);
                }
            })
            .detach();
    }

    /// Writes every thread and the project list now, as the app quits.
    fn save_everything(&mut self, cx: &App) {
        for session in self.projects.iter().flat_map(|project| &project.sessions) {
            let session = session.read(cx);
            if !session.items.is_empty() && !session.is_remote() {
                store::save_thread(&session.save());
            }
        }
        store::save_state(&self.app_state(cx));
    }

    // ---- background work ----------------------------------------------------

    fn tick(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        if window.is_window_active() {
            self.last_active = Instant::now();
        }
        self.flush_saves(cx);
        let allowed = self.background_allowed(window);
        if allowed && !self.git_busy && self.last_git.elapsed() >= GIT_POLL {
            self.poll_git(cx);
        }
        if self.last_housekeeping.elapsed() >= HOUSEKEEPING {
            self.last_housekeeping = Instant::now();
            self.housekeeping(allowed, cx);
        }
        let health = settings::get(|settings| settings.health_check_secs);
        if allowed && health > 0 && self.last_health.elapsed() >= Duration::from_secs(health) {
            self.last_health = Instant::now();
            Catalog::refresh_all(cx);
        }
        if let Some((_, armed)) = self.settle_armed
            && armed.elapsed() >= SETTLE_ARMED
        {
            self.settle_armed = None;
            cx.notify();
        }
        // Repaint about once a minute so thread ages stay current.
        if self.last_repaint.elapsed() >= Duration::from_secs(60) {
            self.last_repaint = Instant::now();
            cx.notify();
        }
    }

    /// Whether background checks should run now, as Background activity says.
    fn background_allowed(&self, window: &Window) -> bool {
        match settings::get(|settings| settings.background_activity) {
            BackgroundActivity::Always => true,
            BackgroundActivity::Minimal => window.is_window_active(),
            BackgroundActivity::Balanced => {
                (window.is_window_active() || self.last_active.elapsed() < BALANCED_GRACE) && !power_saving()
            }
        }
    }

    /// Reads the git state of every project, and of the open thread's worktree.
    fn poll_git(&mut self, cx: &mut Context<Self>) {
        self.git_busy = true;
        self.last_git = Instant::now();
        let mut paths: Vec<PathBuf> =
            self.projects.iter().filter(|project| project.environment.is_none()).map(|project| project.path.clone()).collect();
        let worktree = self.active_worktree(cx);
        paths.extend(worktree.clone());
        let task = cx.background_executor().spawn(async move {
            paths.into_iter().map(|path| (git::read_info(&path), path)).collect::<Vec<_>>()
        });
        cx.spawn(async move |this, cx| {
            let infos = task.await;
            let _ = this.update(cx, |this, cx| {
                this.git_busy = false;
                let mut changed = false;
                for (info, path) in infos {
                    if Some(&path) == worktree.as_ref() {
                        let info = info.unwrap_or_default();
                        if this.worktree_git.get(&path) != Some(&info) {
                            this.worktree_git.insert(path, info);
                            changed = true;
                        }
                    } else if let Some(project) =
                        this.projects.iter_mut().find(|project| project.environment.is_none() && project.path == path)
                        && project.git != info
                    {
                        project.git = info;
                        changed = true;
                    }
                }
                if changed {
                    cx.notify();
                }
            });
        })
        .detach();
    }

    /// Wakes snoozed threads, continues those a usage limit stopped, settles
    /// idle ones, and checks worktree threads' pull requests.
    fn housekeeping(&mut self, allowed: bool, cx: &mut Context<Self>) {
        let now = SystemTime::now();
        let (settle_inactive, inactive_days) =
            settings::get(|settings| (settings.auto_settle_inactive, settings.inactive_days.max(1)));
        let inactive = Duration::from_secs(u64::from(inactive_days) * 86_400);
        // Other devices look after their own threads.
        let sessions: Vec<Entity<Session>> = self
            .projects
            .iter()
            .filter(|project| project.environment.is_none())
            .flat_map(|project| project.sessions.clone())
            .collect();
        for session in &sessions {
            let (wake, resume, settle) = {
                let state = session.read(cx);
                let idle = !state.is_working() && !state.needs_attention() && !state.items.is_empty();
                (
                    state.snoozed_until.is_some_and(|until| until <= now),
                    state.resume_at.is_some_and(|at| at <= now) && !state.is_working(),
                    settle_inactive
                        && idle
                        && !state.settled
                        && !state.pinned
                        && state.resume_at.is_none()
                        && now.duration_since(state.updated).unwrap_or_default() >= inactive,
                )
            };
            if wake {
                session.update(cx, |session, cx| session.snooze(None, cx));
            }
            if resume {
                session.update(cx, |session, cx| {
                    session.resume_at = None;
                    session.send_message(CONTINUE_AFTER_LIMIT.into(), Vec::new(), cx);
                });
            }
            if settle {
                session.update(cx, |session, cx| session.settle(cx));
            }
        }
        if allowed && self.last_pr_check.is_none_or(|checked| checked.elapsed() >= PR_CHECK) {
            self.last_pr_check = Some(Instant::now());
            self.check_pull_requests(cx);
        }
    }

    /// Looks up the pull requests of worktree threads' branches, and settles
    /// threads whose pull request merged (when set to) or closed.
    fn check_pull_requests(&mut self, cx: &mut Context<Self>) {
        let threads: Vec<(Entity<Session>, PathBuf, String)> = self
            .projects
            .iter()
            .filter(|project| project.environment.is_none())
            .flat_map(|project| &project.sessions)
            .filter_map(|session| {
                let state = session.read(cx);
                let worktree = state.worktree.as_ref().filter(|_| !state.settled)?;
                Some((session.clone(), worktree.repo.clone(), worktree.branch.clone()))
            })
            .collect();
        if threads.is_empty() {
            return;
        }
        let lookups: Vec<(PathBuf, String)> = threads.iter().map(|(_, repo, branch)| (repo.clone(), branch.clone())).collect();
        let task = cx.background_executor().spawn(async move {
            lookups.into_iter().map(|(repo, branch)| worktree::pull_request(&repo, &branch)).collect::<Vec<_>>()
        });
        cx.spawn(async move |_, cx| {
            let results = task.await;
            let merged_settles = settings::get(|settings| settings.auto_settle_merged);
            for ((session, _, _), pull_request) in threads.into_iter().zip(results) {
                let Some(pull_request) = pull_request else { continue };
                let _ = session.update(cx, |session, cx| {
                    let was_open = session.pull_request.as_ref().is_none_or(|before| before.is_open());
                    let settle = was_open
                        && !session.is_working()
                        && match pull_request.state.as_str() {
                            "MERGED" => merged_settles,
                            "CLOSED" => true,
                            _ => false,
                        };
                    if session.pull_request.as_ref() != Some(&pull_request) {
                        session.pull_request = Some(pull_request);
                        cx.notify();
                    }
                    if settle {
                        session.settle(cx);
                    }
                });
            }
        })
        .detach();
    }

    // ---- threads ------------------------------------------------------------

    fn add_project(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
        let ix = self.ensure_project(path, None);
        let settings = self.current_settings(cx);
        self.replace_thread(ix, settings, window, cx);
    }

    /// The settings of the open thread, which a new thread inherits.
    fn current_settings(&self, cx: &App) -> ThreadSettings {
        let mut settings = self
            .active
            .as_ref()
            .map(|active| {
                let active = active.read(cx);
                ThreadSettings {
                    instance: active.instance.clone(),
                    provider: active.provider,
                    model: active.model.clone(),
                    mode: active.permission_mode,
                    reasoning: active.reasoning,
                    fast_mode: active.fast_mode,
                }
            })
            .unwrap_or_default();
        // Stay on an agent that's still on, preferring one of the same kind.
        let catalog = Catalog::global(cx);
        if catalog.instance(&settings.instance).is_none_or(|state| !state.config.enabled) {
            let fallback = catalog.first_of(settings.provider).or_else(|| catalog.enabled().next());
            match fallback {
                Some(state) if state.kind() == settings.provider => settings.instance = state.id().to_string(),
                Some(state) => {
                    settings = ThreadSettings {
                        instance: state.id().to_string(),
                        provider: state.kind(),
                        mode: settings.mode,
                        ..ThreadSettings::default()
                    }
                }
                None if settings.instance.is_empty() => settings.instance = settings.provider.key().to_string(),
                None => {}
            }
        }
        settings
    }

    /// What a fresh thread starts with: the open thread's picks, overridden
    /// by the defaults chosen in the settings.
    fn new_thread_settings(&self, cx: &App) -> ThreadSettings {
        let (model, reasoning, access) = settings::get(|settings| {
            (settings.default_model.clone(), settings.default_reasoning.clone(), settings.default_access.clone())
        });
        let mut settings = self.current_settings(cx);
        if let Some(key) = model {
            match Catalog::global(cx).find_key(&key) {
                Some(model) => settings = self.settings_for(model, cx),
                // Claude's models are only known once Claude Code answered.
                None => {
                    if let Some((instance, id)) = split_key(&key)
                        && let Some(state) = Catalog::global(cx).instance(instance).filter(|state| state.config.enabled)
                    {
                        settings.instance = instance.to_string();
                        settings.provider = state.kind();
                        settings.model = Some(id.to_string());
                    }
                }
            }
        }
        if let Some(reasoning) = reasoning.as_deref().and_then(Reasoning::from_key) {
            let catalog = Catalog::global(cx);
            let model = catalog.find(&settings.instance, settings.model.as_deref());
            settings.reasoning = match reasoning {
                Reasoning::Level(effort) if model.is_none_or(|model| model.efforts.contains(&effort)) => reasoning,
                Reasoning::Ultracode | Reasoning::Ultrathink if settings.provider == Provider::Claude => reasoning,
                _ => Reasoning::Default,
            };
        }
        if let Some(mode) = access.as_deref().and_then(PermissionMode::from_cli) {
            settings.mode = mode;
        }
        settings
    }

    /// The worktree a new thread in this project starts in, as Settings →
    /// New threads → Workspace says.
    fn worktree_plan(&self, project_ix: usize, mode: WorkspaceMode) -> Option<WorktreePlan> {
        let project = self.projects.get(project_ix)?;
        let git = project.git.as_ref()?;
        if mode != WorkspaceMode::Worktree || project.environment.is_some() || Some(&project.path) == scratch_dir().as_ref() {
            return None;
        }
        let (from_origin, submodules) = settings::get(|settings| (settings.start_from_origin, settings.submodules));
        Some(WorktreePlan { project: project.path.clone(), base: git.branch.clone(), from_origin, submodules })
    }

    fn new_thread(&mut self, project_ix: usize, window: &mut Window, cx: &mut Context<Self>) {
        let settings = self.new_thread_settings(cx);
        self.new_thread_with(project_ix, settings, window, cx);
    }

    fn new_thread_with(
        &mut self,
        project_ix: usize,
        settings: ThreadSettings,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) -> Option<Entity<ChatView>> {
        let project = self.projects.get(project_ix)?;
        let mode = settings::get(|settings| settings.workspace);
        let session = match project.environment.clone() {
            Some(env) => {
                let worktree = mode == WorkspaceMode::Worktree && project.git.is_some();
                let path = project.path.clone();
                let session = self.new_remote_session(&env, path, settings, worktree, cx)?;
                self.attach(project_ix, session.clone(), window, cx);
                session
            }
            None => {
                let plan = self.worktree_plan(project_ix, mode);
                self.spawn_thread(project_ix, settings, plan, window, cx)
            }
        };
        let chat = self.chats.get(&session.entity_id()).cloned();
        self.select(project_ix, session, window, cx);
        chat
    }

    /// Starts a local thread without opening it.
    fn spawn_thread(
        &mut self,
        project_ix: usize,
        settings: ThreadSettings,
        worktree: Option<WorktreePlan>,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) -> Entity<Session> {
        let project = self.projects[project_ix].path.clone();
        let session = cx.new(|cx| {
            Session::new(
                NewSession {
                    instance: settings.instance,
                    provider: settings.provider,
                    project,
                    model: settings.model,
                    permission_mode: settings.mode,
                    reasoning: settings.reasoning,
                    fast_mode: settings.fast_mode,
                    worktree,
                },
                cx,
            )
        });
        self.attach(project_ix, session.clone(), window, cx);
        session
    }

    /// Adds a thread to a project with a conversation view for it.
    fn attach(
        &mut self,
        project_ix: usize,
        session: Entity<Session>,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) -> Entity<ChatView> {
        let chat = cx.new(|cx| ChatView::new(session.clone(), window, cx));
        self.observers.insert(
            session.entity_id(),
            vec![
                cx.observe_in(&session, window, Self::on_session_changed),
                cx.subscribe_in(&chat, window, Self::on_chat_event),
            ],
        );
        self.chats.insert(session.entity_id(), chat.clone());
        self.projects[project_ix].sessions.push(session);
        chat
    }

    /// Starts a thread that replaces the open one if nothing was sent in it
    /// yet, carrying over what was typed.
    fn replace_thread(
        &mut self,
        project_ix: usize,
        settings: ThreadSettings,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) {
        let previous = self.active.clone().filter(|session| session.read(cx).is_blank());
        let previous_chat = previous.as_ref().and_then(|session| self.chats.get(&session.entity_id()).cloned());
        let Some(chat) = self.new_thread_with(project_ix, settings, window, cx) else { return };
        if let (Some(previous), Some(previous_chat)) = (previous, previous_chat) {
            chat.update(cx, |chat, cx| chat.take_draft_from(&previous_chat, window, cx));
            if let Some(ix) = self.project_of(&previous) {
                self.remove_thread(ix, &previous, cx);
            }
        }
    }

    /// Repaints, marks the thread for saving, and tells the user when a
    /// thread finishes, fails or starts waiting for them: on the desktop
    /// while the window is in the background, in a toast while it isn't.
    fn on_session_changed(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
        cx.notify();
        self.dirty.insert(session.entity_id());
        self.remote_thread_changed(&session, cx);
        let state = session.read(cx);
        let now = Activity { working: state.is_working(), attention: state.needs_attention() };
        let before = self.activity.insert(session.entity_id(), now).unwrap_or_default();
        if state.settled {
            return;
        }
        let failed = state.ended_with_error();
        let event = if now.attention && !before.attention {
            "Waiting for you"
        } else if before.working && !now.working {
            if failed { "Failed" } else { "Finished" }
        } else {
            return;
        };
        let title = state.title.to_string();
        let (notify, sound, in_app) =
            settings::get(|settings| (settings.notify, settings.notify_sound, settings.in_app_notifications));
        if window.is_window_active() {
            let is_open = self.active.as_ref() == Some(&session) && !self.settings_open;
            if in_app && !is_open {
                let note = if failed && event == "Failed" { Notification::error(event) } else { Notification::info(event) };
                window.push_notification(note.title(title), cx);
            }
        } else if notify {
            desktop_notification(&title, event, sound);
        }
    }

    fn project_of(&self, session: &Entity<Session>) -> Option<usize> {
        self.projects
            .iter()
            .position(|project| project.sessions.iter().any(|candidate| candidate == session))
    }

    fn on_chat_event(
        &mut self,
        chat: &Entity<ChatView>,
        event: &ChatEvent,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) {
        let session = chat.read(cx).session().clone();
        let Some(project_ix) = self.project_of(&session) else { return };
        match event {
            ChatEvent::NewThreadWithModel(model) => {
                let settings = self.settings_for(model, cx);
                self.replace_thread(project_ix, settings, window, cx);
            }
            ChatEvent::SwitchProject(path, environment) => {
                if let Some(target) =
                    self.projects.iter().position(|project| &project.path == path && &project.environment == environment)
                {
                    let settings = self.current_settings(cx);
                    self.replace_thread(target, settings, window, cx);
                }
            }
            ChatEvent::AddProject => self.prompt_for_project(window, cx),
            ChatEvent::StartWithoutProject => {
                let Some(path) = scratch_dir() else { return };
                let target = self.ensure_project(path, Some("No project"));
                let settings = self.current_settings(cx);
                self.replace_thread(target, settings, window, cx);
            }
            ChatEvent::SwitchBranch(branch) => {
                // A thread bound for a worktree starts from the branch; others
                // switch the project's checkout.
                let planned = session.read(cx).pending_worktree.is_some();
                if planned {
                    let branch = branch.clone();
                    session.update(cx, |session, cx| {
                        if let Some(plan) = session.pending_worktree.as_mut() {
                            plan.base = Some(branch);
                        }
                        cx.notify();
                    });
                } else {
                    self.switch_branch(branch.clone(), window, cx);
                }
            }
            ChatEvent::SetWorkspace(mode) => {
                let project = &self.projects[project_ix];
                let plan = match &project.environment {
                    // The other device makes the worktree; this only says so.
                    Some(_) => (*mode == WorkspaceMode::Worktree && project.git.is_some()).then(|| WorktreePlan {
                        project: project.path.clone(),
                        base: None,
                        from_origin: false,
                        submodules: settings::Submodules::Skip,
                    }),
                    None => self.worktree_plan(project_ix, *mode),
                };
                session.update(cx, |session, cx| session.set_worktree_plan(plan, cx));
            }
        }
    }

    /// The settings for a thread on `model`, keeping the user's other picks
    /// where the provider supports them.
    fn settings_for(&self, model: &ModelEntry, cx: &App) -> ThreadSettings {
        let current = self.current_settings(cx);
        let reasoning = match current.reasoning {
            Reasoning::Level(effort) if model.efforts.contains(&effort) => current.reasoning,
            Reasoning::Ultracode | Reasoning::Ultrathink if model.provider == Provider::Claude => current.reasoning,
            _ => Reasoning::Default,
        };
        ThreadSettings {
            instance: model.instance.clone(),
            provider: model.provider,
            model: Some(model.id.clone()),
            mode: current.mode,
            reasoning,
            fast_mode: current.fast_mode && model.fast_mode,
        }
    }

    /// The index of this device's project at `path`, adding it if needed.
    fn ensure_project(&mut self, path: PathBuf, name: Option<&str>) -> usize {
        if let Some(ix) = self.projects.iter().position(|project| project.environment.is_none() && project.path == path) {
            return ix;
        }
        let name = name.map(str::to_string).unwrap_or_else(|| {
            path.file_name()
                .map(|name| name.to_string_lossy().to_string())
                .unwrap_or_else(|| path.display().to_string())
        });
        let git = git::read_info(&path);
        self.projects.push(Project {
            path,
            initials: initials(&name).into(),
            tint: tint_for(&name),
            name: name.into(),
            added: SystemTime::now(),
            sessions: Vec::new(),
            git,
            environment: None,
        });
        self.projects.len() - 1
    }

    /// Project indices in the order Settings → Organization → Project order says.
    fn ordered_projects(&self, cx: &App) -> Vec<usize> {
        let mut order: Vec<usize> = (0..self.projects.len()).collect();
        match settings::get(|settings| settings.project_order) {
            ProjectOrder::Name => order.sort_by_key(|&ix| self.projects[ix].name.to_lowercase()),
            ProjectOrder::Added => order.sort_by_key(|&ix| std::cmp::Reverse(self.projects[ix].added)),
            ProjectOrder::LastMessage => order.sort_by_key(|&ix| {
                let project = &self.projects[ix];
                let last = project
                    .sessions
                    .iter()
                    .map(|session| session.read(cx))
                    .filter(|session| !session.items.is_empty())
                    .map(|session| session.last_message)
                    .max();
                std::cmp::Reverse(last.unwrap_or(project.added))
            }),
        }
        order
    }

    fn select(
        &mut self,
        project_ix: usize,
        session: Entity<Session>,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) {
        self.active_project = project_ix;
        session.update(cx, |session, _| session.subscribe_remote());
        if let Some(chat) = self.chats.get(&session.entity_id()).cloned() {
            chat.update(cx, |chat, cx| chat.focus_composer(window, cx));
        }
        self.active = Some(session);
        self.last_git = Instant::now() - GIT_POLL;
        cx.notify();
    }

    fn settle_thread(&mut self, session: Entity<Session>, cx: &mut Context<Self>) {
        self.settle_armed = None;
        session.update(cx, |session, cx| session.settle(cx));
        cx.notify();
    }

    /// The settle button on a thread card: settles at once, or arms itself
    /// for a confirming second click.
    fn press_settle(&mut self, session: Entity<Session>, cx: &mut Context<Self>) {
        let id = session.entity_id();
        let armed = self.settle_armed.is_some_and(|(armed, at)| armed == id && at.elapsed() < SETTLE_ARMED);
        if settings::get(|settings| settings.confirm_settle) && !armed {
            self.settle_armed = Some((id, Instant::now()));
            cx.notify();
        } else {
            self.settle_thread(session, cx);
        }
    }

    /// Deletes a thread, asking first when Delete confirmation is on.
    fn request_delete(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
        if !settings::get(|settings| settings.confirm_delete) {
            self.forget_thread(session, window, cx);
            return;
        }
        let title = session.read(cx).title.clone();
        let workspace = cx.weak_entity();
        window.open_alert_dialog(cx, move |alert, _, _| {
            let workspace = workspace.clone();
            let session = session.clone();
            alert
                .title("Delete thread?")
                .description(format!("\u{201c}{title}\u{201d} and its chat history will be deleted."))
                .ok_text("Delete")
                .ok_variant(ButtonVariant::Danger)
                .show_cancel(true)
                .on_ok(move |_, window, cx| {
                    let _ = workspace.update(cx, |this, cx| this.forget_thread(session.clone(), window, cx));
                    true
                })
        });
    }

    /// Pins or unpins a thread, asking before unpinning when set to.
    fn toggle_pin(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
        let pinned = session.read(cx).pinned;
        if !pinned || !settings::get(|settings| settings.confirm_unpin) {
            session.update(cx, |session, cx| session.set_pinned(!pinned, cx));
            return;
        }
        let title = session.read(cx).title.clone();
        window.open_alert_dialog(cx, move |alert, _, _| {
            let session = session.clone();
            alert
                .title("Unpin thread?")
                .description(format!("\u{201c}{title}\u{201d} moves back among the other threads."))
                .ok_text("Unpin")
                .show_cancel(true)
                .on_ok(move |_, _, cx| {
                    session.update(cx, |session, cx| session.set_pinned(false, cx));
                    true
                })
        });
    }

    fn rename_thread(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
        let title = session.read(cx).title.to_string();
        let input = cx.new(|cx| InputState::new(window, cx).default_value(title));
        input.update(cx, |input, cx| input.focus(window, cx));
        let rename = {
            let input = input.clone();
            let session = session.clone();
            move |window: &mut Window, cx: &mut App| {
                let title = input.read(cx).value().to_string();
                session.update(cx, |session, cx| session.rename(title, cx));
                window.close_dialog(cx);
            }
        };
        let on_enter = rename.clone();
        let subscription = cx.subscribe_in(&input, window, move |_, _, event: &InputEvent, window, cx| {
            if matches!(event, InputEvent::PressEnter { .. }) {
                on_enter(window, cx);
            }
        });
        let rename = Rc::new(rename);
        window.open_dialog(cx, move |dialog, _, cx| {
            let p = metal::palette(cx);
            let rename = rename.clone();
            dialog
                .title("Rename thread")
                .w(px(420.))
                .child(
                    h_flex()
                        .h(px(36.))
                        .px(px(11.))
                        .rounded(px(12.))
                        .well(p)
                        .text_size(px(13.5))
                        .child(div().flex_1().child(Input::new(&input).appearance(false))),
                )
                .footer(
                    h_flex()
                        .w_full()
                        .justify_end()
                        .gap(px(8.))
                        .child(
                            MetalButton::new("rename-cancel")
                                .ghost()
                                .compact()
                                .label("Cancel")
                                .on_click(|_, window, cx| window.close_dialog(cx)),
                        )
                        .child(
                            MetalButton::new("rename-save")
                                .primary()
                                .compact()
                                .label("Rename")
                                .on_click(move |_, window, cx| rename(window, cx)),
                        ),
                )
        });
        // The subscription lives as long as the dialog might.
        self._subscriptions.push(subscription);
    }

    fn delete_thread(
        &mut self,
        project_ix: usize,
        session: Entity<Session>,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) {
        let (id, worktree, remote) = {
            let state = session.read(cx);
            (state.id.clone(), state.worktree.clone(), state.is_remote())
        };
        self.remove_thread(project_ix, &session, cx);
        if remote {
            session.read(cx).forget_remote();
        } else {
            cx.background_executor()
            .spawn(async move {
                store::delete_thread(&id);
                if let Some(worktree) = worktree {
                    let _ = worktree::remove(&worktree);
                }
            })
            .detach();
        }
        let entity_id = session.entity_id();
        if self.active.as_ref().is_some_and(|active| active.entity_id() == entity_id) {
            self.active = None;
            if let Some((ix, next)) = self.threads(cx).0.into_iter().next() {
                self.select(ix, next, window, cx);
            }
        }
        cx.notify();
    }

    /// Stops a thread's process and forgets it.
    fn remove_thread(&mut self, project_ix: usize, session: &Entity<Session>, cx: &mut Context<Self>) {
        session.update(cx, |session, _| session.shutdown());
        let id = session.entity_id();
        self.chats.remove(&id);
        self.observers.remove(&id);
        self.activity.remove(&id);
        self.dirty.remove(&id);
        if let Some(project) = self.projects.get_mut(project_ix) {
            project.sessions.retain(|candidate| candidate.entity_id() != id);
        }
        cx.notify();
    }

    /// Opens the Add project browser over the window, in the folder the
    /// settings name.
    fn prompt_for_project(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let saved = settings::get(|settings| settings.add_project_dir.clone());
        let start = folder_browser::start_dir(Some(saved.as_str()));
        let browser = cx.new(|cx| FolderBrowser::new(start, window, cx));
        let subscription = cx.subscribe_in(&browser, window, |this, _, event: &FolderBrowserEvent, window, cx| {
            this.close_folder_browser(window, cx);
            match event {
                FolderBrowserEvent::Picked(path) => this.add_project(path.clone(), window, cx),
                FolderBrowserEvent::Cancelled => {}
                FolderBrowserEvent::SystemDialog => this.prompt_with_system_dialog(window, cx),
            }
        });
        browser.focus_handle(cx).focus(window, cx);
        self.folder_browser = Some((browser, subscription));
        cx.notify();
    }

    fn close_folder_browser(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        self.folder_browser = None;
        self.focus_handle.focus(window, cx);
        cx.notify();
    }

    fn prompt_with_system_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let paths = cx.prompt_for_paths(PathPromptOptions {
            files: false,
            directories: true,
            multiple: false,
            prompt: Some("Open project".into()),
        });
        cx.spawn_in(window, async move |this, cx| {
            let Ok(Ok(Some(paths))) = paths.await else { return };
            let Some(path) = paths.into_iter().next() else { return };
            let _ = this.update_in(cx, |this, window, cx| this.add_project(path, window, cx));
        })
        .detach();
    }

    /// Every thread matching the search, most recently active first, split
    /// into open and settled.
    fn threads(&self, cx: &App) -> (Vec<ThreadEntry>, Vec<ThreadEntry>) {
        let query = self.search.read(cx).value().trim().to_lowercase();
        let mut threads: Vec<_> = self
            .projects
            .iter()
            .enumerate()
            .flat_map(|(ix, project)| project.sessions.iter().map(move |session| (ix, session.clone())))
            .filter(|(ix, session)| {
                query.is_empty()
                    || session.read(cx).title.to_lowercase().contains(&query)
                    || self.projects[*ix].name.to_lowercase().contains(&query)
            })
            .collect();
        threads.sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).updated));
        threads.into_iter().partition(|(_, session)| !session.read(cx).settled)
    }

    fn active_project(&self) -> Option<&Project> {
        self.projects.get(self.active_project)
    }

    /// The open thread's worktree root, if it runs in one.
    fn active_worktree(&self, cx: &App) -> Option<PathBuf> {
        self.active.as_ref().and_then(|session| session.read(cx).worktree.as_ref().map(|worktree| worktree.root.clone()))
    }

    /// Where the open thread runs: its worktree, or the project folder.
    fn active_path(&self, cx: &App) -> Option<PathBuf> {
        self.active_worktree(cx).or_else(|| self.active_project().map(|project| project.path.clone()))
    }

    fn open_in_editor(&mut self, editor: usize, window: &mut Window, cx: &mut Context<Self>) {
        let (Some(app), Some(path)) = (self.editors.get(editor), self.active_path(cx)) else {
            return;
        };
        let result = Command::new(&app.program)
            .arg(&path)
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn();
        match result {
            Ok(mut child) => {
                // Reap it so it doesn't linger as a zombie.
                std::thread::spawn(move || child.wait());
                self.set_editor(editor, cx);
            }
            Err(err) => window.push_notification(
                Notification::error(format!("Couldn't start {}: {err}", app.name)),
                cx,
            ),
        }
        cx.notify();
    }

    fn switch_branch(&mut self, branch: String, window: &mut Window, cx: &mut Context<Self>) {
        let Some(path) = self.active_project().map(|project| project.path.clone()) else { return };
        let task = cx.background_executor().spawn(async move {
            let output = git::git(&path, &["switch", &branch]);
            let info = git::read_info(&path);
            (path, output, info)
        });
        cx.spawn_in(window, async move |this, cx| {
            let (path, output, info) = task.await;
            let _ = this.update_in(cx, |this, window, cx| {
                if let Err(err) = output {
                    window.push_notification(Notification::error(format!("git switch failed: {err}")), cx);
                }
                if let Some(project) = this.projects.iter_mut().find(|project| project.path == path) {
                    project.git = info;
                }
                cx.notify();
            });
        })
        .detach();
    }

    /// Hands a git chore to the agent in the open thread.
    fn ask_agent(&mut self, prompt: &str, cx: &mut Context<Self>) {
        if let Some(session) = &self.active {
            session.update(cx, |session, cx| session.send_message(prompt.to_string(), Vec::new(), cx));
        }
    }

    // ---- what the settings view reads and changes ----------------------

    fn open_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        self.settings_open = true;
        self.settings_view.update(cx, |view, cx| view.opened(window, cx));
        cx.notify();
    }

    fn close_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        self.settings_open = false;
        if let Some(chat) = self.active.as_ref().and_then(|session| self.chats.get(&session.entity_id())) {
            chat.update(cx, |chat, cx| chat.focus_composer(window, cx));
        }
        cx.notify();
    }

    /// Lets every open conversation pick up changed settings.
    pub fn settings_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        self.sync_remote(window, cx);
        for chat in self.chats.values() {
            chat.update(cx, |chat, cx| chat.settings_changed(window, cx));
        }
        cx.notify();
    }

    pub fn editor_names(&self) -> Vec<&'static str> {
        self.editors.iter().map(|app| app.name).collect()
    }

    pub fn editor(&self) -> usize {
        self.editor
    }

    pub fn set_editor(&mut self, editor: usize, cx: &mut Context<Self>) {
        let Some(app) = self.editors.get(editor) else { return };
        self.editor = editor;
        let name = app.name.to_string();
        settings::update(|settings| settings.editor = Some(name));
        cx.notify();
    }

    /// Every settled thread, most recently active first.
    pub fn settled_threads(&self, cx: &App) -> Vec<SettledThread> {
        let mut threads: Vec<_> = self
            .projects
            .iter()
            .flat_map(|project| project.sessions.iter().map(move |session| (project, session)))
            .filter(|(_, session)| session.read(cx).settled)
            .collect();
        threads.sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).updated));
        threads
            .into_iter()
            .map(|(project, session)| {
                let state = session.read(cx);
                SettledThread {
                    session: session.clone(),
                    project: project.name.clone(),
                    initials: project.initials.clone(),
                    tint: project.tint,
                    title: state.title.clone(),
                    provider: state.provider,
                    age: relative_time(state.updated),
                }
            })
            .collect()
    }

    /// Brings a settled thread back and opens it.
    pub fn restore_thread(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
        let Some(ix) = self.project_of(&session) else { return };
        session.update(cx, |session, cx| session.unsettle(cx));
        self.select(ix, session, window, cx);
        self.close_settings(window, cx);
    }

    pub fn forget_thread(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
        if let Some(ix) = self.project_of(&session) {
            self.delete_thread(ix, session, window, cx);
        }
    }

    // ---- sidebar --------------------------------------------------------

    fn render_sidebar_header(&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(
                MetalButton::new("hide-sidebar")
                    .ghost()
                    .mini()
                    .icon(Lucide::PanelLeftClose)
                    .tooltip("Hide sidebar (Ctrl+B)")
                    .on_click(cx.listener(|this, _, _, cx| {
                        this.sidebar_open = false;
                        cx.notify();
                    })),
            )
            .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))
    }

    fn render_sidebar_toolbar(&self, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let projects: Vec<(usize, SharedString)> = self
            .ordered_projects(cx)
            .into_iter()
            .map(|ix| {
                let project = &self.projects[ix];
                let name = match &project.environment {
                    Some(env) => format!("{} · {}", project.name, self.environment_name(env)).into(),
                    None => project.name.clone(),
                };
                (ix, name)
            })
            .collect();
        let workspace = cx.entity().downgrade();

        h_flex()
            .flex_none()
            .px(px(12.))
            .gap(px(4.))
            .child(
                h_flex()
                    .flex_1()
                    .min_w_0()
                    .h(px(36.))
                    .pl(px(11.))
                    .pr(px(8.))
                    .gap(px(8.))
                    .rounded(px(14.))
                    .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))),
            )
            .child(
                MetalButton::new("project-menu")
                    .ghost()
                    .mini()
                    .icon(Lucide::FolderPlus)
                    .dropdown_menu(move |mut menu, _, _| {
                        for (ix, name) in projects.clone() {
                            let workspace = workspace.clone();
                            menu = menu.item(PopupMenuItem::new(format!("New thread in {name}")).on_click(
                                move |_, window, cx| {
                                    let _ = workspace.update(cx, |this, cx| this.new_thread(ix, window, cx));
                                },
                            ));
                        }
                        let workspace = workspace.clone();
                        menu.separator().item(PopupMenuItem::new("Add project folder…").on_click(
                            move |_, window, cx| {
                                let _ = workspace.update(cx, |this, cx| this.prompt_for_project(window, cx));
                            },
                        ))
                    }),
            )
            .child(
                MetalButton::new("new-thread")
                    .ghost()
                    .mini()
                    .icon(Lucide::SquarePen)
                    .tooltip("New thread (Ctrl+N)")
                    .on_click(cx.listener(|this, _, window, cx| {
                        if this.projects.is_empty() {
                            this.prompt_for_project(window, cx);
                        } else {
                            this.new_thread(this.active_project, window, cx);
                        }
                    })),
            )
    }

    /// The right-click menu of a thread card.
    fn thread_menu(
        workspace: WeakEntity<Self>,
        session: Entity<Session>,
        menu: PopupMenu,
        window: &mut Window,
        cx: &mut Context<PopupMenu>,
    ) -> PopupMenu {
        let state = session.read(cx);
        let (pinned, settled, snoozed) = (state.pinned, state.settled, state.is_snoozed());
        let action = |run: fn(&mut Workspace, Entity<Session>, &mut Window, &mut Context<Workspace>)| {
            let workspace = workspace.clone();
            let session = session.clone();
            move |_: &ClickEvent, window: &mut Window, cx: &mut App| {
                let _ = workspace.update(cx, |this, cx| run(this, session.clone(), window, cx));
            }
        };
        let snooze_session = session.clone();
        let mut menu = menu
            .min_w(px(200.))
            .item(
                PopupMenuItem::new(if pinned { "Unpin" } else { "Pin to top" })
                    .icon(Icon::new(if pinned { Lucide::PinOff } else { Lucide::Pin }))
                    .on_click(action(|this, session, window, cx| this.toggle_pin(session, window, cx))),
            )
            .item(
                PopupMenuItem::new("Rename…")
                    .icon(Icon::new(Lucide::Pencil))
                    .on_click(action(|this, session, window, cx| this.rename_thread(session, window, cx))),
            );
        if !settled {
            menu = menu.submenu_with_icon(Some(Icon::new(Lucide::AlarmClock)), "Snooze", window, cx, move |mut menu, _, _| {
                let snooze = |until: fn() -> SystemTime| {
                    let session = snooze_session.clone();
                    move |_: &ClickEvent, _: &mut Window, cx: &mut App| {
                        session.update(cx, |session, cx| session.snooze(Some(until()), cx));
                    }
                };
                menu = menu
                    .min_w(px(220.))
                    .item(PopupMenuItem::new("For an hour").on_click(snooze(|| SystemTime::now() + Duration::from_secs(3_600))))
                    .item(PopupMenuItem::new("For three hours").on_click(snooze(|| SystemTime::now() + Duration::from_secs(10_800))))
                    .item(PopupMenuItem::new(format!("Until tomorrow, {}", clock::clock(clock::at_hour(1, 9)))).on_click(snooze(|| clock::at_hour(1, 9))))
                    .item(PopupMenuItem::new(format!("Until next week, {}", clock::clock(clock::at_hour(7, 9)))).on_click(snooze(|| clock::at_hour(7, 9))));
                menu
            });
            if snoozed {
                let session = session.clone();
                menu = menu.item(PopupMenuItem::new("Wake up now").icon(Icon::new(Lucide::AlarmClockOff)).on_click(
                    move |_, _, cx| session.update(cx, |session, cx| session.snooze(None, cx)),
                ));
            }
        }
        menu = menu.separator();
        menu = if settled {
            menu.item(
                PopupMenuItem::new("Restore")
                    .icon(Icon::new(Lucide::ArchiveRestore))
                    .on_click(action(|this, session, window, cx| this.restore_thread(session, window, cx))),
            )
        } else {
            menu.item(
                PopupMenuItem::new("Settle")
                    .icon(Icon::new(Lucide::Archive))
                    .on_click(action(|this, session, _, cx| this.settle_thread(session, cx))),
            )
        };
        menu.item(
            PopupMenuItem::new("Delete…")
                .icon(Icon::new(Lucide::Trash))
                .on_click(action(|this, session, window, cx| this.request_delete(session, window, cx))),
        )
    }

    fn render_thread_card(
        &self,
        project_ix: usize,
        session: &Entity<Session>,
        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 selected = self.active.as_ref().is_some_and(|active| active.entity_id() == id);
        let (led, pulse) = if state.settled {
            (Led::Off, false)
        } else if state.needs_attention() {
            (Led::Waiting, true)
        } else if state.limited_until().is_some() {
            (Led::Waiting, false)
        } else {
            match state.status {
                SessionStatus::Working => (Led::Live, true),
                SessionStatus::Idle => (Led::Live, false),
                SessionStatus::Starting => (Led::Off, false),
                SessionStatus::Exited => (Led::Failed, false),
            }
        };
        let branch = match &state.worktree {
            Some(worktree) => Some(worktree.branch.clone()),
            None => project.git.as_ref().and_then(|git| git.branch.clone()),
        };
        let in_worktree = state.worktree.is_some() || state.pending_worktree.is_some();
        let host = project.environment.as_deref().map(|env| self.environment_name(env));
        let settled = state.settled;
        let pinned = state.pinned;
        let snoozed_until = state.snoozed_until.filter(|_| state.is_snoozed());
        let group = SharedString::from(format!("thread-{id}"));
        let select_session = session.clone();
        let action_session = session.clone();
        let pin_session = session.clone();
        let compact = settings::get(|settings| settings.compact_threads);
        let armed = self.settle_armed.is_some_and(|(armed, at)| armed == id && at.elapsed() < SETTLE_ARMED);

        let settle_or_delete = if settled {
            MetalButton::new(("delete-thread", id.as_u64() as usize))
                .ghost()
                .mini()
                .icon(IconName::Close)
                .tooltip("Delete thread")
                .on_click(cx.listener(move |this, _, window, cx| {
                    cx.stop_propagation();
                    this.request_delete(action_session.clone(), window, cx)
                }))
        } else if armed {
            MetalButton::new(("settle-thread", id.as_u64() as usize))
                .danger()
                .mini()
                .icon(IconName::Check)
                .tooltip("Click again to settle")
                .on_click(cx.listener(move |this, _, _, cx| {
                    cx.stop_propagation();
                    this.press_settle(action_session.clone(), cx)
                }))
        } else {
            MetalButton::new(("settle-thread", id.as_u64() as usize))
                .ghost()
                .mini()
                .icon(Lucide::Archive)
                .tooltip("Settle: stop the agent and file the thread away")
                .on_click(cx.listener(move |this, _, _, cx| {
                    cx.stop_propagation();
                    this.press_settle(action_session.clone(), cx)
                }))
        };
        let pin_button = MetalButton::new(("pin-thread", id.as_u64() as usize))
            .ghost()
            .mini()
            .icon(if pinned { Lucide::PinOff } else { Lucide::Pin })
            .tooltip(if pinned { "Unpin" } else { "Pin to top" })
            .on_click(cx.listener(move |this, _, window, cx| {
                cx.stop_propagation();
                this.toggle_pin(pin_session.clone(), window, cx)
            }));
        let actions = h_flex()
            .gap(px(1.))
            .when(!armed, |this| this.invisible().group_hover(group.clone(), |this| this.visible()))
            .when(!settled, |this| this.child(pin_button))
            .child(settle_or_delete);
        let led = metal::led(("thread-led", id.as_u64() as usize), led, pulse, p);
        let title = h_flex()
            .min_w_0()
            .gap(px(5.))
            .when(pinned, |this| this.child(Icon::new(Lucide::Pin).size(px(11.)).text_color(p.ink3).flex_none()))
            .child(
                div()
                    .min_w_0()
                    .truncate()
                    .text_size(px(13.5))
                    .font_weight(FontWeight::SEMIBOLD)
                    .text_color(if selected || !settled { p.ink } else { p.ink2 })
                    .child(state.title.clone()),
            );
        let age = div()
            .flex_none()
            .font_family(metal::MONO)
            .text_size(px(10.5))
            .text_color(p.ink3)
            .child(match snoozed_until {
                Some(until) => clock::when(until),
                None => relative_time(state.updated),
            });

        let workspace = cx.weak_entity();
        let menu_session = session.clone();
        let card = v_flex()
            .id(("thread", id.as_u64() as usize))
            .group(group)
            .px(px(12.))
            .py(px(if compact { 5. } else { 9. }))
            .gap(px(4.))
            .rounded(px(if compact { 12. } else { 14. }))
            .cursor_pointer()
            .map(|this| {
                if selected {
                    this.raised(p)
                } else {
                    this.hover(|style| style.bg(p.row_hover))
                }
            })
            .on_click(cx.listener(move |this, _, window, cx| {
                this.select(project_ix, select_session.clone(), window, cx)
            }));

        let card = if compact {
            card.child(
                h_flex()
                    .h(px(22.))
                    .gap(px(7.))
                    .child(metal::badge(project.initials.clone(), project.tint, p))
                    .child(title.flex_1().text_size(px(13.)).font_weight(FontWeight::MEDIUM))
                    .child(actions)
                    .child(age)
                    .child(led),
            )
        } else {
            card.child(
                h_flex()
                    .gap(px(7.))
                    .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.ink3)
                            .child(project.name.clone()),
                    )
                    .when_some(host, |this, host| this.child(metal::tag(host, p)))
                    .when(snoozed_until.is_some(), |this| {
                        this.child(Icon::new(Lucide::AlarmClock).size(px(11.)).text_color(p.ink3))
                    })
                    .child(age),
            )
            .child(title)
            .child(
                h_flex()
                    .h(px(18.))
                    .gap(px(6.))
                    .text_color(p.ink3)
                    .when_some(branch, |this, branch| {
                        this.child(Icon::new(if in_worktree { Lucide::FolderGit2 } else { Lucide::GitBranch }).size(px(11.)))
                            .child(
                                div()
                                    .min_w_0()
                                    .truncate()
                                    .font_family(metal::MONO)
                                    .text_size(px(10.5))
                                    .child(branch),
                            )
                    })
                    .child(div().flex_1())
                    .child(pickers::provider_icon(state.provider, px(11.), p))
                    .child(actions)
                    .child(led),
            )
        };
        card.context_menu(move |menu, window, cx| {
            Self::thread_menu(workspace.clone(), menu_session.clone(), menu, window, cx)
        })
        .into_any_element()
    }

    /// A foldable section header in the thread list: `Settled (3) ——— ›`.
    fn render_fold(
        &self,
        id: &'static str,
        label: String,
        open: bool,
        toggle: fn(&mut Self),
        cx: &mut Context<Self>,
    ) -> AnyElement {
        let p = metal::palette(cx);
        h_flex()
            .id(id)
            .mt(px(10.))
            .h(px(30.))
            .px(px(12.))
            .gap(px(8.))
            .rounded(px(10.))
            .cursor_pointer()
            .text_size(px(12.5))
            .font_weight(FontWeight::MEDIUM)
            .text_color(p.ink3)
            .hover(|style| style.bg(p.row_hover).text_color(p.ink))
            .on_click(cx.listener(move |this, _, _, cx| {
                toggle(this);
                cx.notify();
            }))
            .child(label)
            .child(div().flex_1().h(px(1.)).bg(p.rule))
            .child(Icon::new(if open { IconName::ChevronDown } else { IconName::ChevronRight }).size(px(12.)))
            .into_any_element()
    }

    /// A project's header in the grouped thread list.
    fn render_project_header(&self, ix: usize, count: usize, cx: &mut Context<Self>) -> AnyElement {
        let p = metal::palette(cx);
        let project = &self.projects[ix];
        let collapsed = self.collapsed_projects.contains(&project.path);
        let path = project.path.clone();
        h_flex()
            .id(("project-group", ix))
            .group(SharedString::from(format!("project-group-{ix}")))
            .mt(px(8.))
            .h(px(30.))
            .pl(px(10.))
            .pr(px(4.))
            .gap(px(7.))
            .rounded(px(10.))
            .cursor_pointer()
            .hover(|style| style.bg(p.row_hover))
            .on_click(cx.listener(move |this, _, _, cx| {
                if !this.collapsed_projects.remove(&path) {
                    this.collapsed_projects.insert(path.clone());
                }
                cx.notify();
            }))
            .child(Icon::new(if collapsed { IconName::ChevronRight } else { IconName::ChevronDown }).size(px(12.)).text_color(p.ink3))
            .child(metal::badge(project.initials.clone(), project.tint, p))
            .child(
                div()
                    .flex_1()
                    .min_w_0()
                    .truncate()
                    .text_size(px(12.5))
                    .font_weight(FontWeight::SEMIBOLD)
                    .text_color(p.ink2)
                    .child(project.name.clone()),
            )
            .child(div().font_family(metal::MONO).text_size(px(10.5)).text_color(p.ink3).child(count.to_string()))
            .child(
                div().invisible().group_hover(SharedString::from(format!("project-group-{ix}")), |this| this.visible()).child(
                    MetalButton::new(("project-new-thread", ix))
                        .ghost()
                        .mini()
                        .icon(Lucide::SquarePen)
                        .tooltip(format!("New thread in {}", project.name))
                        .on_click(cx.listener(move |this, _, window, cx| {
                            cx.stop_propagation();
                            this.new_thread(ix, window, cx);
                        })),
                ),
            )
            .into_any_element()
    }

    fn render_thread_list(&self, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let (open, settled) = self.threads(cx);
        let searching = !self.search.read(cx).value().trim().is_empty();
        let (group, working_section) = settings::get(|settings| (settings.group_by_project, settings.working_section));

        // Pinned threads stay on top; snoozed ones wait below; working ones
        // fold away (when set to) until they need the user.
        let mut pinned = Vec::new();
        let mut snoozed = Vec::new();
        let mut working = Vec::new();
        let mut rest = Vec::new();
        for entry in open {
            let state = entry.1.read(cx);
            if state.is_snoozed() {
                snoozed.push(entry);
            } else if state.pinned {
                pinned.push(entry);
            } else if working_section && state.is_working() && !state.needs_attention() {
                working.push(entry);
            } else {
                rest.push(entry);
            }
        }
        let is_empty = pinned.is_empty() && snoozed.is_empty() && working.is_empty() && rest.is_empty() && settled.is_empty();
        let mut children: Vec<AnyElement> = Vec::new();
        if !pinned.is_empty() {
            children.push(
                div()
                    .px(px(12.))
                    .pt(px(2.))
                    .pb(px(4.))
                    .child(metal::engraved(format!("Pinned · {}", pinned.len()), p))
                    .into_any_element(),
            );
            children.extend(pinned.iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)));
            if !rest.is_empty() || !working.is_empty() {
                children.push(div().px(px(12.)).py(px(6.)).child(metal::rule(p)).into_any_element());
            }
        }
        if !working.is_empty() {
            let open = self.working_open || searching;
            children.push(self.render_fold(
                "working-toggle",
                format!("Working ({})", working.len()),
                open,
                |this| this.working_open = !this.working_open,
                cx,
            ));
            if open {
                children.extend(working.iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)));
            }
        }
        if group {
            for ix in self.ordered_projects(cx) {
                let threads: Vec<_> = rest.iter().filter(|(project, _)| *project == ix).collect();
                if threads.is_empty() {
                    continue;
                }
                children.push(self.render_project_header(ix, threads.len(), cx));
                if searching || !self.collapsed_projects.contains(&self.projects[ix].path) {
                    children.extend(threads.into_iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)));
                }
            }
        } else {
            children.extend(rest.iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)));
        }
        for (id, label, threads, open, toggle) in [
            (
                "snoozed-toggle",
                "Snoozed",
                snoozed,
                self.snoozed_open || searching,
                (|this: &mut Self| this.snoozed_open = !this.snoozed_open) as fn(&mut Self),
            ),
            (
                "settled-toggle",
                "Settled",
                settled,
                self.settled_open || searching,
                |this: &mut Self| this.settled_open = !this.settled_open,
            ),
        ] {
            if threads.is_empty() {
                continue;
            }
            children.push(self.render_fold(id, format!("{label} ({})", threads.len()), open, toggle, cx));
            if open {
                children.extend(threads.iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)));
            }
        }

        v_flex()
            .id("threads")
            .flex_1()
            .min_h_0()
            .overflow_y_scroll()
            .px(px(8.))
            .pt(px(12.))
            .pb(px(12.))
            .gap(px(3.))
            .when(is_empty, |this| {
                this.child(
                    div()
                        .px(px(12.))
                        .py(px(8.))
                        .text_size(px(12.5))
                        .text_color(p.ink3)
                        .child(if searching { "No threads match." } else { "No threads yet." }),
                )
            })
            .children(children)
    }

    fn render_sidebar_footer(&self, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let catalog = Catalog::global(cx);
        let claude = catalog.first_of(Provider::Claude);
        let account = claude.and_then(|state| state.account.clone());
        let checking = claude.is_some_and(|state| state.checking || state.checked.is_none());
        let workspace = cx.entity().downgrade();

        h_flex()
            .flex_none()
            .h(px(48.))
            .px(px(12.))
            .gap(px(8.))
            .child(
                MetalButton::new("settings")
                    .ghost()
                    .compact()
                    .icon(IconName::Settings2)
                    .label("Settings")
                    .tooltip("Settings (Ctrl+,)")
                    .on_click(move |_, window, cx| {
                        let _ = workspace.update(cx, |this, cx| this.open_settings(window, cx));
                    }),
            )
            .child(div().flex_1())
            .child(
                div()
                    .min_w_0()
                    .truncate()
                    .font_family(metal::MONO)
                    .text_size(px(10.5))
                    .text_color(p.ink3)
                    .child(account.clone().unwrap_or_else(|| if checking { "Connecting…" } else { "Not signed in" }.into())),
            )
            .child(metal::led("account-led", if account.is_some() { Led::Live } else { Led::Off }, false, p))
    }

    fn render_sidebar(&self, cx: &mut Context<Self>) -> impl IntoElement {
        v_flex()
            .w(px(SIDEBAR_WIDTH))
            .h_full()
            .flex_none()
            .child(self.render_sidebar_header(cx))
            .child(self.render_sidebar_toolbar(cx))
            .child(self.render_thread_list(cx))
            .child(self.render_sidebar_footer(cx))
    }

    fn render_top_bar(&self, panel_visible: bool, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let project = self.active_project();
        let title = self.active.as_ref().map(|session| session.read(cx).title.clone());

        v_flex()
            .flex_none()
            .child(
                h_flex()
                    .h(px(52.))
                    .pl(px(if self.sidebar_open { 20. } else { 12. }))
                    .pr(px(12.))
                    .gap(px(10.))
                    .when(!self.sidebar_open, |this| {
                        this.child(
                            MetalButton::new("show-sidebar")
                                .ghost()
                                .mini()
                                .icon(Lucide::PanelLeftOpen)
                                .tooltip("Show sidebar (Ctrl+B)")
                                .on_click(cx.listener(|this, _, _, cx| {
                                    this.sidebar_open = true;
                                    cx.notify();
                                })),
                        )
                    })
                    .when_some(project, |this, project| {
                        this.child(metal::badge(project.initials.clone(), project.tint, p)).child(
                            div()
                                .flex_none()
                                .text_size(px(14.))
                                .font_weight(FontWeight::MEDIUM)
                                .text_color(p.ink2)
                                .child(project.name.clone()),
                        )
                    })
                    .when_some(title, |this, title| {
                        this.child(div().text_size(px(14.)).text_color(p.ink3).child("/")).child(
                            div()
                                .flex_1()
                                .min_w_0()
                                .truncate()
                                .text_size(px(14.))
                                .font_weight(FontWeight::SEMIBOLD)
                                .text_color(p.ink)
                                .child(title),
                        )
                    })
                    .child(div().flex_1())
                    .child(
                        MetalButton::new("toggle-panel")
                            .ghost()
                            .mini()
                            .icon(if panel_visible { Lucide::PanelRightClose } else { Lucide::PanelRightOpen })
                            .tooltip("Project panel (Ctrl+Shift+B)")
                            .on_click(cx.listener(|this, _, _, cx| {
                                this.panel_open = !this.panel_open;
                                cx.notify();
                            })),
                    ),
            )
            .child(metal::rule(p))
    }

    /// One row of the project panel: an icon, a label and an optional trailer.
    fn panel_row(icon: impl Into<Icon>, label: impl Into<SharedString>, p: &Palette) -> Div {
        h_flex()
            .h(px(36.))
            .px(px(10.))
            .gap(px(10.))
            .rounded(px(12.))
            .text_size(px(13.5))
            .font_weight(FontWeight::MEDIUM)
            .text_color(p.ink)
            .child(icon.into().size(px(15.)).text_color(p.ink2))
            .child(div().flex_1().min_w_0().truncate().child(label.into()))
    }

    fn render_panel(&self, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let Some(project) = self.active_project() else {
            return div().into_any_element();
        };
        let active = self.active.as_ref().map(|session| session.read(cx));
        let worktree = active.and_then(|session| session.worktree.clone());
        let pull_request = active.and_then(|session| session.pull_request.clone());
        let git = match &worktree {
            Some(worktree) => self.worktree_git.get(&worktree.root).cloned().or_else(|| {
                Some(GitInfo { branch: Some(worktree.branch.clone()), ..GitInfo::default() })
            }),
            None => project.git.clone(),
        };
        let path = worktree.as_ref().map(|worktree| worktree.root.clone()).unwrap_or_else(|| project.path.clone());
        let host = project.environment.as_deref().map(|env| self.environment_name(env));
        let remote = host.is_some();
        let has_thread = self.active.is_some();
        let cost = active
            .map(|session| session.total_cost)
            .filter(|cost| *cost > 0. && settings::get(|settings| settings.show_costs));
        let workspace = cx.entity().downgrade();
        let editors = self.editors.clone();
        let editor = self.editors.get(self.editor);

        let hover = |row: Stateful<Div>| row.cursor_pointer().hover(|style| style.bg(p.row_hover));

        let editor_row = match editor {
            Some(app) => h_flex()
                .gap(px(2.))
                .child(
                    hover(
                        Self::panel_row(Lucide::Code, format!("Open in {}", app.name), p)
                            .flex_1()
                            .id("open-editor"),
                    )
                    .on_click(cx.listener(|this, _, window, cx| this.open_in_editor(this.editor, window, cx))),
                )
                .child(
                    MetalButton::new("editor-menu")
                        .ghost()
                        .mini()
                        .icon(IconName::ChevronDown)
                        .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
                            for (ix, app) in editors.iter().enumerate() {
                                let workspace = workspace.clone();
                                menu = menu.item(PopupMenuItem::new(app.name).on_click(move |_, window, cx| {
                                    let _ = workspace.update(cx, |this, cx| this.open_in_editor(ix, window, cx));
                                }));
                            }
                            menu
                        }),
                )
                .into_any_element(),
            None => Self::panel_row(Lucide::Code, "No editor found", p)
                .text_color(p.ink3)
                .into_any_element(),
        };

        let git_rows: AnyElement = match git {
            Some(git) => {
                let branches = git.branches.clone();
                let current = git.branch.clone();
                let workspace = cx.entity().downgrade();
                let branch_label = git.branch.clone().unwrap_or_else(|| "Detached HEAD".into());
                let has_changes = git.added + git.removed > 0;
                let branch_row = if worktree.is_some() {
                    Self::panel_row(Lucide::FolderGit2, branch_label, p).child(metal::tag("Worktree", p)).into_any_element()
                } else if remote {
                    Self::panel_row(Lucide::GitBranch, branch_label, p).into_any_element()
                } else {
                    MetalButton::new("branch-menu")
                        .ghost()
                        .w_full()
                        .h(px(36.))
                        .px(px(10.))
                        .rounded(px(12.))
                        .justify_start()
                        .gap(px(10.))
                        .text_size(px(13.5))
                        .text_color(p.ink)
                        .icon(Lucide::GitBranch)
                        .label(branch_label)
                        .stretch()
                        .caret()
                        .dropdown_menu(move |mut menu, _, _| {
                            menu = menu.min_w(px(220.)).label("Switch branch");
                            for branch in branches.clone() {
                                let workspace = workspace.clone();
                                let checked = current.as_deref() == Some(branch.as_str());
                                menu = menu.item(PopupMenuItem::new(branch.clone()).checked(checked).on_click(
                                    move |_, window, cx| {
                                        let branch = branch.clone();
                                        let _ = workspace
                                            .update(cx, |this, cx| this.switch_branch(branch, window, cx));
                                    },
                                ));
                            }
                            menu
                        })
                        .into_any_element()
                };
                v_flex()
                    .gap(px(2.))
                    .child(branch_row)
                    .child(self.render_commit_row(has_thread && has_changes, cx))
                    .child(
                        Self::panel_row(Lucide::FileDiff, "Changes", p)
                            .child(
                                div()
                                    .font_family(metal::MONO)
                                    .text_size(px(11.5))
                                    .text_color(p.green_ink)
                                    .child(format!("+{}", git.added)),
                            )
                            .child(
                                div()
                                    .font_family(metal::MONO)
                                    .text_size(px(11.5))
                                    .text_color(p.red)
                                    .child(format!("−{}", git.removed)),
                            ),
                    )
                    .when_some(pull_request, |this, pull_request| {
                        let url = pull_request.url.clone();
                        let state = match pull_request.state.as_str() {
                            "MERGED" => "Merged",
                            "CLOSED" => "Closed",
                            _ => "Open",
                        };
                        this.child(
                            hover(
                                Self::panel_row(Lucide::GitPullRequest, format!("Pull request #{}", pull_request.number), p)
                                    .id("pull-request"),
                            )
                            .on_click(move |_, _, _| open_url(&url))
                            .child(metal::tag(state, p)),
                        )
                    })
                    .into_any_element()
            }
            None => Self::panel_row(Lucide::GitBranch, "Not a git repository", p)
                .text_color(p.ink3)
                .into_any_element(),
        };

        v_flex()
            .w(px(PANEL_WIDTH))
            .flex_none()
            .p(px(12.))
            .pl(px(0.))
            .child(
                v_flex()
                    .p(px(6.))
                    .gap(px(2.))
                    .rounded(px(18.))
                    .raised(p)
                    .child(match host {
                        Some(host) => Self::panel_row(Lucide::Server, host, p).child(metal::tag("Remote", p)),
                        None => Self::panel_row(Lucide::Laptop, self.hostname.clone(), p).child(metal::tag("Local", p)),
                    })
                    .child(
                        div()
                            .px(px(10.))
                            .pb(px(4.))
                            .truncate()
                            .font_family(metal::MONO)
                            .text_size(px(10.5))
                            .text_color(p.ink3)
                            .child(display_path(&path)),
                    )
                    .when(!remote, |this| this.child(editor_row))
                    .child(div().px(px(8.)).py(px(4.)).child(metal::rule(p)))
                    .child(git_rows)
                    .when_some(cost, |this, cost| {
                        this.child(div().px(px(8.)).py(px(4.)).child(metal::rule(p))).child(
                            Self::panel_row(IconName::ChartPie, "Thread cost", p).child(
                                div()
                                    .font_family(metal::MONO)
                                    .text_size(px(11.5))
                                    .text_color(p.ink2)
                                    .child(format!("${cost:.4}")),
                            ),
                        )
                    }),
            )
            .into_any_element()
    }

    fn render_commit_row(&self, enabled: bool, cx: &mut Context<Self>) -> impl IntoElement {
        const COMMIT: &str = "Commit all current changes in this repository with a concise, descriptive commit message.";
        const COMMIT_AND_PUSH: &str = "Commit all current changes in this repository with a concise, descriptive commit message, then push the branch to its remote.";
        const PULL_REQUEST: &str = "Commit all current changes in this repository with a concise, descriptive commit message, push the branch, and open a pull request for it with `gh pr create`.";
        let p = metal::palette(cx);
        let workspace = cx.entity().downgrade();
        h_flex()
            .gap(px(2.))
            .child(
                Self::panel_row(Lucide::GitCommitHorizontal, "Commit", p)
                    .flex_1()
                    .id("commit")
                    .map(|this| {
                        if enabled {
                            this.cursor_pointer()
                                .hover(|style| style.bg(p.row_hover))
                                .on_click(cx.listener(|this, _, _, cx| this.ask_agent(COMMIT, cx)))
                        } else {
                            this.opacity(0.45)
                        }
                    })
                    .tooltip(|window, cx| {
                        gpui_kit::component::tooltip::Tooltip::new("Ask the agent to commit the changes")
                            .build(window, cx)
                    }),
            )
            .child(
                MetalButton::new("commit-menu")
                    .ghost()
                    .mini()
                    .icon(IconName::ChevronDown)
                    .disabled(!enabled)
                    .dropdown_menu_with_anchor(Anchor::TopRight, move |menu, _, _| {
                        let ask = |prompt: &'static str| {
                            let workspace = workspace.clone();
                            move |_: &ClickEvent, _: &mut Window, cx: &mut App| {
                                let _ = workspace.update(cx, |this, cx| this.ask_agent(prompt, cx));
                            }
                        };
                        menu.min_w(px(240.))
                            .item(PopupMenuItem::new("Ask the agent to commit").on_click(ask(COMMIT)))
                            .item(PopupMenuItem::new("Ask the agent to commit and push").on_click(ask(COMMIT_AND_PUSH)))
                            .item(PopupMenuItem::new("Ask the agent to open a pull request").on_click(ask(PULL_REQUEST)))
                    }),
            )
    }

    fn render_empty_sheet(&self, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        v_flex()
            .size_full()
            .items_center()
            .justify_center()
            .gap(px(14.))
            .child(metal::engraved("No thread open", p))
            .child(
                div()
                    .text_size(px(22.))
                    .font_weight(FontWeight::SEMIBOLD)
                    .text_color(p.ink)
                    .child("Pick a thread, or start a new one."),
            )
            .child(
                MetalButton::new("empty-new-thread")
                    .primary()
                    .icon(Lucide::SquarePen)
                    .label("New thread")
                    .on_click(cx.listener(|this, _, window, cx| {
                        if this.projects.is_empty() {
                            this.prompt_for_project(window, cx);
                        } else {
                            this.new_thread(this.active_project, window, cx);
                        }
                    })),
            )
    }
}

impl Render for Workspace {
    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let chat = self
            .active
            .as_ref()
            .and_then(|session| self.chats.get(&session.entity_id()))
            .cloned();
        let title = match &self.active {
            Some(session) => format!("{} — Vectrum Code", session.read(cx).title),
            None => "Vectrum Code".to_string(),
        };
        window.set_window_title(&title);
        // The conversation's start screen reads the projects from here.
        let scratch = scratch_dir();
        let directory = ProjectDirectory {
            hostname: self.hostname.clone(),
            projects: self
                .ordered_projects(cx)
                .into_iter()
                .map(|ix| &self.projects[ix])
                .map(|project| ProjectSummary {
                    name: project.name.clone(),
                    path: project.path.clone(),
                    environment: project.environment.clone(),
                    host: project.environment.as_deref().map(|env| self.environment_name(env).into()),
                    scratch: Some(&project.path) == scratch.as_ref(),
                    git: project.git.is_some(),
                    branch: project.git.as_ref().and_then(|git| git.branch.clone()),
                    branches: project.git.as_ref().map(|git| git.branches.clone()).unwrap_or_default(),
                })
                .collect(),
        };
        cx.set_global(directory);
        let p = metal::palette(cx);
        let panel_visible = self.panel_open
            && self.active_project().is_some()
            && window.viewport_size().width >= px(PANEL_MIN_WINDOW);

        h_flex()
            .key_context("Workspace")
            .track_focus(&self.focus_handle)
            .size_full()
            .bg(p.page)
            .text_color(p.ink)
            .font_family(cx.theme().font_family.clone())
            .on_action(cx.listener(|this, _: &NewThread, window, cx| {
                if this.projects.is_empty() {
                    this.prompt_for_project(window, cx);
                } else {
                    this.new_thread(this.active_project, window, cx);
                }
            }))
            .on_action(cx.listener(|this, _: &OpenProject, window, cx| {
                this.prompt_for_project(window, cx)
            }))
            .on_action(cx.listener(|this, _: &ToggleSidebar, _, cx| {
                this.sidebar_open = !this.sidebar_open;
                cx.notify();
            }))
            .on_action(cx.listener(|this, _: &ToggleProjectPanel, _, cx| {
                this.panel_open = !this.panel_open;
                cx.notify();
            }))
            .on_action(cx.listener(|this, _: &FocusSearch, window, cx| {
                this.settings_open = false;
                this.sidebar_open = true;
                this.search.update(cx, |search, cx| search.focus(window, cx));
                cx.notify();
            }))
            .on_action(cx.listener(|this, _: &OpenSettings, window, cx| {
                if this.settings_open {
                    this.close_settings(window, cx);
                } else {
                    this.open_settings(window, cx);
                }
            }))
            .when(self.settings_open, |this| this.child(self.settings_view.clone()))
            .when(!self.settings_open && self.sidebar_open, |this| this.child(self.render_sidebar(cx)))
            .when(!self.settings_open, |this| this.child(
                div()
                    .flex_1()
                    .min_w_0()
                    .h_full()
                    .py(px(SHEET_INSET))
                    .pr(px(SHEET_INSET))
                    .when(!self.sidebar_open, |this| this.pl(px(SHEET_INSET)))
                    .child(
                        v_flex()
                            .size_full()
                            .rounded(px(SHEET_RADIUS))
                            .plate(p)
                            .child(self.render_top_bar(panel_visible, cx))
                            .child(
                                h_flex()
                                    .flex_1()
                                    .min_h_0()
                                    .items_start()
                                    .child(div().flex_1().min_w_0().h_full().map(|this| match chat {
                                        Some(chat) => this.child(chat),
                                        None => this.child(self.render_empty_sheet(cx)),
                                    }))
                                    .when(panel_visible, |this| this.child(self.render_panel(cx))),
                            ),
                    ),
            ))
            // The Add project browser floats over everything; a click beside it cancels.
            .when_some(self.folder_browser.as_ref().map(|(browser, _)| browser.clone()), |this, browser| {
                this.child(
                    div()
                        .absolute()
                        .inset_0()
                        .flex()
                        .items_center()
                        .justify_center()
                        .bg(black().opacity(if p.dark { 0.4 } else { 0.16 }))
                        .occlude()
                        .on_mouse_down(
                            MouseButton::Left,
                            cx.listener(|this, _, window, cx| this.close_folder_browser(window, cx)),
                        )
                        .child(browser),
                )
            })
    }
}

/// Two letters for a project's badge: the first letters of its first two
/// words (`vectrum.git` → `VG`), or of its name (`T3-2` → `T3`).
fn initials(name: &str) -> String {
    let words: Vec<&str> = name.split(|c: char| !c.is_alphanumeric()).filter(|word| !word.is_empty()).collect();
    let letters: String = match words.as_slice() {
        [first, second, ..] if second.starts_with(|c: char| c.is_alphabetic()) => {
            first.chars().take(1).chain(second.chars().take(1)).collect()
        }
        [first, ..] => first.chars().take(2).collect(),
        [] => "?".into(),
    };
    letters.to_uppercase()
}

fn tint_for(name: &str) -> usize {
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    name.hash(&mut hasher);
    hasher.finish() as usize
}

fn relative_time(time: SystemTime) -> String {
    let secs = time.elapsed().unwrap_or_default().as_secs();
    match secs {
        0..60 => "now".into(),
        60..3_600 => format!("{}m", secs / 60),
        3_600..86_400 => format!("{}h", secs / 3_600),
        _ => format!("{}d", secs / 86_400),
    }
}

fn hostname() -> String {
    std::fs::read_to_string("/proc/sys/kernel/hostname")
        .or_else(|_| std::fs::read_to_string("/etc/hostname"))
        .map(|name| name.trim().to_string())
        .ok()
        .filter(|name| !name.is_empty())
        .unwrap_or_else(|| "This computer".into())
}

/// Whether the machine is saving power: the power-saver platform profile.
fn power_saving() -> bool {
    std::fs::read_to_string("/sys/firmware/acpi/platform_profile")
        .is_ok_and(|profile| matches!(profile.trim(), "low-power" | "quiet"))
}

pub(crate) fn find_program(names: &[&str]) -> Option<PathBuf> {
    let paths = std::env::var_os("PATH")?;
    std::env::split_paths(&paths)
        .flat_map(|dir| names.iter().map(move |name| dir.join(name)))
        .find(|path| path.is_file())
}

fn detect_editors() -> Vec<EditorApp> {
    const CANDIDATES: &[(&str, &[&str])] = &[
        ("Zed", &["zeditor", "zed"]),
        ("VS Code", &["code"]),
        ("Cursor", &["cursor"]),
        ("VSCodium", &["codium"]),
        ("Sublime Text", &["subl"]),
        ("Kate", &["kate"]),
        ("File manager", &["xdg-open"]),
    ];
    CANDIDATES
        .iter()
        .filter_map(|(name, programs)| Some(EditorApp { name, program: find_program(programs)? }))
        .collect()
}

/// Runs a program in the background and forgets it, reaping it when it exits.
fn spawn_detached(command: &mut Command) {
    let spawned = command.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn();
    if let Ok(mut child) = spawned {
        std::thread::spawn(move || child.wait());
    }
}

/// Opens a link in the default browser.
pub(crate) fn open_url(url: &str) {
    if let Some(program) = find_program(&["xdg-open"]) {
        spawn_detached(Command::new(program).arg(url));
    }
}

/// Posts a desktop notification through `notify-send`, if it's installed,
/// with the desktop's "complete" sound when `sound` is on.
fn desktop_notification(title: &str, body: &str, sound: bool) {
    if let Some(program) = find_program(&["notify-send"]) {
        spawn_detached(Command::new(program).args(["--app-name=Vectrum Code", title, body]));
    }
    if sound {
        play_sound();
    }
}

/// Plays the freedesktop "complete" sound with whichever player is around.
fn play_sound() {
    const SOUND: &str = "/usr/share/sounds/freedesktop/stereo/complete.oga";
    if let Some(program) = find_program(&["canberra-gtk-play"]) {
        spawn_detached(Command::new(program).args(["-i", "complete", "-d", "Vectrum Code"]));
    } else if let Some(program) = find_program(&["pw-play", "paplay"])
        && std::path::Path::new(SOUND).exists()
    {
        spawn_detached(Command::new(program).arg(SOUND));
    }
}

/// Where threads started without a project run.
pub(crate) fn scratch_dir() -> Option<PathBuf> {
    let dir = settings::data_dir()?.join("scratch");
    std::fs::create_dir_all(&dir).ok()?;
    Some(dir)
}

#[cfg(test)]
mod tests {
    use super::initials;

    #[::core::prelude::v1::test]
    fn project_initials() {
        assert_eq!(initials("T3-2"), "T3");
        assert_eq!(initials("vectrum.git"), "VG");
        assert_eq!(initials("my-pi-config"), "MP");
        assert_eq!(initials("codey"), "CO");
    }
}