Vectrum
Sign In
2466 lines · 107.5 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::{Path, PathBuf},
    process::{Command, Stdio},
    rc::Rc,
    time::{Duration, Instant, SystemTime},
};

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

use crate::browser;
use crate::device;
use crate::claude::PermissionMode;
use crate::git::{self, GitInfo};
use crate::models::{Catalog, ModelEntry, Provider, split_key};
use crate::session::{NewSession, Reasoning, Session, from_millis, to_millis};
use crate::settings::{self, BackgroundActivity, ProjectOrder, WorkspaceMode};
use crate::store::{self, AppState, SavedProject};
use crate::ui::browser::{BrowserPanel, BrowserPanelEvent};
use crate::ui::chat::{ChatEvent, ChatView, display_path};
use crate::projects::{self, ProjectSource};
use crate::remote::ClientMessage;
use crate::ui::folder_browser;
use crate::ui::icons::Lucide;
use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette};
use crate::ui::settings::{SettingsEvent, SettingsView};
use crate::worktree::{self, Worktree, WorktreePlan};
use crate::{
    CopyThreadReference, CycleAppearance, FocusSearch, NewThread, NewThreadLocal, NewThreadWithoutProject, NextThread,
    CloseTerminal, NewTerminal, OpenInEditor, OpenProject, OpenSettings, OpenUsage, PinThread, PreviousThread, SelectTheme,
    SettleThread, SplitTerminal, SplitTerminalVertical, ToggleBrowser, ToggleCommandPalette, ToggleProjectPanel,
    ToggleRightPanel, ToggleSidebar, ToggleSubagents, ToggleTerminal, UndoThreadAction,
};

mod actions;
mod delegation;
mod devices;
mod git_actions;
mod header;
mod palette;
mod remote;
mod sidebar;
mod subagents;
mod surfaces;
mod terminal_drawer;

use actions::UndoEntry;
use palette::CommandPalette;
use remote::RemoteState;
pub use remote::SettingsEnvironment;
use terminal_drawer::TerminalDrawer;

pub(crate) const SIDEBAR_WIDTH: f32 = 280.;
/// The narrowest the thread sidebar drags to, and how much room it leaves
/// the conversation.
const SIDEBAR_MIN_WIDTH: f32 = 208.;
const MAIN_MIN_WIDTH: f32 = 640.;
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);
/// How long Ctrl is held before the Ctrl+1–9 hints show.
const JUMP_HINT_DELAY: Duration = Duration::from_millis(200);
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>,
    /// The worktree the project's most recently active thread ran in, for
    /// the composer's "Previous worktree".
    pub previous_worktree: Option<Worktree>,
}

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>,
    /// The repository it's in (its common directory) and its path inside
    /// the checkout, for grouping projects in the legacy sidebar.
    repo: Option<(PathBuf, PathBuf)>,
}

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

/// Names a project across changes to the list: its path, and the remote
/// connection it's on (`None` for this device).
pub(crate) type ProjectKey = (PathBuf, Option<String>);

/// A thread title being edited in place, in the sidebar or the chat header.
struct InlineRename {
    session: Entity<Session>,
    input: Entity<InputState>,
    /// In the sidebar row, rather than the chat header.
    sidebar: bool,
    _subscription: Subscription,
}

/// A menu opened from code rather than a right click: the chat header's
/// thread menu, the project scope picker's settings, and the like.
struct OverlayMenu {
    menu: Entity<PopupMenu>,
    position: Point<Pixels>,
    _dismiss: Subscription,
}

/// 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 {
    /// When Settings → Storage's worktree cleanup last ran.
    last_worktree_cleanup: Option<Instant>,
    /// The open thread's checkout as the git actions see it.
    git_status: Option<(PathBuf, git::GitStatus)>,
    /// The git action running, and since when.
    git_running: Option<(&'static str, Instant)>,
    /// The row whose hover card stays hidden while its menu is open.
    hover_card_hidden: Option<EntityId>,
    projects: Vec<Project>,
    chats: HashMap<EntityId, Entity<ChatView>>,
    /// Each thread's browser panel, made the first time it's opened.
    browsers: HashMap<EntityId, Entity<BrowserPanel>>,
    /// The browser panel showing, which watches its stream.
    browser_shown: Option<EntityId>,
    active: Option<Entity<Session>>,
    active_project: usize,
    search: Entity<InputState>,
    sidebar_open: bool,
    panel_open: bool,
    /// What each thread's right panel shows, in the project panel's place: a
    /// surface (subagents, browser, device, terminal) or the list to pick one
    /// from. Each thread has its own, so another thread's device or browser
    /// doesn't follow you around.
    right_panels: HashMap<EntityId, surfaces::Surface>,
    /// The right panel while no thread is open.
    idle_right_panel: Option<surfaces::Surface>,
    /// The surface picker's focus, for its keys.
    surface_focus: FocusHandle,
    settled_open: bool,
    snoozed_open: bool,
    working_open: bool,
    /// How many settled threads the Settled shelf lists before "Show N more".
    settled_shown: usize,
    /// Projects folded away in the legacy sidebar.
    collapsed_projects: HashSet<PathBuf>,
    /// Projects whose full thread list the legacy sidebar shows ("Show more").
    expanded_projects: HashSet<PathBuf>,
    /// The project the sidebar is filtered to; `None` shows all projects.
    project_scope: Option<ProjectKey>,
    /// The project scope picker, while open, with its search field.
    scope_picker: Option<(Entity<InputState>, Subscription)>,
    /// Threads picked with Ctrl+click or Shift+click, in the order picked.
    selection: Vec<EntityId>,
    /// Where a Shift+click range starts.
    selection_anchor: Option<EntityId>,
    /// The highlighted row of the sidebar's search results.
    search_highlight: usize,
    renaming: Option<InlineRename>,
    overlay_menu: Option<OverlayMenu>,
    /// The command palette, while open.
    palette: Option<(Entity<CommandPalette>, Subscription)>,
    /// Actions the undo notice can take back, oldest first.
    undo: Vec<UndoEntry>,
    undo_generation: u64,
    undo_timer: Task<()>,
    /// When Ctrl went down, for showing the Ctrl+1–9 jump hints after a beat.
    ctrl_held: Option<Instant>,
    jump_hints: bool,
    /// A title click waiting to see whether it becomes a double-click.
    title_click: Task<()>,
    /// The thread sidebar's width, and whether its edge is being dragged.
    sidebar_width: f32,
    resizing_sidebar: bool,
    /// Each thread's terminal drawer, made the first time it's opened.
    terminals: HashMap<EntityId, TerminalDrawer>,
    /// The terminal drawer's height, and where a drag of its edge started
    /// (the pointer's y and the height then).
    terminal_height: f32,
    terminal_drag: Option<(Pixels, f32)>,
    editors: Rc<Vec<EditorApp>>,
    editor: usize,
    hostname: SharedString,
    settings_view: Entity<SettingsView>,
    settings_open: bool,
    /// The Add project browser, while it's open.
    activity: HashMap<EntityId, Activity>,
    /// The thread whose settle button waits for its confirming click.
    settle_armed: Option<(EntityId, Instant)>,
    /// The legacy row whose Archive button waits for its Confirm click.
    archive_armed: Option<(EntityId, Instant)>,
    /// Where the chat header's title sits, to open its menu under it.
    title_bounds: Rc<std::cell::Cell<Bounds<Pixels>>>,
    /// 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,
    /// Tasks agents delegated to the app's subagents.
    delegations: delegation::Delegations,
    /// Each thread's Device panel and the device floating over the chat.
    devices: devices::Devices,
    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();
        crate::ui::inline::set_workspace(workspace.clone(), cx);
        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());
                    }
                }
                // Device helpers stop with the app; simulators and emulators keep running.
                device::service().stop_all();
                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_in(&search, window, |this, _, event: &InputEvent, window, cx| match event {
                InputEvent::Change => {
                    this.search_highlight = 0;
                    cx.notify();
                }
                InputEvent::PressEnter { .. } => this.open_search_result(window, cx),
                _ => {}
            }),
        ];
        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 {
            last_worktree_cleanup: None,
            git_status: None,
            git_running: None,
            hover_card_hidden: None,
            projects: Vec::new(),
            chats: HashMap::new(),
            browsers: HashMap::new(),
            browser_shown: None,
            active: None,
            active_project: 0,
            search,
            sidebar_open: true,
            panel_open: true,
            right_panels: HashMap::new(),
            idle_right_panel: None,
            surface_focus: cx.focus_handle(),
            settled_open: false,
            snoozed_open: false,
            working_open: false,
            settled_shown: sidebar::SETTLED_PAGE,
            collapsed_projects: HashSet::new(),
            expanded_projects: HashSet::new(),
            project_scope: None,
            scope_picker: None,
            selection: Vec::new(),
            selection_anchor: None,
            search_highlight: 0,
            renaming: None,
            overlay_menu: None,
            palette: None,
            undo: Vec::new(),
            undo_generation: 0,
            undo_timer: Task::ready(()),
            ctrl_held: None,
            jump_hints: false,
            title_click: Task::ready(()),
            sidebar_width: SIDEBAR_WIDTH,
            resizing_sidebar: false,
            terminals: HashMap::new(),
            terminal_height: terminal_drawer::DEFAULT_HEIGHT,
            terminal_drag: None,
            editors: Rc::new(editors),
            editor,
            hostname: hostname().into(),
            settings_view,
            settings_open: false,
            activity: HashMap::new(),
            settle_armed: None,
            archive_armed: None,
            title_bounds: Rc::default(),
            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(),
            delegations: Default::default(),
            devices: Default::default(),
            focus_handle: cx.focus_handle(),
            observers: HashMap::new(),
            _subscriptions: subscriptions,
            _ticker: ticker,
            _remote_flush: remote_flush,
        };
        workspace.listen_for_tools(window, cx);
        workspace.listen_for_devices(window, cx);
        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(ix, 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();
        self.working_open = state.working_expanded;
        self.snoozed_open = state.snoozed_expanded;
        self.settled_open = state.settled_expanded;
        self.collapsed_projects = state.collapsed_projects.iter().cloned().collect();
        self.sidebar_width = state.sidebar_width.unwrap_or(SIDEBAR_WIDTH).max(SIDEBAR_MIN_WIDTH);
        self.terminal_height = state.terminal_height.unwrap_or(terminal_drawer::DEFAULT_HEIGHT);
        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.recover_delegations(cx);
        self.project_scope = state
            .project_scope
            .filter(|path| self.projects.iter().any(|project| project.environment.is_none() && &project.path == path))
            .map(|path| (path, None));
        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()),
            project_scope: self.project_scope.as_ref().filter(|(_, env)| env.is_none()).map(|(path, _)| path.clone()),
            working_expanded: self.working_open,
            snoozed_expanded: self.snoozed_open,
            settled_expanded: self.settled_open,
            collapsed_projects: {
                let mut paths: Vec<PathBuf> = self.collapsed_projects.iter().cloned().collect();
                paths.sort();
                paths
            },
            sidebar_width: Some(self.sidebar_width.round()).filter(|width| *width != SIDEBAR_WIDTH),
            terminal_height: Some(self.terminal_height.round()).filter(|height| *height != terminal_drawer::DEFAULT_HEIGHT),
        }
    }

    /// 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 allowed {
            crate::ui::chat::refresh_limits(false, cx);
        }
        if let Some((_, armed)) = self.settle_armed
            && armed.elapsed() >= SETTLE_ARMED
        {
            self.settle_armed = None;
            cx.notify();
        }
        if let Some((_, armed)) = self.archive_armed
            && armed.elapsed() >= SETTLE_ARMED
        {
            self.archive_armed = None;
            cx.notify();
        }
        // Working threads show how long they've worked.
        let working = self.projects.iter().flat_map(|project| &project.sessions).any(|session| session.read(cx).is_working());
        if working && (self.sidebar_open || self.surface_open(surfaces::Surface::Subagents)) {
            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());
        // The open thread's checkout, for the git actions.
        let status_path = self
            .active_project()
            .filter(|project| project.environment.is_none())
            .map(|project| worktree.clone().unwrap_or_else(|| project.path.clone()));
        let task = cx.background_executor().spawn(async move {
            let infos = paths.into_iter().map(|path| (git::read_info(&path), path)).collect::<Vec<_>>();
            let status = status_path.and_then(|path| git::status(&path).map(|status| (path, status)));
            (infos, status)
        });
        cx.spawn(async move |this, cx| {
            let (infos, status) = task.await;
            let _ = this.update(cx, |this, cx| {
                this.git_busy = false;
                let mut changed = this.git_status != status;
                this.git_status = status;
                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();
        // 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();
                let (settle_inactive, inactive_days) =
                    settings::get_for(&state.project, |settings| (settings.auto_settle_inactive, settings.inactive_days.max(1)));
                let inactive = Duration::from_secs(u64::from(inactive_days) * 86_400);
                (
                    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.auto_settle
                        && !state.settled
                        && !state.archived
                        && !state.pinned
                        && state.resume_at.is_none()
                        && now.duration_since(state.updated).unwrap_or_default() >= inactive,
                )
            };
            if wake {
                session.update(cx, |session, cx| session.wake(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);
        }
        if allowed && self.last_worktree_cleanup.is_none_or(|checked| checked.elapsed() >= PR_CHECK) {
            self.last_worktree_cleanup = Some(Instant::now());
            self.clean_up_worktrees(cx);
        }
    }

    /// Settings → Storage's worktree cleanup: removes the worktrees of
    /// threads inactive for a while, whose pull request merged, or with no
    /// commits beyond the default branch. Like T3 Code, only worktrees in the
    /// app's worktree folder, with nothing uncommitted, on their thread's
    /// branch, and with no agent at work there; the thread and its branch
    /// stay, and the worktree comes back with the thread's next message.
    fn clean_up_worktrees(&mut self, cx: &mut Context<Self>) {
        let rules = |project: &Path| {
            settings::get_for(project, |settings| {
                (settings.cleanup_inactive_worktrees, settings.cleanup_merged_worktrees, settings.cleanup_unchanged_worktrees)
            })
        };
        let projects: Vec<&Path> =
            self.projects.iter().filter(|project| project.environment.is_none()).map(|project| project.path.as_path()).collect();
        if projects.iter().all(|project| rules(project) == (None, false, false)) {
            return;
        }
        let Some(managed) = worktree::worktrees_dir() else { return };
        let now = SystemTime::now();
        let sessions: Vec<&Entity<Session>> =
            self.projects.iter().filter(|project| project.environment.is_none()).flat_map(|project| &project.sessions).collect();
        let busy: Vec<PathBuf> = sessions
            .iter()
            .map(|session| session.read(cx))
            .filter(|state| state.is_working() || state.preparing)
            .filter_map(|state| state.worktree.as_ref().map(|worktree| worktree.root.clone()))
            .collect();
        // Each worktree, whether it's due, merged, and removable unchanged.
        let mut candidates: Vec<(Worktree, bool, bool, bool)> = Vec::new();
        for session in sessions {
            let state = session.read(cx);
            let Some(worktree) = state.worktree.clone() else { continue };
            let (inactive_days, merged, unchanged) = rules(&state.project);
            if !worktree.root.starts_with(&managed) || !worktree.root.exists() || busy.contains(&worktree.root) {
                continue;
            }
            let inactive = inactive_days.is_some_and(|days| {
                now.duration_since(state.updated).unwrap_or_default() >= Duration::from_secs(u64::from(days) * 86_400)
            });
            let pr_merged = merged && state.pull_request.as_ref().is_some_and(|pr| pr.state == "MERGED");
            if inactive || pr_merged || unchanged {
                candidates.push((worktree, inactive || pr_merged, pr_merged, unchanged));
            }
        }
        if candidates.is_empty() {
            return;
        }
        cx.background_executor()
            .spawn(async move {
                for (worktree, due, merged, unchanged) in candidates {
                    let status = git::status(&worktree.root);
                    let safe = status
                        .as_ref()
                        .is_some_and(|status| !status.has_changes() && status.branch.as_deref() == Some(&worktree.branch));
                    if !safe {
                        continue;
                    }
                    let status = status.unwrap_or_default();
                    // A merged branch's commits must be in the default branch.
                    let merged_in = !merged || status.ahead_of_default == 0;
                    if (due && merged_in) || (unchanged && status.ahead_of_default == 0) {
                        let _ = worktree::remove(&worktree);
                    }
                }
            })
            .detach();
    }

    /// 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;
            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 merged_settles = settings::get_for(&session.project, |settings| settings.auto_settle_merged);
                    let was_open = session.pull_request.as_ref().is_none_or(|before| before.is_open());
                    let settle = was_open
                        && session.auto_settle
                        && !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 in this project starts with: the open thread's
    /// picks, overridden by the defaults its environment and project chose.
    fn new_thread_settings(&self, project_ix: usize, cx: &App) -> ThreadSettings {
        let defaults = self.project_settings(project_ix);
        let (model, reasoning, access) = (defaults.default_model, defaults.default_reasoning, defaults.default_access);
        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_for(&project.path, |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(project_ix, 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 mode = self.project_settings(project_ix).workspace;
        let project = self.projects.get(project_ix)?;
        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(&session, window, Self::on_session_event),
                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);
        self.track_delegation(&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 is_open = self.active.as_ref() == Some(&session) && !self.settings_open && window.is_window_active();
        if before.working && !now.working && !is_open && !state.unread {
            session.update(cx, |session, cx| session.set_unread(true, cx));
        }
        let state = session.read(cx);
        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);
        } else if sound {
            // "Sound only".
            play_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, create } => {
                let cwd = session.read(cx).cwd.clone();
                self.switch_branch(cwd, branch.clone(), *create, window, cx);
            }
            ChatEvent::OpenProviderSettings => self.open_settings(window, cx),
            ChatEvent::ImplementInNewThread(plan) => {
                let settings = self.new_thread_settings(project_ix, cx);
                if let Some(chat) = self.new_thread_with(project_ix, settings, window, cx) {
                    let prompt = format!("Implement this plan:\n\n{plan}");
                    chat.read(cx).session().clone().update(cx, |session, cx| session.send_message(prompt, Vec::new(), 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);
        let repo = git.as_ref().and_then(|_| git::repository_of(&path));
        self.projects.push(Project {
            repo,
            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::Manual => {}
            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, cx| {
            session.subscribe_remote();
            session.mark_seen(cx);
        });
        if let Some(chat) = self.chats.get(&session.entity_id()).cloned() {
            chat.update(cx, |chat, cx| chat.focus_composer(window, cx));
        }
        // Opening a thread drops the multi-select, as in T3 Code.
        self.selection.clear();
        let previous = self.active.replace(session.clone());
        if let Some(previous) = previous.filter(|previous| previous != &session) {
            self.prune_empty_draft(&previous, cx);
        }
        if self.renaming.as_ref().is_some_and(|rename| !rename.sidebar) {
            self.renaming = None;
        }
        self.last_git = Instant::now() - GIT_POLL;
        cx.notify();
    }

    /// Forgets a new thread left without anything sent or typed, as T3 drops
    /// an empty draft once you move on.
    fn prune_empty_draft(&mut self, session: &Entity<Session>, cx: &mut Context<Self>) {
        if !session.read(cx).is_blank() || session.read(cx).is_remote() {
            return;
        }
        let empty = self
            .chats
            .get(&session.entity_id())
            .is_none_or(|chat| {
                let (text, attachments) = chat.read(cx).draft(cx);
                text.trim().is_empty() && attachments == 0
            });
        if empty && let Some(ix) = self.project_of(session) {
            self.remove_thread(ix, session, cx);
        }
    }

    /// The key naming a project.
    fn project_key(&self, ix: usize) -> ProjectKey {
        let project = &self.projects[ix];
        (project.path.clone(), project.environment.clone())
    }

    fn project_by_key(&self, key: &ProjectKey) -> Option<usize> {
        self.projects.iter().position(|project| project.path == key.0 && project.environment == key.1)
    }

    /// Takes a thread out of its project without stopping it, so the undo
    /// notice can put it back.
    fn detach_thread(&mut self, project_ix: usize, session: &Entity<Session>, cx: &mut Context<Self>) {
        let id = session.entity_id();
        self.chats.remove(&id);
        self.observers.remove(&id);
        self.activity.remove(&id);
        if let Some(project) = self.projects.get_mut(project_ix) {
            project.sessions.retain(|candidate| candidate.entity_id() != id);
        }
        cx.notify();
    }

    /// Puts a detached thread back where it was.
    fn reattach_thread(
        &mut self,
        key: &ProjectKey,
        session: Entity<Session>,
        chat: Entity<ChatView>,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) {
        let Some(project_ix) = self.project_by_key(key) else {
            session.update(cx, |session, _| session.shutdown());
            return;
        };
        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);
        self.projects[project_ix].sessions.push(session);
        cx.notify();
    }

    /// Opens a menu at a point in the window, as a right click would.
    fn open_menu(
        &mut self,
        position: Point<Pixels>,
        window: &mut Window,
        cx: &mut Context<Self>,
        build: impl FnOnce(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
    ) {
        // The menus read the workspace as they build, so build once this
        // update is over.
        let workspace = cx.weak_entity();
        window.defer(cx, move |window, cx| {
            let menu = PopupMenu::build(window, cx, build);
            menu.focus_handle(cx).focus(window, cx);
            let _ = workspace.update(cx, |this, cx| {
                let dismiss = cx.subscribe_in(&menu, window, |this, _, _: &DismissEvent, window, cx| {
                    this.overlay_menu = None;
                    // Hand focus back, so the shortcuts keep working.
                    match this.active.as_ref().and_then(|session| this.chats.get(&session.entity_id())) {
                        Some(chat) if this.renaming.is_none() => chat.update(cx, |chat, cx| chat.focus_composer(window, cx)),
                        _ if this.renaming.is_none() => this.focus_handle.focus(window, cx),
                        _ => {}
                    }
                    cx.notify();
                });
                this.overlay_menu = Some(OverlayMenu { menu, position, _dismiss: dismiss });
                cx.notify();
            });
        });
    }

    /// Edits a thread's title in place: in its sidebar row, or in the chat
    /// header. Enter or leaving the field saves, Esc cancels.
    fn start_rename(&mut self, session: Entity<Session>, sidebar: bool, window: &mut Window, cx: &mut Context<Self>) {
        // A row that isn't showing can't host the field; the header can.
        let sidebar = sidebar && self.sidebar_open && !self.settings_open;
        if !sidebar && self.active.as_ref() != Some(&session) {
            if let Some(ix) = self.project_of(&session) {
                self.select(ix, session.clone(), window, cx);
            }
        }
        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);
            input.select_all(window, cx);
        });
        let subscription = cx.subscribe_in(&input, window, |this, _, event: &InputEvent, window, cx| match event {
            InputEvent::PressEnter { .. } | InputEvent::Blur => this.commit_rename(window, cx),
            _ => {}
        });
        self.renaming = Some(InlineRename { session, input, sidebar, _subscription: subscription });
        cx.notify();
    }

    fn commit_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let Some(rename) = self.renaming.take() else { return };
        let title = rename.input.read(cx).value().trim().to_string();
        if title.is_empty() {
            window.push_notification(Notification::warning("Thread title cannot be empty"), cx);
        } else if title != rename.session.read(cx).title.as_ref() {
            rename.session.update(cx, |session, cx| session.rename(title, cx));
        }
        cx.notify();
    }

    fn cancel_rename(&mut self, cx: &mut Context<Self>) {
        if self.renaming.take().is_some() {
            cx.notify();
        }
    }

    /// Opens the previous or next thread in the sidebar (Ctrl+Shift+[ and ]).
    fn step_thread(&mut self, delta: isize, window: &mut Window, cx: &mut Context<Self>) {
        let rows = self.visible_rows(cx);
        if rows.is_empty() {
            return;
        }
        let current = self.active.as_ref().and_then(|active| rows.iter().position(|(_, row)| row == active));
        let next = match current {
            Some(ix) => (ix as isize + delta).rem_euclid(rows.len() as isize) as usize,
            None if delta < 0 => rows.len() - 1,
            None => 0,
        };
        let (project, session) = rows[next].clone();
        self.select(project, session, window, cx);
    }

    /// Ctrl+1–9 open the sidebar's first nine threads.
    fn jump_with_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
        let keystroke = &event.keystroke;
        let modifiers = &keystroke.modifiers;
        if !modifiers.control || modifiers.shift || modifiers.alt || modifiers.platform || self.palette.is_some() {
            return;
        }
        let Some(digit) = keystroke.key.parse::<usize>().ok().filter(|digit| (1..=9).contains(digit)) else { return };
        if let Some((project, session)) = self.visible_rows(cx).get(digit - 1).cloned() {
            cx.stop_propagation();
            self.jump_hints = false;
            self.select(project, session, window, cx);
        }
    }

    /// Shows the jump hints once Ctrl has been held for a beat.
    fn modifiers_changed(&mut self, event: &ModifiersChangedEvent, window: &mut Window, cx: &mut Context<Self>) {
        let held = event.modifiers.control && !event.modifiers.shift && !event.modifiers.alt;
        if !held {
            self.ctrl_held = None;
            if self.jump_hints {
                self.jump_hints = false;
                cx.notify();
            }
            return;
        }
        if self.ctrl_held.is_some() {
            return;
        }
        let since = Instant::now();
        self.ctrl_held = Some(since);
        cx.spawn_in(window, async move |this, cx| {
            cx.background_executor().timer(JUMP_HINT_DELAY).await;
            let _ = this.update(cx, |this, cx| {
                if this.ctrl_held == Some(since) {
                    this.jump_hints = true;
                    cx.notify();
                }
            });
        })
        .detach();
    }

    /// Opens a settings page by its name in the settings view (`Usage`).
    fn open_settings_page(&mut self, page: &str, window: &mut Window, cx: &mut Context<Self>) {
        self.open_settings(window, cx);
        self.settings_view.update(cx, |view, cx| view.open_page_named(page, cx));
    }

    /// The settle button on a thread card: settles at once, or arms itself
    /// for a confirming second click when Settle confirmation is on.
    fn press_settle(&mut self, session: Entity<Session>, window: &mut Window, 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_threads(vec![session], window, cx);
        }
    }

    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())
        };
        // Like T3 Code, a worktree goes too only when no other thread uses it,
        // and only once the user says so.
        let entity_id = session.entity_id();
        let worktree = worktree.filter(|worktree| {
            worktree.root.exists()
                && !self.projects.iter().flat_map(|project| &project.sessions).any(|other| {
                    other.entity_id() != entity_id
                        && other.read(cx).worktree.as_ref().is_some_and(|other| other.root == worktree.root)
                })
        });
        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);
                    browser::stop(&browser::session_name(&id));
                })
                .detach();
            if let Some(worktree) = worktree.clone().filter(|_| settings::get(|settings| settings.cleanup_deleted_worktrees)) {
                // Settings → Storage says to; git keeps one with local changes.
                cx.background_executor()
                    .spawn(async move {
                        let _ = worktree::remove(&worktree);
                    })
                    .detach();
            } else if let Some(worktree) = worktree {
                let name = worktree.root.file_name().map(|name| name.to_string_lossy().to_string()).unwrap_or_default();
                let message =
                    format!("This thread is the only one linked to this worktree:\n{name}\n\nDelete the worktree too?");
                // After the dialog that asked to delete the thread has closed.
                window.defer(cx, move |window, cx| actions::confirm(&message, true, window, cx, move |window, cx| {
                    let worktree = worktree.clone();
                    let name = name.clone();
                    let task = cx.background_executor().spawn(async move { worktree::remove_force(&worktree) });
                    window
                        .spawn(cx, async move |cx| {
                            if let Err(err) = task.await {
                                let _ = cx.update(|window, cx| {
                                    window.push_notification(
                                        Notification::error(format!("Could not remove {name}. {err}"))
                                            .title("Failed to delete worktree"),
                                        cx,
                                    );
                                });
                            }
                        })
                        .detach();
                }));
            }
        }
        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.browsers.remove(&id);
        self.right_panels.remove(&id);
        let thread_id = session.read(cx).id.clone();
        self.forget_thread_devices(&thread_id, cx);
        self.drop_terminals(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 Add project in the palette: the devices to add it on, or this
    /// device's sources when there are no others.
    fn prompt_for_project(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        self.close_palette(window, cx);
        let view = self.add_project_view();
        self.open_palette(view, window, cx);
    }

    /// Adds the project the palette picked, on this device or another, and
    /// starts a thread in it. Clones run in the background.
    fn add_project_from(&mut self, env: Option<String>, source: ProjectSource, window: &mut Window, cx: &mut Context<Self>) {
        let label = source.destination(Path::new("")).map(|name| name.display().to_string());
        if let Some(env) = env {
            let Some(environment) = self.remote.environments.iter().find(|candidate| candidate.host.id == env) else { return };
            if source.is_clone() {
                let name = environment.host.label();
                let what = label.unwrap_or_else(|| "the repository".into());
                window.push_notification(Notification::info(format!("Cloning {what} on {name}…")), cx);
            }
            environment.client.send(&ClientMessage::AddProject { source });
            return;
        }
        let saved = settings::get(|settings| settings.add_project_dir.clone());
        let base = folder_browser::start_dir(Some(&saved));
        let home = folder_browser::home_dir();
        if !source.is_clone() {
            match projects::resolve(&source, &base, &home) {
                Ok(path) => self.add_project(path, window, cx),
                Err(message) => window.push_notification(Notification::error(message).title("Couldn't add the project"), cx),
            }
            return;
        }
        let what = label.unwrap_or_else(|| "the repository".into());
        window.push_notification(Notification::info(format!("Cloning {what}…")), cx);
        let clone = cx.background_executor().spawn(async move { projects::resolve(&source, &base, &home) });
        cx.spawn_in(window, async move |this, cx| {
            let result = clone.await;
            let _ = this.update_in(cx, |this, window, cx| match result {
                Ok(path) => this.add_project(path, window, cx),
                Err(message) => {
                    window.push_notification(Notification::error(message).title(format!("Couldn't clone {what}")), 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(|(_, session)| !session.read(cx).is_subagent())
            .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();
    }

    /// Checks out a branch in a thread's folder (creating it first when
    /// `create`), then reads the project's git state again.
    fn switch_branch(&mut self, cwd: PathBuf, branch: String, create: bool, window: &mut Window, cx: &mut Context<Self>) {
        let project = self.active_project().map(|project| project.path.clone());
        let task = cx.background_executor().spawn(async move {
            let output = if create {
                git::git(&cwd, &["switch", "-c", &branch])
            } else {
                // `origin/x` checks out a local `x` tracking it.
                let local = branch.strip_prefix("origin/").unwrap_or(&branch);
                git::git(&cwd, &["switch", local])
            };
            let info = project.as_deref().and_then(git::read_info);
            (project, 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 {
                    let message = if create { "Failed to create and switch ref." } else { "Failed to switch ref." };
                    window.push_notification(Notification::error(format!("{message} {err}")), cx);
                }
                if let Some(project) = this.projects.iter_mut().find(|project| Some(&project.path) == path.as_ref()) {
                    project.git = info;
                }
                cx.notify();
            });
        })
        .detach();
    }

    // ---- 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);
        self.broadcast_settings();
        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 archived thread, most recently active first, for Settings →
    /// Archive. (Settled threads stay in the sidebar's Settled shelf.)
    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).archived && !session.read(cx).is_subagent())
            .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.set_archived(false, 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);
        }
    }

    fn toggle_project_panel(&mut self, cx: &mut Context<Self>) {
        // With a surface in its place, the project panel comes back.
        if self.right_panel().is_some() {
            self.set_right_panel(None);
            self.panel_open = true;
        } else {
            self.panel_open = !self.panel_open;
        }
        cx.notify();
    }

    /// The open thread's browser panel while it shows, made on first use.
    /// The others let go of their streams.
    /// Shows `url` in the open thread's browser panel, or in the system
    /// browser when agent-browser isn't installed to drive one.
    pub(crate) fn preview(&mut self, url: String, window: &mut Window, cx: &mut Context<Self>) {
        if browser::find_binary().is_none() || self.active.is_none() {
            open_url(&url);
            return;
        }
        self.open_surface(surfaces::Surface::Browser, window, cx);
        if let Some(panel) = self.shown_browser(window, cx) {
            panel.update(cx, |panel, cx| panel.navigate(url, window, cx));
        }
    }

    fn shown_browser(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<Entity<BrowserPanel>> {
        let session = self.active.clone().filter(|_| self.surface_open(surfaces::Surface::Browser) && !self.settings_open);
        let panel = session.map(|session| {
            let id = session.entity_id();
            if let Some(panel) = self.browsers.get(&id) {
                return panel.clone();
            }
            let thread_id = session.read(cx).id.clone();
            let panel = cx.new(|cx| BrowserPanel::new(&thread_id, window, cx));
            let subscription = cx.subscribe(&panel, move |this, _, event: &BrowserPanelEvent, cx| match event {
                BrowserPanelEvent::Close => {
                    if this.right_panels.get(&id) == Some(&surfaces::Surface::Browser) {
                        this.right_panels.remove(&id);
                    }
                    cx.notify();
                }
            });
            self.observers.entry(id).or_default().push(subscription);
            self.browsers.insert(id, panel.clone());
            panel
        });
        let shown = panel.as_ref().map(|panel| panel.entity_id());
        if shown != self.browser_shown {
            self.browser_shown = shown;
            for browser in self.browsers.values() {
                let visible = Some(browser.entity_id()) == shown;
                browser.update(cx, |browser, cx| browser.set_visible(visible, cx));
            }
        }
        panel
    }

    /// 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 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| {
                                            let Some(cwd) = this.active_path(cx) else { return };
                                            this.switch_branch(cwd, branch, false, window, cx)
                                        });
                                    },
                                ));
                            }
                            menu
                        })
                        .into_any_element()
                };
                v_flex()
                    .gap(px(2.))
                    .child(branch_row)
                    .when(has_thread, |this| this.child(self.render_git_actions(cx)))
                    .child(
                        Self::panel_row(Lucide::FileDiff, "Changes", p)
                            .child(
                                div()
                                    .font_family(metal::MONO)
                                    .text_size(px(11.5))
                                    .text_color(metal::diff_ink(p).added)
                                    .child(format!("+{}", git.added)),
                            )
                            .child(
                                div()
                                    .font_family(metal::MONO)
                                    .text_size(px(11.5))
                                    .text_color(metal::diff_ink(p).removed)
                                    .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_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()),
                    previous_worktree: project
                        .sessions
                        .iter()
                        .map(|session| session.read(cx))
                        .filter(|session| !session.items.is_empty())
                        .filter_map(|session| Some((session.updated, session.worktree.clone()?)))
                        .filter(|(_, worktree)| worktree.root.exists())
                        .max_by_key(|(updated, _)| *updated)
                        .map(|(_, worktree)| worktree),
                })
                .collect(),
        };
        cx.set_global(directory);
        let browser = self.shown_browser(window, cx);
        let device_panel = self.shown_device_panel(window, cx);
        let floating_device = self.render_floating_device(cx);
        let p = metal::palette(cx);
        let subagents_visible = self.surface_open(surfaces::Surface::Subagents);
        let picker_visible = self.surface_open(surfaces::Surface::Picker) && !self.settings_open;
        let docked_terminal =
            if self.surface_open(surfaces::Surface::Terminal) { self.render_terminal_drawer(true, window, cx) } else { None };
        let panel_visible = self.panel_open
            && self.right_panel().is_none()
            && self.active_project().is_some()
            && window.viewport_size().width >= px(PANEL_MIN_WINDOW);
        // The browser gives way before the conversation gets too narrow.
        let chat_min = if browser.is_some() || docked_terminal.is_some() || device_panel.is_some() { px(360.) } else { px(0.) };

        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| {
                // T3's chat.new: the project picker once there's a choice.
                if this.legacy_sidebar() {
                    this.new_thread_button(true, window, cx);
                } else {
                    this.new_thread_button(false, window, cx);
                }
            }))
            .on_action(cx.listener(|this, _: &NewThreadLocal, window, cx| this.new_thread_button(true, window, cx)))
            .on_action(cx.listener(|this, _: &NewThreadWithoutProject, window, cx| {
                this.new_thread_without_project(window, cx)
            }))
            .on_action(cx.listener(|this, _: &ToggleCommandPalette, window, cx| this.toggle_palette(window, cx)))
            .on_action(cx.listener(|this, _: &PreviousThread, window, cx| this.step_thread(-1, window, cx)))
            .on_action(cx.listener(|this, _: &NextThread, window, cx| this.step_thread(1, window, cx)))
            .on_action(cx.listener(|this, _: &SettleThread, window, cx| {
                if let Some(session) = this.active.clone().filter(|session| !session.read(cx).is_blank()) {
                    this.toggle_settled(session, window, cx);
                }
            }))
            .on_action(cx.listener(|this, _: &PinThread, window, cx| {
                if let Some(session) = this.active.clone().filter(|session| !session.read(cx).is_blank()) {
                    this.toggle_pin(session, window, cx);
                }
            }))
            .on_action(cx.listener(|this, _: &UndoThreadAction, window, cx| {
                if !this.undo_latest(window, cx) {
                    cx.propagate();
                }
            }))
            .on_action(cx.listener(|this, _: &CopyThreadReference, window, cx| {
                if let Some(session) = this.active.clone().filter(|session| !session.read(cx).is_blank()) {
                    Self::copy_thread_reference(&session, window, cx);
                }
            }))
            .on_action(cx.listener(|this, _: &OpenUsage, window, cx| this.open_settings_page("Usage", window, cx)))
            .on_action(cx.listener(|this, _: &ToggleRightPanel, window, cx| this.toggle_right_panel(window, cx)))
            .on_action(cx.listener(|this, _: &SelectTheme, window, cx| {
                this.open_palette(palette::PaletteView::Themes, window, cx)
            }))
            .on_action(cx.listener(|this, _: &CycleAppearance, window, cx| this.cycle_appearance(window, cx)))
            .on_action(cx.listener(|this, _: &ToggleTerminal, window, cx| this.toggle_terminal(window, cx)))
            .on_action(cx.listener(|this, _: &SplitTerminal, window, cx| this.split_terminal(false, window, cx)))
            .on_action(cx.listener(|this, _: &SplitTerminalVertical, window, cx| this.split_terminal(true, window, cx)))
            .on_action(cx.listener(|this, _: &NewTerminal, window, cx| this.new_terminal(window, cx)))
            .on_action(cx.listener(|this, _: &CloseTerminal, window, cx| this.request_close_terminal(None, window, cx)))
            .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| this.jump_with_key(event, window, cx)))
            // Dragging the sidebar's edge.
            .when(self.resizing_sidebar, |this| {
                this.cursor_col_resize()
                    .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, window, cx| {
                        let max = (window.viewport_size().width.as_f32() - MAIN_MIN_WIDTH).max(SIDEBAR_MIN_WIDTH);
                        this.sidebar_width = event.position.x.as_f32().clamp(SIDEBAR_MIN_WIDTH, max);
                        cx.notify();
                    }))
                    .on_mouse_up(
                        MouseButton::Left,
                        cx.listener(|this, _, _, cx| {
                            this.resizing_sidebar = false;
                            cx.notify();
                        }),
                    )
            })
            // Moving or resizing the floating device. Always listening, so
            // moves that come before the next frame aren't lost.
            .on_mouse_move(cx.listener(Self::drag_float))
            .on_mouse_up(MouseButton::Left, cx.listener(|this, _, _, cx| this.end_float_drag(cx)))
            .when(self.float_dragging(), |this| this.cursor_grabbing())
            // Dragging the terminal drawer's top edge.
            .when(self.terminal_drag.is_some(), |this| {
                this.cursor_row_resize()
                    .on_mouse_move(cx.listener(Self::drag_terminal_edge))
                    .on_mouse_up(MouseButton::Left, cx.listener(|this, _, _, cx| this.end_terminal_drag(cx)))
            })
            .on_modifiers_changed(cx.listener(|this, event: &ModifiersChangedEvent, window, cx| {
                this.modifiers_changed(event, window, cx)
            }))
            .on_action(cx.listener(|this, _: &OpenInEditor, window, cx| this.open_in_editor(this.editor, 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.toggle_project_panel(cx)))
            .on_action(cx.listener(|this, _: &ToggleSubagents, window, cx| {
                this.toggle_surface(surfaces::Surface::Subagents, window, cx)
            }))
            .on_action(cx.listener(|this, _: &ToggleBrowser, window, cx| {
                this.toggle_surface(surfaces::Surface::Browser, window, cx)
            }))
            .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(window, 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(
                                        v_flex()
                                            .relative()
                                            .flex_1()
                                            .min_w(chat_min)
                                            .h_full()
                                            .child({
                                                let workspace = cx.entity().downgrade();
                                                canvas(
                                                    move |bounds, _, cx| {
                                                        let _ = workspace.update(cx, |this, _| this.track_chat_area(bounds));
                                                    },
                                                    |_, _, _, _| {},
                                                )
                                                .absolute()
                                                .size_full()
                                            })
                                            .child(div().flex_1().min_h_0().w_full().overflow_hidden().map(|this| match chat {
                                                Some(chat) => this.child(chat),
                                                None => this.child(self.render_empty_sheet(cx)),
                                            }))
                                            .children(self.render_terminal_drawer(false, window, cx))
                                            .children(floating_device),
                                    )
                                    .when(panel_visible, |this| this.child(self.render_panel(cx)))
                                    .when(subagents_visible, |this| this.child(self.render_subagents_panel(cx)))
                                    .when(picker_visible, |this| this.child(self.render_surface_picker(cx)))
                                    .children(docked_terminal)
                                    .when_some(browser, |this, browser| this.child(browser))
                                    .when_some(device_panel, |this, panel| this.child(panel)),
                            ),
                    ),
            ))
            // The command palette floats over everything; a click beside it closes it.
            .when_some(self.palette.as_ref().map(|(palette, _)| palette.clone()), |this, palette| {
                this.child(
                    div()
                        .absolute()
                        .inset_0()
                        .flex()
                        .flex_col()
                        .items_center()
                        .pt(window.viewport_size().height * 0.12)
                        .px(px(16.))
                        .bg(black().opacity(if p.dark { 0.4 } else { 0.16 }))
                        .occlude()
                        .on_mouse_down(
                            MouseButton::Left,
                            cx.listener(|this, _, window, cx| this.close_palette(window, cx)),
                        )
                        .child(div().on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()).child(palette)),
                )
            })
            .when_some(self.overlay_menu.as_ref(), |this, overlay| {
                this.child(
                    deferred(
                        anchored()
                            .position(overlay.position)
                            .snap_to_window_with_margin(px(8.))
                            .child(overlay.menu.clone()),
                    )
                    .with_priority(2),
                )
            })
    }
}

/// 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`).
pub(crate) 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()
}

pub(crate) 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");
    }
}