From 6237c03ed7e8a035363732bf21cf7802ccecbc9b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:07:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20the=20board=20=E2=80=94=20the=20mai?= =?UTF-8?q?n=20area's=20second=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Board row under the sidebar search (with a count of agents waiting on you) swaps the terminal area for five columns of cards: this workspace's tasks, plus every tab running an agent no task claims. Columns come from `task::column` / `task::live_column` on every frame. - New task (N): title, prompt, agent; Add to queue or Start. Start opens a tab named for the task and launches the agent with the prompt as its first message, staying on the board. - Cards: agent avatar, title, repo · branch, what a running agent is doing, the question a waiting one asks with an Open button, diff counts, age. Click opens the tab; right-click has start/resume/done/reopen/edit/delete, and "keep as task" for an agent started by hand. - Runs record their agent's session id, so a closed run can be resumed. - Toggle Board: secondary-alt-b, and in the command palette. --- assets/icons/board.svg | 1 + src/core/actions.rs | 1 + src/ui/agent_launch.rs | 2 +- src/ui/app.rs | 23 + src/ui/assets.rs | 1 + src/ui/board.rs | 1396 ++++++++++++++++++++++++++++++++++++++ src/ui/i18n/en.rs | 43 ++ src/ui/i18n/ja.rs | 43 ++ src/ui/i18n/mod.rs | 35 + src/ui/i18n/zh.rs | 41 ++ src/ui/keymap.rs | 3 + src/ui/machine_mirror.rs | 11 + src/ui/mod.rs | 1 + src/ui/search/command.rs | 4 + src/ui/tab_sidebar.rs | 5 +- src/ui/tree_sync.rs | 123 ++++ 16 files changed, 1730 insertions(+), 3 deletions(-) create mode 100644 assets/icons/board.svg create mode 100644 src/ui/board.rs diff --git a/assets/icons/board.svg b/assets/icons/board.svg new file mode 100644 index 00000000..aadbddbc --- /dev/null +++ b/assets/icons/board.svg @@ -0,0 +1 @@ + diff --git a/src/core/actions.rs b/src/core/actions.rs index 417f2d00..bb1823c7 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -119,6 +119,7 @@ actions!( ShowSshForwards, ToggleCodePanel, ToggleDocumentFill, + ToggleBoard, DocumentWidthThird, DocumentWidthHalf, DocumentWidthTwoThirds, diff --git a/src/ui/agent_launch.rs b/src/ui/agent_launch.rs index cb574cd6..e229f395 100644 --- a/src/ui/agent_launch.rs +++ b/src/ui/agent_launch.rs @@ -149,7 +149,7 @@ pub(crate) fn fork_line( /// Type `command` into the shell `slot` holds — now if it is up, or the /// moment it lands if it is still connecting. -fn run_when_ready(slot: &PaneSlot, command: String, cx: &mut App) { +pub(crate) fn run_when_ready(slot: &PaneSlot, command: String, cx: &mut App) { match slot { PaneSlot::Ready(view) => view.read(cx).run_command_line(&command), PaneSlot::Connecting(pending) => { diff --git a/src/ui/app.rs b/src/ui/app.rs index d634ad20..069dc1a8 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -878,6 +878,9 @@ pub struct Tty7App { pub(crate) closed: Vec, pub(crate) renaming: Option, pub(crate) worktree_prompt: Option, + /// What the main area shows: the terminal, or the board over it. + pub(crate) main_view: crate::ui::board::MainView, + pub(crate) board: crate::ui::board::Board, pub(crate) maximized: Option>, pub(crate) mod_hint_badges: bool, pub(crate) mod_hint_gen: u64, @@ -1516,6 +1519,8 @@ impl Tty7App { closed: Vec::new(), renaming: None, worktree_prompt: None, + main_view: crate::ui::board::MainView::Terminal, + board: crate::ui::board::Board::new(cx), maximized: None, mod_hint_badges: false, mod_hint_gen: 0, @@ -3765,6 +3770,10 @@ impl Tty7App { window.focus(&settings.focus_handle, cx); return; } + if self.board_open() { + window.focus(&self.board.focus, cx); + return; + } let Some(tab) = self.tabs.get(self.active) else { window.focus(&self.home_focus, cx); return; @@ -4056,6 +4065,9 @@ impl Tty7App { }; // Something opened, so whatever the last failure was is stale. self.startup_error = None; + // A new tab is one to look at. The board's own Start puts the board + // back once the tab is open. + self.main_view = crate::ui::board::MainView::Terminal; self.remember_active_pane(window, cx); self.maximized = None; let insert_at = self.new_tab_insert_at(cx); @@ -4908,6 +4920,12 @@ impl Tty7App { } pub(crate) fn activate(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + // Picking a tab is asking to see it, board or not. + if self.board_open() && index < self.tabs.len() { + self.main_view = crate::ui::board::MainView::Terminal; + self.focus_active(window, cx); + cx.notify(); + } // A tab is woken by being looked at, the way a browser reloads a // discarded tab when it is selected: a tab on screen is always a // running one, so there is no third state to draw. A wake that fails @@ -6365,6 +6383,7 @@ impl Tty7App { ShowSshForwards => self.show_ssh_forwards(window, cx), ToggleCodePanel => self.toggle_code_panel(window, cx), ToggleDocumentFill => self.toggle_document_fill(cx), + ToggleBoard => self.toggle_board(window, cx), DocumentWidthThird => { self.set_document_ratio(crate::core::config::DOCUMENT_RATIO_THIRD, cx) } @@ -8947,6 +8966,7 @@ impl Render for Tty7App { .when_some(self.render_remote_workspace_strip(cx), |this, el| { this.child(el) }) + .when_some(self.render_board(window, cx), |this, el| this.child(el)) // Both of these used to anchor themselves at `bottom_4` and centre // themselves, as siblings here — so a remote workspace whose ssh // link had also dropped drew them one on top of the other. One @@ -9379,6 +9399,9 @@ impl Render for Tty7App { .on_action(cx.listener(|this, _: &ToggleDiffViewMode, _window, cx| { this.toggle_diff_view_mode(cx) })) + .on_action( + cx.listener(|this, _: &ToggleBoard, window, cx| this.toggle_board(window, cx)), + ) .on_action(cx.listener(|this, _: &ToggleDocumentFill, _window, cx| { this.toggle_document_fill(cx) })) diff --git a/src/ui/assets.rs b/src/ui/assets.rs index ac9c095e..44e3e85e 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -33,6 +33,7 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { "icons/git-sync.svg" => include_bytes!("../../assets/icons/git-sync.svg"), "icons/git-commit.svg" => include_bytes!("../../assets/icons/git-commit.svg"), "icons/panel-left.svg" => include_bytes!("../../assets/icons/panel-left.svg"), + "icons/board.svg" => include_bytes!("../../assets/icons/board.svg"), "icons/panel-right.svg" => include_bytes!("../../assets/icons/panel-right.svg"), "icons/plus.svg" => include_bytes!("../../assets/icons/plus.svg"), "icons/ellipsis.svg" => include_bytes!("../../assets/icons/ellipsis.svg"), diff --git a/src/ui/board.rs b/src/ui/board.rs new file mode 100644 index 00000000..d2f096cf --- /dev/null +++ b/src/ui/board.rs @@ -0,0 +1,1396 @@ +//! The board: the window's agents and the tasks handed to them, in five +//! columns by where each one stands (#1010). +//! +//! It is the main area's second view — the sidebar stays, the terminal steps +//! aside — and it only ever shows this window's workspace, the same scope as +//! the sidebar beside it. +//! +//! Every card is one of two things. A *task* is kept on the machine tree +//! ([`tty7_core::core::task`]): something the user wrote down, maybe started, +//! maybe finished. Any other tab running an agent is a *loose* card, drawn +//! from the tab itself and kept nowhere, so an agent started by hand shows up +//! without anyone filing it. Where either sits is worked out from the agent +//! on every frame; nothing here stores a column. + +use std::path::PathBuf; + +use gpui::{ + AnyElement, App, Context, Entity, FocusHandle, Hsla, SharedString, Subscription, Window, div, + prelude::*, px, rems, +}; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::input::{Input, InputEvent, InputState}; +use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem}; +use gpui_component::{ActiveTheme as _, Icon, Sizable as _, WindowExt as _, h_flex, v_flex}; +use tty7_core::core::machine::TabId; +use tty7_core::core::task::{self, Column, Done, Live, Run, Task, TaskId}; + +use crate::core::cli_agent::{AgentStatus, CLIAgent}; +use crate::core::config::{Config, unix_now}; +use crate::ui::app::Tty7App; +use crate::ui::dialog::{self, Tone}; +use crate::ui::i18n::{L10nKey, t, t_fmt}; +use crate::ui::right_panel::{HEADING, META, META_MONO, TAB_TEXT, TEXT}; + +/// What the main area is showing. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum MainView { + #[default] + Terminal, + Board, +} + +/// The board's own state on the window. +pub(crate) struct Board { + /// This workspace's tasks, as the machine last said — and as this window + /// has since changed them, since its own edits never come back to it. + pub tasks: Vec, + /// Only this agent's cards, or everyone's. + pub agent_filter: Option, + pub composer: Option, + pub focus: FocusHandle, +} + +impl Board { + pub(crate) fn new(cx: &mut App) -> Board { + Board { + tasks: Vec::new(), + agent_filter: None, + composer: None, + focus: cx.focus_handle(), + } + } +} + +/// The New task card, or the same card open on a task being edited. +pub(crate) struct Composer { + editing: Option, + title: Entity, + prompt: Entity, + agent: Option, + cwd: Option, + _subs: Vec, +} + +/// Board geometry, from the v7 board comp. +const HEADER_H: f32 = 48.; +const PAD_X: f32 = 20.; +const COLUMN_GAP: f32 = 12.; +const COLUMN_HEAD_H: f32 = 28.; +const CARD_GAP: f32 = 8.; +const CARD_RADIUS: f32 = 9.; +const AVATAR: f32 = 16.; +const COMPOSER_W: f32 = 560.; +const PROMPT_H: f32 = 132.; + +/// Which card a click or a menu is about. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CardRef { + Task(TaskId), + /// A tab running an agent that no task claims. + Loose(TabId), +} + +/// One card, read off a task or a tab for this frame. +struct Card { + key: CardRef, + column: Column, + agent: Option, + status: Option, + title: SharedString, + /// `repo · branch`, or just the repo. + place: Option, + /// What the agent says it is doing, from its terminal title. + doing: Option, + /// What the agent is stopped on. + ask: Option, + diff: Option<(u32, u32)>, + /// When this card's current state began, in Unix seconds, if known. + since: Option, + /// The open tab a run of this card lives in. + tab: Option, + /// A finished run this card could pick back up. + resumable: bool, +} + +/// What the board reads off one open tab. +struct TabFacts { + id: TabId, + agent: CLIAgent, + live: Live, + ask: Option, + doing: Option, + cwd: Option, + branch: Option, + diff: Option<(u32, u32)>, + label: Option, +} + +impl Tty7App { + pub(crate) fn board_open(&self) -> bool { + self.main_view == MainView::Board + } + + pub(crate) fn toggle_board(&mut self, window: &mut Window, cx: &mut Context) { + match self.main_view { + MainView::Board => self.close_board(window, cx), + MainView::Terminal => self.open_board(window, cx), + } + } + + pub(crate) fn open_board(&mut self, window: &mut Window, cx: &mut Context) { + self.main_view = MainView::Board; + self.maximized = None; + self.focus_active(window, cx); + cx.notify(); + } + + pub(crate) fn close_board(&mut self, window: &mut Window, cx: &mut Context) { + if self.main_view == MainView::Terminal { + return; + } + self.main_view = MainView::Terminal; + self.board.composer = None; + self.focus_active(window, cx); + cx.notify(); + } + + /// Takes the tasks a tree pull brought in. + pub(crate) fn adopt_board_tasks(&mut self, tasks: Vec, cx: &mut Context) { + if self.board.tasks != tasks { + self.board.tasks = tasks; + cx.notify(); + } + } + + pub(crate) fn board_task_put(&mut self, task: Task) { + match self.board.tasks.iter_mut().find(|t| t.id == task.id) { + Some(slot) => *slot = task, + None => self.board.tasks.push(task), + } + } + + pub(crate) fn board_task_removed(&mut self, task: TaskId) { + self.board.tasks.retain(|t| t.id != task); + } + + /// How many cards are stopped on the user — the sidebar row's count. + pub(crate) fn board_needs_input(&self, cx: &App) -> usize { + self.board_cards(None, false, cx) + .iter() + .filter(|c| c.column == Column::NeedsInput) + .count() + } + + /// Saves `task` here and on the machine. False, with the reason on + /// screen, when the machine would not take it. + fn save_task(&mut self, task: Task, window: &mut Window, cx: &mut Context) -> bool { + if let Err(why) = task.check() { + window.push_notification(why, cx); + return false; + } + if !crate::ui::tree_sync::push_task(cx, self.workspace, task.clone()) { + window.push_notification(t(L10nKey::BoardUnavailable), cx); + return false; + } + self.board_task_put(task); + cx.notify(); + true + } + + fn delete_task(&mut self, id: TaskId, window: &mut Window, cx: &mut Context) { + if !crate::ui::tree_sync::remove_task(cx, self.workspace, id) { + window.push_notification(t(L10nKey::BoardUnavailable), cx); + return; + } + self.board_task_removed(id); + cx.notify(); + } + + /// Everything the board knows about the open tabs that run an agent. + /// + /// `full` also reads what only a drawn card shows — the tab's label, its + /// git counts, its terminal title. The sidebar's count asks every frame + /// and needs none of that. + fn tab_facts(&self, window: Option<&Window>, full: bool, cx: &App) -> Vec { + let urgency = |s: AgentStatus| match s { + AgentStatus::Waiting => 3, + AgentStatus::Working => 2, + AgentStatus::Done => 1, + AgentStatus::Idle => 0, + }; + self.tabs + .iter() + .filter_map(|tab| { + // The tab's most urgent agent pane speaks for it, the same + // pane its sidebar row reports. + let leaf = tab + .pane + .terminals() + .into_iter() + .filter(|l| l.read(cx).agent().is_some()) + .max_by_key(|l| l.read(cx).agent_session().map_or(0, |s| urgency(s.status)))?; + let view = leaf.read(cx); + let session = view.agent_session().unwrap_or_default(); + let git = full.then(|| view.git_status(cx)).flatten(); + Some(TabFacts { + id: tab.tree_id.get(), + agent: view.agent()?, + live: Live::from(&session), + ask: (session.status == AgentStatus::Waiting) + .then(|| session.message.clone()) + .flatten(), + doing: full + .then(|| view.stated_title().map(str::to_string)) + .flatten(), + cwd: view.cwd(), + branch: git.as_ref().map(|g| g.branch.clone()), + diff: git + .as_ref() + .map(|g| (g.added, g.removed)) + .filter(|&(a, r)| a > 0 || r > 0), + label: full.then(|| self.full_tab_label(tab, window, cx)).flatten(), + }) + }) + .collect() + } + + /// Every card on the board this frame, filtered by agent. + fn board_cards(&self, window: Option<&Window>, full: bool, cx: &App) -> Vec { + let facts = self.tab_facts(window, full, cx); + let find = |tab: Option| tab.and_then(|id| facts.iter().find(|f| f.id == id)); + let mut cards = Vec::new(); + for task in &self.board.tasks { + let column = task::column(task, |run| find(run.tab).map(|f| f.live)); + // The run this card reports: the newest one still open, else the + // newest one at all. + let open = task.runs.iter().rev().find_map(|r| find(r.tab)); + let last = task.runs.last(); + let agent = open + .map(|f| f.agent) + .or(last.map(|r| r.agent)) + .or(task.agent); + let cwd = open + .and_then(|f| f.cwd.clone()) + .or_else(|| task.cwd.as_ref().map(PathBuf::from)); + cards.push(Card { + key: CardRef::Task(task.id), + column, + agent, + status: open.map(|f| f.live.status), + title: task.title.clone().into(), + place: place(cwd.as_deref(), open.and_then(|f| f.branch.as_deref())), + doing: open + .filter(|_| column == Column::Running) + .and_then(|f| f.doing.clone()) + .map(Into::into), + ask: open.and_then(|f| f.ask.clone()).map(Into::into), + diff: open.and_then(|f| f.diff), + since: match column { + Column::Done => task.done.map(|d| d.at), + Column::Queued => Some(task.created), + _ => last.map(|r| r.started), + }, + tab: open.map(|f| f.id), + resumable: open.is_none() && last.is_some_and(|r| r.session_id.is_some()), + }); + } + let claimed = |id: TabId| self.board.tasks.iter().any(|t| t.runs_in(id)); + for f in facts.iter().filter(|f| !claimed(f.id)) { + let column = task::live_column(f.live); + cards.push(Card { + key: CardRef::Loose(f.id), + column, + agent: Some(f.agent), + status: Some(f.live.status), + title: f + .label + .clone() + .unwrap_or_else(|| f.agent.display_name().to_string()) + .into(), + place: place(f.cwd.as_deref(), f.branch.as_deref()), + doing: (column == Column::Running) + .then(|| f.doing.clone()) + .flatten() + .filter(|d| Some(d) != f.label.as_ref()) + .map(Into::into), + ask: f.ask.clone().map(Into::into), + diff: f.diff, + since: None, + tab: Some(f.id), + resumable: false, + }); + } + if let Some(only) = self.board.agent_filter { + cards.retain(|c| c.agent == Some(only)); + } + cards + } + + /// Jumps to the tab a card's agent runs in. + fn open_card_tab(&mut self, tab: TabId, window: &mut Window, cx: &mut Context) { + let Some(index) = self.tabs.iter().position(|t| t.tree_id.get() == tab) else { + return; + }; + self.main_view = MainView::Terminal; + self.board.composer = None; + self.activate(index, window, cx); + self.focus_active(window, cx); + cx.notify(); + } + + fn click_card(&mut self, key: CardRef, window: &mut Window, cx: &mut Context) { + let cards = self.board_cards(Some(window), false, cx); + let Some(card) = cards.iter().find(|c| c.key == key) else { + return; + }; + match (card.tab, key) { + (Some(tab), _) => self.open_card_tab(tab, window, cx), + (None, CardRef::Task(id)) => self.open_composer(Some(id), window, cx), + (None, CardRef::Loose(_)) => {} + } + } + + /// Opens a new tab for `task` and starts its agent there, told what to do. + /// + /// The window stays on the board: the card moving to Running is the + /// answer, and whoever wants to watch can click it. + fn start_task( + &mut self, + id: TaskId, + agent: Option, + window: &mut Window, + cx: &mut Context, + ) { + let Some(mut task) = self.board.tasks.iter().find(|t| t.id == id).cloned() else { + return; + }; + let Some(agent) = agent + .or(task.agent) + .or_else(|| self.offered_agents(cx).first().copied()) + else { + window.push_notification(t(L10nKey::BoardNoAgent), cx); + return; + }; + let overrides = &cx.global::().agent_launch; + let launch = agent.launch_command(overrides); + let command = match task::prompt_flag(agent) { + Some(flag) => { + let ask = crate::core::shell_quote::quote_for_shell(&one_line(task.ask()), None); + match flag { + Some(flag) => format!("{launch} {flag} {ask}"), + None => format!("{launch} {ask}"), + } + } + // The agent takes no prompt it would stay open after answering, + // so it starts bare and the prompt waits on the clipboard. + None => { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(task.ask().to_string())); + window.push_notification( + t_fmt( + L10nKey::BoardPromptCopied, + &[("agent", agent.display_name())], + ), + cx, + ); + launch + } + }; + let cwd = task.cwd.as_ref().map(PathBuf::from); + let Some(slot) = self.new_tab_slot(cwd, None, window, cx) else { + return; + }; + // The tab is named for the task, so its sidebar row says what it is + // for rather than whatever the agent titles itself. + let tab = &mut self.tabs[self.active]; + tab.name = Some(task.title.clone()); + let tab_id = tab.tree_id.get(); + crate::ui::agent_launch::run_when_ready(&slot, command, cx); + self.main_view = MainView::Board; + self.focus_active(window, cx); + task.push_run(Run { + agent, + tab: Some(tab_id), + session_id: None, + started: unix_now(), + }); + task.agent = Some(agent); + task.done = None; + self.save_task(task, window, cx); + self.save_session(cx); + } + + fn mark_done(&mut self, key: CardRef, window: &mut Window, cx: &mut Context) { + let facts = self.tab_facts(Some(window), false, cx); + let live = |run: &Run| { + run.tab + .and_then(|id| facts.iter().find(|f| f.id == id)) + .map(|f| f.live) + }; + let task = match key { + CardRef::Task(id) => { + let Some(mut task) = self.board.tasks.iter().find(|t| t.id == id).cloned() else { + return; + }; + task.done = Some(Done { + at: unix_now(), + turns: task::live_turns(&task, live), + }); + task + } + CardRef::Loose(tab) => { + let Some(mut task) = self.loose_task(tab, window, cx) else { + return; + }; + task.done = Some(Done { + at: unix_now(), + turns: task::live_turns(&task, live), + }); + task + } + }; + self.save_task(task, window, cx); + } + + fn reopen(&mut self, id: TaskId, window: &mut Window, cx: &mut Context) { + let Some(mut task) = self.board.tasks.iter().find(|t| t.id == id).cloned() else { + return; + }; + task.done = None; + self.save_task(task, window, cx); + } + + /// A task for a loose card, holding the tab it runs in. + fn loose_task(&self, tab: TabId, window: &Window, cx: &App) -> Option { + let facts = self.tab_facts(Some(window), true, cx); + let f = facts.into_iter().find(|f| f.id == tab)?; + let title = f + .label + .clone() + .unwrap_or_else(|| f.agent.display_name().to_string()); + let title: String = title.chars().take(task::MAX_TITLE_CHARS).collect(); + let mut task = Task::new(title); + task.cwd = f.cwd.map(|p| p.display().to_string()); + task.agent = Some(f.agent); + task.push_run(Run { + agent: f.agent, + tab: Some(tab), + session_id: None, + started: unix_now(), + }); + Some(task) + } + + fn keep_loose(&mut self, tab: TabId, window: &mut Window, cx: &mut Context) { + if let Some(task) = self.loose_task(tab, window, cx) { + self.save_task(task, window, cx); + } + } + + fn resume_task(&mut self, id: TaskId, window: &mut Window, cx: &mut Context) { + let Some(task) = self.board.tasks.iter().find(|t| t.id == id).cloned() else { + return; + }; + let Some(run) = task.runs.last().cloned() else { + return; + }; + let Some(session) = run.session_id.clone() else { + return; + }; + let cwd = task.cwd.as_ref().map(PathBuf::from); + let before = self.tabs.len(); + self.resume_session(run.agent, &session, cwd, false, window, cx); + // Nothing opened, and the reason is on screen already; the run must + // not be pinned to whichever tab happened to be active. + if self.tabs.len() == before { + return; + } + let Some(tab) = self.tabs.get(self.active) else { + return; + }; + let mut task = task; + task.push_run(Run { + tab: Some(tab.tree_id.get()), + started: unix_now(), + ..run + }); + task.done = None; + self.save_task(task, window, cx); + } + + /// Writes down the session each open run's agent reported, so a run whose + /// tab closes can still be resumed. Called on every frame, board or not — + /// a tab can close while the terminal is showing — and only sends + /// anything when a session is new. Quiet on failure: a window whose tree + /// has not landed tries again next frame, and saying so every frame + /// would bury the screen in toasts. + fn note_run_sessions(&mut self, cx: &mut Context) { + let mut changed = Vec::new(); + for task in &self.board.tasks { + let mut next = task.clone(); + let mut dirty = false; + for run in next.runs.iter_mut() { + let Some(tab) = run + .tab + .and_then(|id| self.tabs.iter().find(|t| t.tree_id.get() == id)) + else { + continue; + }; + let session = tab + .pane + .terminals() + .into_iter() + .find_map(|l| l.read(cx).agent_session().and_then(|s| s.session_id)); + if session.is_some() && session != run.session_id { + run.session_id = session; + dirty = true; + } + } + if dirty { + changed.push(next); + } + } + for task in changed { + if crate::ui::tree_sync::push_task(cx, self.workspace, task.clone()) { + self.board_task_put(task); + } + } + } + + // ---- composer --------------------------------------------------------- + + pub(crate) fn open_composer( + &mut self, + editing: Option, + window: &mut Window, + cx: &mut Context, + ) { + let existing = editing.and_then(|id| self.board.tasks.iter().find(|t| t.id == id).cloned()); + let (title, prompt) = existing + .as_ref() + .map(|t| (t.title.clone(), t.prompt.clone())) + .unwrap_or_default(); + let title = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(t(L10nKey::BoardTitlePlaceholder)) + .default_value(title) + }); + let prompt = cx.new(|cx| { + InputState::new(window, cx) + .multi_line(true) + .placeholder(t(L10nKey::BoardPromptPlaceholder)) + .default_value(prompt) + }); + title.update(cx, |state, cx| state.focus(window, cx)); + let subs = vec![ + cx.subscribe_in( + &title, + window, + |this, _, ev: &InputEvent, window, cx| match ev { + InputEvent::PressEnter { + secondary: true, .. + } => this.submit_composer(true, window, cx), + InputEvent::PressEnter { .. } => this.submit_composer(false, window, cx), + InputEvent::Change => cx.notify(), + _ => {} + }, + ), + cx.subscribe_in(&prompt, window, |this, _, ev: &InputEvent, window, cx| { + if let InputEvent::PressEnter { + secondary: true, .. + } = ev + { + this.submit_composer(true, window, cx); + } + }), + ]; + let cwd = match &existing { + Some(t) => t.cwd.as_ref().map(PathBuf::from), + None => self.tabs.get(self.active).and_then(|t| { + t.pane + .focused_or_first(window, cx) + .and_then(|leaf| leaf.read(cx).spawnable_cwd()) + }), + }; + let agent = existing + .as_ref() + .and_then(|t| t.agent) + .or_else(|| self.offered_agents(cx).first().copied()); + self.board.composer = Some(Composer { + editing, + title, + prompt, + agent, + cwd, + _subs: subs, + }); + cx.notify(); + } + + fn close_composer(&mut self, window: &mut Window, cx: &mut Context) { + if self.board.composer.take().is_some() { + self.focus_active(window, cx); + cx.notify(); + } + } + + /// Saves the composer's task, and with `start`, starts it too. + fn submit_composer(&mut self, start: bool, window: &mut Window, cx: &mut Context) { + let Some(c) = self.board.composer.as_ref() else { + return; + }; + let title = c.title.read(cx).value().trim().to_string(); + if title.is_empty() { + window.push_notification(t(L10nKey::BoardNeedsTitle), cx); + return; + } + let prompt = c.prompt.read(cx).value().trim().to_string(); + let mut task = c + .editing + .and_then(|id| self.board.tasks.iter().find(|t| t.id == id).cloned()) + .unwrap_or_else(|| Task::new(String::new())); + task.title = title; + task.prompt = prompt; + task.agent = c.agent; + task.cwd = c.cwd.as_ref().map(|p| p.display().to_string()); + let (id, agent) = (task.id, task.agent); + if !self.save_task(task, window, cx) { + return; + } + self.board.composer = None; + if start { + self.start_task(id, agent, window, cx); + } else { + self.focus_active(window, cx); + } + cx.notify(); + } + + fn set_composer_agent(&mut self, agent: CLIAgent, cx: &mut Context) { + if let Some(c) = self.board.composer.as_mut() { + c.agent = Some(agent); + cx.notify(); + } + } + + // ---- drawing ---------------------------------------------------------- + + /// The board over the terminal area, or `None` while the terminal shows. + pub(crate) fn render_board( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + self.note_run_sessions(cx); + if self.main_view != MainView::Board { + return None; + } + let cards = self.board_cards(Some(window), true, cx); + let header = self.render_board_header(&cards, cx); + let body = match cards.is_empty() && self.board.agent_filter.is_none() { + true => self.render_board_empty(cx), + false => h_flex() + .flex_1() + .min_h_0() + .items_start() + .gap(px(COLUMN_GAP)) + .px(px(PAD_X)) + .pt(px(4.)) + .pb(px(PAD_X)) + .children(Column::ALL.into_iter().map(|col| { + let in_col: Vec<&Card> = cards.iter().filter(|c| c.column == col).collect(); + self.render_column(col, &in_col, cx) + })) + .into_any_element(), + }; + Some( + v_flex() + .id("board") + .track_focus(&self.board.focus) + .key_context("Board") + .absolute() + .inset_0() + .occlude() + .bg(crate::ui::theme::overlay_background(cx)) + .children(crate::ui::app::overlay_surface_layers(cx)) + .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| { + let k = &ev.keystroke; + if this.board.composer.is_some() { + if k.key == "escape" { + this.close_composer(window, cx); + } + return; + } + let bare = !k.modifiers.modified(); + match k.key.as_str() { + "escape" => this.close_board(window, cx), + "n" if bare => this.open_composer(None, window, cx), + _ => return, + } + cx.stop_propagation(); + })) + .child(header) + .child(body) + .when_some(self.render_composer(cx), |this, el| this.child(el)) + .into_any_element(), + ) + } + + fn render_board_header(&self, cards: &[Card], cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let muted = theme.muted_foreground; + let count = |col: Column| cards.iter().filter(|c| c.column == col).count(); + let summary = t_fmt( + L10nKey::BoardSummary, + &[ + ("running", &count(Column::Running).to_string()), + ("input", &count(Column::NeedsInput).to_string()), + ("review", &count(Column::Review).to_string()), + ], + ); + let filter_label: SharedString = match self.board.agent_filter { + Some(agent) => agent.display_name().into(), + None => t(L10nKey::BoardAllAgents).into(), + }; + let mut agents: Vec = self + .board + .tasks + .iter() + .filter_map(|t| t.agent) + .chain(self.tab_facts(None, false, cx).iter().map(|f| f.agent)) + .collect(); + agents.sort_by_key(|a| a.display_name()); + agents.dedup(); + let app = cx.entity().downgrade(); + let rungs = dialog::popover_rungs(cx); + h_flex() + .flex_none() + .h(px(HEADER_H)) + .px(px(PAD_X)) + .items_center() + .gap(px(12.)) + .child( + div() + .text_size(rems(TEXT)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(t(L10nKey::BoardTitle)), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(rems(META)) + .text_color(muted) + .child(summary), + ) + .child( + Button::new("board-agent-filter") + .label(filter_label) + .ghost() + .small() + .dropdown_caret(true) + .dropdown_menu(move |menu: PopupMenu, _window, _cx| { + let a0 = app.clone(); + let mut menu = + menu.item(PopupMenuItem::new(t(L10nKey::BoardAllAgents)).on_click( + move |_, _, cx| { + let _ = a0.update(cx, |this, cx| { + this.board.agent_filter = None; + cx.notify(); + }); + }, + )); + for agent in agents.clone() { + let a = app.clone(); + menu = menu.item(PopupMenuItem::new(agent.display_name()).on_click( + move |_, _, cx| { + let _ = a.update(cx, |this, cx| { + this.board.agent_filter = Some(agent); + cx.notify(); + }); + }, + )); + } + menu + }), + ) + .child(dialog::button( + "board-new-task", + t(L10nKey::BoardNewTask), + Tone::Primary, + true, + rungs, + cx, + cx.listener(|this, _, window, cx| this.open_composer(None, window, cx)), + )) + .into_any_element() + } + + fn render_board_empty(&self, cx: &mut Context) -> AnyElement { + let muted = cx.theme().muted_foreground; + v_flex() + .flex_1() + .items_center() + .justify_center() + .gap(px(6.)) + .child( + div() + .text_size(rems(TEXT)) + .font_weight(gpui::FontWeight::MEDIUM) + .child(t(L10nKey::BoardEmpty)), + ) + .child( + div() + .max_w(px(420.)) + .text_center() + .text_size(rems(META)) + .text_color(muted) + .child(t(L10nKey::BoardEmptyHint)), + ) + .into_any_element() + } + + fn render_column(&self, col: Column, cards: &[&Card], cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let (fg, muted) = (theme.foreground, theme.muted_foreground); + let (dot, hollow) = column_dot(col, cx); + let label = t(match col { + Column::Queued => L10nKey::BoardColQueued, + Column::Running => L10nKey::BoardColRunning, + Column::NeedsInput => L10nKey::BoardColNeedsInput, + Column::Review => L10nKey::BoardColReview, + Column::Done => L10nKey::BoardColDone, + }); + let head = h_flex() + .flex_none() + .h(px(COLUMN_HEAD_H)) + .px(px(4.)) + .items_center() + .gap(px(8.)) + .text_size(rems(META)) + .child( + div() + .size(px(7.)) + .rounded_full() + .when(hollow, |d| d.border_1().border_color(dot)) + .when(!hollow, |d| d.bg(dot)), + ) + .child( + div() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(fg) + .child(label), + ) + .child( + div() + .text_color(muted) + .font_features(crate::ui::tab_sidebar::tabular()) + .child(cards.len().to_string()), + ); + let list = v_flex() + .id(("board-column", col as usize)) + .flex_1() + .min_h_0() + .overflow_y_scroll() + .gap(px(CARD_GAP)) + .pb(px(4.)) + .children(cards.iter().map(|c| self.render_card(c, cx))); + v_flex() + .flex_1() + .min_w_0() + .h_full() + .gap(px(CARD_GAP)) + .child(head) + .child(list) + .into_any_element() + } + + fn render_card(&self, card: &Card, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let (fg, muted, border) = (theme.foreground, theme.muted_foreground, theme.border); + let surface = theme.popover; + let well: Hsla = gpui::rgb(dialog::popover_rungs(cx).hover).into(); + let mono = theme.mono_font_family.clone(); + let key = card.key; + let id = card_element_id(key); + let avatar = crate::ui::tab_strip::avatar( + SharedString::from(format!("{id}-avatar")), + crate::ui::search::Avatar { + agent: card.agent, + status: card.status, + unread: 0, + ssh: None, + }, + AVATAR, + cx, + ); + let title_row = h_flex() + .items_start() + .gap(px(9.)) + .child(div().mt(px(1.)).child(avatar)) + .child( + div() + .flex_1() + .min_w_0() + .text_size(rems(TAB_TEXT)) + .line_height(rems(TAB_TEXT * 1.4)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(fg) + .line_clamp(3) + .child(card.title.clone()), + ); + let place = card.place.clone().map(|p| { + div() + .truncate() + .text_size(rems(HEADING)) + .text_color(muted) + .child(p) + }); + let doing = card.doing.clone().map(|d| { + div() + .truncate() + .px(px(7.)) + .py(px(4.)) + .rounded(px(5.)) + .bg(well) + .font_family(mono.clone()) + .text_size(rems(META_MONO)) + .text_color(muted) + .child(d) + }); + let ask = (card.column == Column::NeedsInput).then(|| { + let tab = card.tab; + v_flex() + .gap(px(8.)) + .when_some(card.ask.clone(), |v, ask| { + v.child( + div() + .px(px(8.)) + .py(px(6.)) + .rounded(px(5.)) + .bg(well) + .font_family(mono.clone()) + .text_size(rems(META_MONO)) + .line_height(rems(META_MONO * 1.5)) + .text_color(fg) + .line_clamp(4) + .child(ask), + ) + }) + .when_some(tab, |v, tab| { + v.child( + dialog::button( + SharedString::from(format!("{id}-open")), + t(L10nKey::BoardOpen), + Tone::Primary, + true, + dialog::popover_rungs(cx), + cx, + cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.open_card_tab(tab, window, cx) + }), + ) + .w_full() + .h(px(24.)), + ) + }) + }); + let footer = { + let mut row = h_flex() + .items_center() + .gap(px(8.)) + .text_size(rems(META_MONO)) + .text_color(muted) + .font_features(crate::ui::tab_sidebar::tabular()); + if let Some((added, removed)) = card.diff { + row = row.child( + h_flex() + .gap(px(4.)) + .child(div().text_color(theme.success).child(format!("+{added}"))) + .child(div().text_color(theme.danger).child(format!("−{removed}"))), + ); + } + if card.resumable { + row = row.child(t(L10nKey::BoardResumable)); + } + row = row.child(div().flex_1()); + if let Some(since) = card.since { + row = row.child(ago(since)); + } + row + }; + let done = card.column == Column::Done; + let app = cx.entity().downgrade(); + let (column, tab, resumable) = (card.column, card.tab, card.resumable); + v_flex() + .id(id) + .gap(px(8.)) + .pt(px(11.)) + .px(px(12.)) + .pb(px(10.)) + .rounded(px(CARD_RADIUS)) + .bg(surface) + .border_1() + .border_color(border.opacity(0.6)) + .hover(|s| s.border_color(border)) + .when(done, |c| c.opacity(0.6)) + .cursor_pointer() + .on_click(cx.listener(move |this, _, window, cx| this.click_card(key, window, cx))) + .child(title_row) + .children(place) + .children(doing) + .children(ask) + .child(footer) + .context_menu(move |menu, _window, _cx| { + card_menu(menu, key, column, tab, resumable, app.clone()) + }) + .into_any_element() + } + + fn render_composer(&self, cx: &mut Context) -> Option { + let c = self.board.composer.as_ref()?; + let theme = cx.theme(); + let muted = theme.muted_foreground; + let rungs = dialog::popover_rungs(cx); + let agents = self.offered_agents(cx); + let agent_label: SharedString = c + .agent + .map(|a| a.display_name().into()) + .unwrap_or_else(|| t(L10nKey::BoardNoAgent).into()); + let app = cx.entity().downgrade(); + let where_ = c + .cwd + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + let has_title = !c.title.read(cx).value().trim().is_empty(); + let editing = c.editing.is_some(); + let well = dialog::well_fill(cx); + let prompt_field = div() + .h(px(PROMPT_H)) + .rounded(crate::ui::rounding::ROW_RADIUS) + .bg(well) + .py(px(2.)) + .child(Input::new(&c.prompt).appearance(false).small().h_full()); + let agent_picker = Button::new("board-composer-agent") + .label(agent_label) + .small() + .ghost() + .dropdown_caret(true) + .when_some(c.agent, |b, agent| { + b.icon(Icon::default().path(agent.icon_path()).size(px(12.))) + }) + .dropdown_menu(move |menu: PopupMenu, _window, _cx| { + let mut menu = menu; + for agent in agents.clone() { + let a = app.clone(); + menu = menu.item(PopupMenuItem::new(agent.display_name()).on_click( + move |_, _, cx| { + let _ = a.update(cx, |this, cx| this.set_composer_agent(agent, cx)); + }, + )); + } + menu + }); + let card = dialog::card(COMPOSER_W, cx) + .child(dialog::header( + t(if editing { + L10nKey::BoardEditTask + } else { + L10nKey::BoardNewTask + }), + cx, + )) + .child( + dialog::body() + .child(dialog::labelled( + t(L10nKey::BoardFieldTitle), + Input::new(&c.title), + cx, + )) + .child( + v_flex() + .gap(px(6.)) + .child(dialog::label(t(L10nKey::BoardFieldPrompt), cx)) + .child(prompt_field), + ) + .child( + h_flex() + .items_center() + .gap(px(10.)) + .child(dialog::label(t(L10nKey::BoardFieldAgent), cx)) + .child(agent_picker) + .child(div().flex_1()) + .child( + div() + .min_w_0() + .max_w(px(260.)) + .truncate() + .font_family(theme.mono_font_family.clone()) + .text_size(rems(META_MONO)) + .text_color(muted) + .child(where_), + ), + ), + ) + .child( + dialog::footer(cx) + .child(dialog::button( + "board-composer-cancel", + t(L10nKey::Cancel), + Tone::Secondary, + true, + rungs, + cx, + cx.listener(|this, _, window, cx| this.close_composer(window, cx)), + )) + .child(dialog::button( + "board-composer-save", + t(if editing { + L10nKey::BoardSave + } else { + L10nKey::BoardAddToQueue + }), + Tone::Secondary, + has_title, + rungs, + cx, + cx.listener(|this, _, window, cx| this.submit_composer(false, window, cx)), + )) + .child(dialog::button( + "board-composer-start", + t(L10nKey::BoardStartNow), + Tone::Primary, + has_title && c.agent.is_some(), + rungs, + cx, + cx.listener(|this, _, window, cx| this.submit_composer(true, window, cx)), + )), + ); + Some( + div() + .absolute() + .inset_0() + .bg(crate::ui::presets::scrim_fill(cx)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _: &gpui::MouseDownEvent, window, cx| { + this.close_composer(window, cx) + }), + ) + .flex() + .flex_col() + .items_center() + .justify_start() + .pt(px(crate::ui::switcher::CARD_TOP - HEADER_H)) + .child( + card.on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()), + ) + .into_any_element(), + ) + } + + /// The sidebar's Board row, under the search box. + pub(crate) fn render_board_nav(&self, cx: &mut Context) -> AnyElement { + let sf = cx.global::().rail; + let active = self.board_open(); + let theme = cx.theme(); + let needs = self.board_needs_input(cx); + let warn = theme.warning; + h_flex() + .id("sidebar-board") + .w_full() + .flex_shrink_0() + .h(px(30.)) + .px(px(8.)) + .gap(px(10.)) + .items_center() + .rounded(crate::ui::rounding::ROW_RADIUS) + .cursor_pointer() + .when(active, |s| { + s.bg(gpui::rgb(sf.selected)) + .text_color(gpui::rgb(sf.text_selected)) + .font_weight(gpui::FontWeight::MEDIUM) + }) + .when(!active, |s| { + s.text_color(gpui::rgb(sf.text_resting)) + .hover(|s| s.bg(gpui::rgb(sf.hover))) + }) + .child( + Icon::empty() + .path("icons/board.svg") + .size(px(14.)) + .text_color(theme.muted_foreground), + ) + .child(div().flex_1().child(t(L10nKey::BoardTitle))) + .when(needs > 0, |row| { + row.child( + div() + .min_w(px(18.)) + .h(px(18.)) + .px(px(5.)) + .rounded_full() + .bg(warn) + .flex() + .items_center() + .justify_center() + .text_size(rems(11. / 16.)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(gpui::white()) + .child(needs.min(99).to_string()), + ) + }) + .on_click(cx.listener(|this, _, window, cx| this.toggle_board(window, cx))) + .into_any_element() + } +} + +fn card_element_id(key: CardRef) -> SharedString { + match key { + CardRef::Task(id) => format!("board-task-{id}").into(), + CardRef::Loose(id) => format!("board-tab-{id}").into(), + } +} + +fn card_menu( + menu: PopupMenu, + key: CardRef, + column: Column, + tab: Option, + resumable: bool, + app: gpui::WeakEntity, +) -> PopupMenu { + let item = |label: L10nKey, + f: Box)>| { + let app = app.clone(); + PopupMenuItem::new(t(label)).on_click(move |_, window, cx| { + let _ = app.update(cx, |this, cx| f(this, window, cx)); + }) + }; + let mut menu = menu; + if let Some(tab) = tab { + menu = menu.item(item( + L10nKey::BoardOpen, + Box::new(move |this, window, cx| this.open_card_tab(tab, window, cx)), + )); + } + match key { + CardRef::Task(id) => { + if tab.is_none() { + menu = menu.item(item( + L10nKey::BoardStart, + Box::new(move |this, window, cx| this.start_task(id, None, window, cx)), + )); + } + if resumable { + menu = menu.item(item( + L10nKey::BoardResume, + Box::new(move |this, window, cx| this.resume_task(id, window, cx)), + )); + } + menu = match column { + Column::Done => menu.item(item( + L10nKey::BoardReopen, + Box::new(move |this, window, cx| this.reopen(id, window, cx)), + )), + _ => menu.item(item( + L10nKey::BoardMarkDone, + Box::new(move |this, window, cx| this.mark_done(key, window, cx)), + )), + }; + menu.separator() + .item(item( + L10nKey::BoardEdit, + Box::new(move |this, window, cx| this.open_composer(Some(id), window, cx)), + )) + .item(item( + L10nKey::BoardDelete, + Box::new(move |this, window, cx| this.delete_task(id, window, cx)), + )) + } + CardRef::Loose(tab) => menu + .item(item( + L10nKey::BoardKeep, + Box::new(move |this, window, cx| this.keep_loose(tab, window, cx)), + )) + .item(item( + L10nKey::BoardMarkDone, + Box::new(move |this, window, cx| this.mark_done(key, window, cx)), + )), + } +} + +/// The dot a column heads itself with, and whether it is drawn as a ring. +/// The colours are the agent status dots the sidebar already uses, so a card +/// and its tab's row say a state the same way. +fn column_dot(col: Column, cx: &App) -> (Hsla, bool) { + let muted = cx.theme().muted_foreground; + let status = |s: AgentStatus| -> Hsla { gpui::rgb(s.dot_rgb().unwrap_or(0x888888)).into() }; + match col { + Column::Queued => (muted, true), + Column::Running => (status(AgentStatus::Working), false), + Column::NeedsInput => (status(AgentStatus::Waiting), true), + Column::Review => (status(AgentStatus::Done), false), + Column::Done => (muted.opacity(0.6), false), + } +} + +/// `repo · branch` for a card, from where its agent runs. +fn place(cwd: Option<&std::path::Path>, branch: Option<&str>) -> Option { + let repo = cwd + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().into_owned()); + match (repo, branch) { + (Some(repo), Some(branch)) => Some(format!("{repo} · {branch}").into()), + (Some(repo), None) => Some(repo.into()), + (None, Some(branch)) => Some(branch.to_string().into()), + (None, None) => None, + } +} + +/// A prompt as one command-line argument. The command is typed into a shell, +/// where a newline would submit it half-way; the agent reads the paragraphs +/// joined just as well. +fn one_line(text: &str) -> String { + text.lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .collect::>() + .join(" ") +} + +/// How long ago `since` was, in the board's short form: `now`, `12m`, `3h`, +/// `2d`. +fn ago(since: u64) -> String { + let secs = unix_now().saturating_sub(since); + match secs { + 0..60 => t(L10nKey::BoardNow).to_string(), + 60..3600 => format!("{}m", secs / 60), + 3600..86400 => format!("{}h", secs / 3600), + _ => format!("{}d", secs / 86400), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_prompt_goes_on_one_line() { + assert_eq!(one_line("Fix it.\n\n Then test.\n"), "Fix it. Then test."); + assert_eq!(one_line("one"), "one"); + } + + #[test] + fn a_place_names_what_it_knows() { + let p = std::path::Path::new("/src/tty7"); + assert_eq!( + place(Some(p), Some("main")).as_deref(), + Some("tty7 · main") + ); + assert_eq!(place(Some(p), None).as_deref(), Some("tty7")); + assert_eq!(place(None, None), None); + } +} diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index b9b91c4a..39990a04 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1766,6 +1766,49 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::CmdDocumentDock => "Document: Dock Beside Terminal", L10nKey::CmdDocumentFill => "Document: Fill Window", L10nKey::CmdToggleDocumentFill => "Toggle Document Fill / Dock", + L10nKey::CmdToggleBoard => "Toggle Board", + L10nKey::BoardTitle => "Board", + L10nKey::BoardNewTask => "New task", + L10nKey::BoardEditTask => "Edit task", + L10nKey::BoardColQueued => "Queued", + L10nKey::BoardColRunning => "Running", + L10nKey::BoardColNeedsInput => "Needs input", + L10nKey::BoardColReview => "Review", + L10nKey::BoardColDone => "Done", + L10nKey::BoardAllAgents => "All agents", + L10nKey::BoardSummary => "{running} running · {input} need you · {review} to review", + L10nKey::BoardEmpty => "Nothing on the board yet", + L10nKey::BoardEmptyHint => { + "Add a task with New task (N), or start an agent in any tab — it shows up here on its own." + } + L10nKey::BoardOpen => "Open", + L10nKey::BoardStart => "Start", + L10nKey::BoardResume => "Resume", + L10nKey::BoardResumable => "Resumable", + L10nKey::BoardMarkDone => "Mark done", + L10nKey::BoardReopen => "Reopen", + L10nKey::BoardEdit => "Edit", + L10nKey::BoardDelete => "Delete", + L10nKey::BoardKeep => "Keep as task", + L10nKey::BoardFieldTitle => "Title", + L10nKey::BoardFieldPrompt => "Prompt", + L10nKey::BoardFieldAgent => "Agent", + L10nKey::BoardTitlePlaceholder => "What should the agent do?", + L10nKey::BoardPromptPlaceholder => { + "Details, context, how to check it’s done… (optional — the title is sent if this is empty)" + } + L10nKey::BoardSave => "Save", + L10nKey::BoardAddToQueue => "Add to queue", + L10nKey::BoardStartNow => "Start", + L10nKey::BoardNeedsTitle => "A task needs a title", + L10nKey::BoardNoAgent => "No agent found", + L10nKey::BoardPromptCopied => { + "{agent} can’t be given a starting prompt, so it was copied — paste it in once {agent} is up" + } + L10nKey::BoardUnavailable => { + "This machine’s server can’t keep tasks yet — update it to use the board" + } + L10nKey::BoardNow => "now", L10nKey::CmdDocumentWidthThird => "Document: Third Width", L10nKey::CmdDocumentWidthHalf => "Document: Half Width", L10nKey::CmdDocumentWidthTwoThirds => "Document: Two-Thirds Width", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 44b64e7d..a848b32c 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1811,6 +1811,49 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::CmdDocumentDock => "ドキュメント: ターミナルの隣にドック", L10nKey::CmdDocumentFill => "ドキュメント: ウィンドウ全体", L10nKey::CmdToggleDocumentFill => "ドキュメントのフィル / ドックを切り替え", + L10nKey::CmdToggleBoard => "ボードの表示を切り替え", + L10nKey::BoardTitle => "ボード", + L10nKey::BoardNewTask => "新しいタスク", + L10nKey::BoardEditTask => "タスクを編集", + L10nKey::BoardColQueued => "待機", + L10nKey::BoardColRunning => "実行中", + L10nKey::BoardColNeedsInput => "入力待ち", + L10nKey::BoardColReview => "レビュー", + L10nKey::BoardColDone => "完了", + L10nKey::BoardAllAgents => "すべてのエージェント", + L10nKey::BoardSummary => "実行中 {running} · 入力待ち {input} · レビュー待ち {review}", + L10nKey::BoardEmpty => "ボードはまだ空です", + L10nKey::BoardEmptyHint => { + "「新しいタスク」(N) でタスクを追加するか、どれかのタブでエージェントを起動してください。自動的にここに表示されます。" + } + L10nKey::BoardOpen => "開く", + L10nKey::BoardStart => "開始", + L10nKey::BoardResume => "再開", + L10nKey::BoardResumable => "再開可能", + L10nKey::BoardMarkDone => "完了にする", + L10nKey::BoardReopen => "再度開く", + L10nKey::BoardEdit => "編集", + L10nKey::BoardDelete => "削除", + L10nKey::BoardKeep => "タスクとして残す", + L10nKey::BoardFieldTitle => "タイトル", + L10nKey::BoardFieldPrompt => "プロンプト", + L10nKey::BoardFieldAgent => "エージェント", + L10nKey::BoardTitlePlaceholder => "エージェントに何をさせますか?", + L10nKey::BoardPromptPlaceholder => { + "詳細、背景、完了の確認方法など(任意 — 空ならタイトルを送ります)" + } + L10nKey::BoardSave => "保存", + L10nKey::BoardAddToQueue => "キューに追加", + L10nKey::BoardStartNow => "開始", + L10nKey::BoardNeedsTitle => "タスクにはタイトルが必要です", + L10nKey::BoardNoAgent => "エージェントが見つかりません", + L10nKey::BoardPromptCopied => { + "{agent} は起動時にプロンプトを受け取れないため、コピーしました。起動したら貼り付けてください" + } + L10nKey::BoardUnavailable => { + "このマシンのサーバーはまだタスクを保存できません。ボードを使うには更新してください" + } + L10nKey::BoardNow => "たった今", L10nKey::CmdDocumentWidthThird => "ドキュメント: 幅3分の1", L10nKey::CmdDocumentWidthHalf => "ドキュメント: 幅半分", L10nKey::CmdDocumentWidthTwoThirds => "ドキュメント: 幅3分の2", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 8ebfec1d..a18c662b 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1500,6 +1500,41 @@ l10n_keys! { CmdDocumentDock, CmdDocumentFill, CmdToggleDocumentFill, + CmdToggleBoard, + BoardTitle, + BoardNewTask, + BoardEditTask, + BoardColQueued, + BoardColRunning, + BoardColNeedsInput, + BoardColReview, + BoardColDone, + BoardAllAgents, + BoardSummary, + BoardEmpty, + BoardEmptyHint, + BoardOpen, + BoardStart, + BoardResume, + BoardResumable, + BoardMarkDone, + BoardReopen, + BoardEdit, + BoardDelete, + BoardKeep, + BoardFieldTitle, + BoardFieldPrompt, + BoardFieldAgent, + BoardTitlePlaceholder, + BoardPromptPlaceholder, + BoardSave, + BoardAddToQueue, + BoardStartNow, + BoardNeedsTitle, + BoardNoAgent, + BoardPromptCopied, + BoardUnavailable, + BoardNow, CmdDocumentWidthThird, CmdDocumentWidthHalf, CmdDocumentWidthTwoThirds, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index e797ff9a..954f5d35 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1638,6 +1638,47 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::CmdDocumentDock => "文档:停靠在终端旁", L10nKey::CmdDocumentFill => "文档:铺满窗口", L10nKey::CmdToggleDocumentFill => "切换文档铺满 / 停靠", + L10nKey::CmdToggleBoard => "切换看板", + L10nKey::BoardTitle => "看板", + L10nKey::BoardNewTask => "新任务", + L10nKey::BoardEditTask => "编辑任务", + L10nKey::BoardColQueued => "排队", + L10nKey::BoardColRunning => "运行中", + L10nKey::BoardColNeedsInput => "等待输入", + L10nKey::BoardColReview => "待审阅", + L10nKey::BoardColDone => "完成", + L10nKey::BoardAllAgents => "全部 Agent", + L10nKey::BoardSummary => "{running} 运行中 · {input} 等你 · {review} 待审阅", + L10nKey::BoardEmpty => "看板上还没有东西", + L10nKey::BoardEmptyHint => { + "用「新任务」(N) 添加一个任务,或者在任意标签页里启动 Agent——它会自己出现在这里。" + } + L10nKey::BoardOpen => "打开", + L10nKey::BoardStart => "开始", + L10nKey::BoardResume => "恢复会话", + L10nKey::BoardResumable => "可恢复", + L10nKey::BoardMarkDone => "标记完成", + L10nKey::BoardReopen => "重新打开", + L10nKey::BoardEdit => "编辑", + L10nKey::BoardDelete => "删除", + L10nKey::BoardKeep => "保留为任务", + L10nKey::BoardFieldTitle => "标题", + L10nKey::BoardFieldPrompt => "提示词", + L10nKey::BoardFieldAgent => "使用的 Agent", + L10nKey::BoardTitlePlaceholder => "要 Agent 做什么?", + L10nKey::BoardPromptPlaceholder => "细节、上下文、怎么算完成……(可选,留空则发送标题)", + L10nKey::BoardSave => "保存", + L10nKey::BoardAddToQueue => "加入排队", + L10nKey::BoardStartNow => "开始", + L10nKey::BoardNeedsTitle => "任务需要一个标题", + L10nKey::BoardNoAgent => "没有找到 Agent", + L10nKey::BoardPromptCopied => { + "{agent} 不支持启动时带提示词,已复制到剪贴板——{agent} 启动后粘贴进去" + } + L10nKey::BoardUnavailable => { + "这台机器的 server 版本太旧,还不能保存任务——更新后即可使用看板" + } + L10nKey::BoardNow => "刚刚", L10nKey::CmdDocumentWidthThird => "文档:三分之一宽", L10nKey::CmdDocumentWidthHalf => "文档:一半宽", L10nKey::CmdDocumentWidthTwoThirds => "文档:三分之二宽", diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index ea2752a7..dbc5ef49 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -659,6 +659,7 @@ fn shipped_bindings() -> Vec<(&'static str, &'static str)> { // terminal back, so a default chord here would only be one more thing // competing for a two-key combination nobody asked for. ("ToggleDocumentFill", ""), + ("ToggleBoard", "secondary-alt-b"), ("DocumentWidthThird", ""), ("DocumentWidthHalf", ""), ("DocumentWidthTwoThirds", ""), @@ -946,6 +947,7 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { CommandGroup::View, t(L10nKey::CmdToggleDocumentFill).to_string(), ), + "ToggleBoard" => (CommandGroup::View, t(L10nKey::CmdToggleBoard).to_string()), "DocumentWidthThird" => ( CommandGroup::View, t(L10nKey::CmdDocumentWidthThird).to_string(), @@ -1799,6 +1801,7 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "ShowSshForwards" => KeyBinding::new(keystroke, ShowSshForwards, None), "ToggleCodePanel" => KeyBinding::new(keystroke, ToggleCodePanel, None), "ToggleDocumentFill" => KeyBinding::new(keystroke, ToggleDocumentFill, None), + "ToggleBoard" => KeyBinding::new(keystroke, ToggleBoard, None), "DocumentWidthThird" => KeyBinding::new(keystroke, DocumentWidthThird, None), "DocumentWidthHalf" => KeyBinding::new(keystroke, DocumentWidthHalf, None), "DocumentWidthTwoThirds" => KeyBinding::new(keystroke, DocumentWidthTwoThirds, None), diff --git a/src/ui/machine_mirror.rs b/src/ui/machine_mirror.rs index 405a427d..921794c6 100644 --- a/src/ui/machine_mirror.rs +++ b/src/ui/machine_mirror.rs @@ -316,6 +316,17 @@ fn apply(machine: &mut Machine, workspace: WorkspaceId, delta: &LayoutDelta) -> ws.groups = groups.clone(); true } + LayoutDelta::TaskPut { task } => { + match ws.tasks.iter_mut().find(|t| t.id == task.id) { + Some(slot) => *slot = task.clone(), + None => ws.tasks.push(task.clone()), + } + true + } + LayoutDelta::TaskRemoved { task } => { + ws.tasks.retain(|t| t.id != *task); + true + } LayoutDelta::TabMoved { tab, to } => { let Some(from) = ws.tabs.iter().position(|t| t.id == *tab) else { return false; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 04eb9af6..65b8269d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,6 +1,7 @@ pub mod agent_launch; pub mod app; pub mod assets; +pub mod board; pub mod code_editor; pub mod dialog; pub mod diff_list; diff --git a/src/ui/search/command.rs b/src/ui/search/command.rs index c7c07b8d..c7d9bc9f 100644 --- a/src/ui/search/command.rs +++ b/src/ui/search/command.rs @@ -85,6 +85,7 @@ pub enum CommandKind { ShowSshForwards, ToggleCodePanel, ToggleDocumentFill, + ToggleBoard, DocumentWidthThird, DocumentWidthHalf, DocumentWidthTwoThirds, @@ -265,6 +266,7 @@ impl CommandKind { ShowSshForwards => "ssh-port-forwarding", ToggleCodePanel => "code-panel", ToggleDocumentFill => "document-fill", + ToggleBoard => "board", DocumentWidthThird => "document-width-third", DocumentWidthHalf => "document-width-half", DocumentWidthTwoThirds => "document-width-two-thirds", @@ -407,6 +409,7 @@ impl CommandKind { ShowSshForwards => "ShowSshForwards", ToggleCodePanel => "ToggleCodePanel", ToggleDocumentFill => "ToggleDocumentFill", + ToggleBoard => "ToggleBoard", DocumentWidthThird => "DocumentWidthThird", DocumentWidthHalf => "DocumentWidthHalf", DocumentWidthTwoThirds => "DocumentWidthTwoThirds", @@ -690,6 +693,7 @@ impl Item { ToggleRightPanel, ), Item::localized(L10nKey::CmdShowCodePanel, ToggleCodePanel), + Item::localized(L10nKey::CmdToggleBoard, ToggleBoard), Item::localized(L10nKey::CmdGoToFile, QuickOpenFile), Item::localized( if document_filled { diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 8f990ecd..461e25fb 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -72,7 +72,7 @@ const META_SEP: &str = " · "; const META_SEP_TRIMMED: &str = "·"; /// Tabular numerals, so a column of diff counts lines up digit for digit. -fn tabular() -> gpui::FontFeatures { +pub(crate) fn tabular() -> gpui::FontFeatures { gpui::FontFeatures(std::sync::Arc::new(vec![("tnum".to_string(), 1)])) } @@ -1774,7 +1774,8 @@ impl Tty7App { .pt(px(4.)) .pb(px(16.)) .child(self.workspace_head(cx)) - .child(search); + .child(search) + .child(self.render_board_nav(cx)); let container: Rc>>> = Rc::new(Cell::new(None)); // Read while there is still a `cx` to read it from: the drag handler diff --git a/src/ui/tree_sync.rs b/src/ui/tree_sync.rs index 5fc8931c..c8ecfe29 100644 --- a/src/ui/tree_sync.rs +++ b/src/ui/tree_sync.rs @@ -16,6 +16,7 @@ use crate::core::session::{Session, SessionPane, SessionTab, WorkspaceId, Worksp use crate::ui::app::Tty7App; use crate::ui::i18n::{L10nKey, t}; use crate::ui::pane::{Pane, PaneSlot}; +use tty7_core::core::task::{Task, TaskId}; pub(crate) fn control_for(cx: &mut App, host: HostId) -> Option> { if host.is_local() { @@ -340,6 +341,7 @@ pub(crate) struct WsMirror { pub tabs: Vec, pub active: Option, pub groups: WorkspaceGroups, + pub tasks: Vec, } #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -1571,6 +1573,7 @@ fn primed(ws: Workspace, arrival: Arrival) -> (WsMirror, Option, Arrival tabs: ws.tabs, active: ws.active_tab, groups: ws.groups, + tasks: ws.tasks, }, ws.name, arrival, @@ -1611,6 +1614,7 @@ fn finish_prime( } }; adopt_groups(cx, client_ws); + adopt_tasks(cx, client_ws); let host = WorkspaceStore::host_of(cx, client_ws); let machine_ws = tree_workspace_id(cx, client_ws); crate::ui::machine_mirror::MachineMirrors::note_synced_workspace( @@ -1712,6 +1716,104 @@ fn adopt_groups(cx: &mut App, client_ws: WorkspaceId) { }); } +/// Whether the machine holding `client_ws` keeps a board. One that predates +/// the board would refuse the request that saves a card — and, worse, one it +/// cannot decode takes the whole link down — so a window never sends it one. +pub(crate) fn can_board_on(cx: &App, client_ws: WorkspaceId) -> bool { + let feature = tty7_core::daemon::control::feature::TASKS; + let host = WorkspaceStore::host_of(cx, client_ws); + match host.is_local() { + true => crate::ui::local_link::LocalLink::supports(cx, feature), + false => crate::ui::remote_connect::HostLinks::peer_supports(cx, host, feature), + } +} + +/// Sends a card a window just made or changed up to the machine — the whole +/// card, which replaces whatever the machine had under its id. +/// +/// Like groups, tasks are never part of the diff [`sync_window`] runs: a +/// window has nothing to say about a card except the edit someone just made +/// to it. Returns false when the window's tree has not landed yet (or the +/// machine keeps no board), in which case nothing was sent and the caller +/// should not show the edit as made. +pub(crate) fn push_task(cx: &mut App, client_ws: WorkspaceId, task: Task) -> bool { + if !cx.has_global::() || !can_board_on(cx, client_ws) { + return false; + } + let machine_ws = tree_workspace_id(cx, client_ws); + let state = cx + .default_global::() + .windows + .entry(client_ws) + .or_default(); + let SyncPhase::Primed(mirror) = &mut state.sync else { + return false; + }; + match mirror.tasks.iter_mut().find(|t| t.id == task.id) { + Some(slot) if *slot == task => return true, + Some(slot) => *slot = task.clone(), + None => mirror.tasks.push(task.clone()), + } + state.queue.push_back(ControlRequest::TaskPut { + workspace: machine_ws, + task, + }); + pump(cx, client_ws); + true +} + +/// Takes a card off the machine's board. See [`push_task`]. +pub(crate) fn remove_task(cx: &mut App, client_ws: WorkspaceId, task: TaskId) -> bool { + if !cx.has_global::() || !can_board_on(cx, client_ws) { + return false; + } + let machine_ws = tree_workspace_id(cx, client_ws); + let state = cx + .default_global::() + .windows + .entry(client_ws) + .or_default(); + let SyncPhase::Primed(mirror) = &mut state.sync else { + return false; + }; + let before = mirror.tasks.len(); + mirror.tasks.retain(|t| t.id != task); + if mirror.tasks.len() == before { + return true; + } + state.queue.push_back(ControlRequest::TaskRemove { + workspace: machine_ws, + task, + }); + pump(cx, client_ws); + true +} + +/// Hands the window the board a pull just brought in. Deferred for the same +/// reason as [`adopt_groups`]. +fn adopt_tasks(cx: &mut App, client_ws: WorkspaceId) { + cx.defer(move |cx| { + let tasks = match cx + .default_global::() + .windows + .get(&client_ws) + .map(|s| &s.sync) + { + Some(SyncPhase::Primed(mirror)) => mirror.tasks.clone(), + _ => return, + }; + if !cx.has_global::() { + return; + } + let Some(app) = + crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|a| a.upgrade()) + else { + return; + }; + app.update(cx, |app, cx| app.adopt_board_tasks(tasks, cx)); + }); +} + fn pump(cx: &mut App, client_ws: WorkspaceId) { let host = WorkspaceStore::host_of(cx, client_ws); let client = tree_control_for(cx, host); @@ -2379,6 +2481,7 @@ fn layout_of( tabs: ws.tabs.clone(), active: ws.active_tab, groups: ws.groups.clone(), + tasks: ws.tasks.clone(), }; let session = session_from_tree(ws, &machine.panes); Ok((machine, mirror, session)) @@ -2460,6 +2563,7 @@ fn settle_hydration( dirty }; adopt_groups(cx, client_ws); + adopt_tasks(cx, client_ws); let Some(app) = crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|app| app.upgrade()) else { @@ -2741,6 +2845,17 @@ fn apply_to_mirror(mirror: &mut WsMirror, delta: &LayoutDelta) -> bool { mirror.groups = groups.clone(); true } + LayoutDelta::TaskPut { task } => { + match mirror.tasks.iter_mut().find(|t| t.id == task.id) { + Some(slot) => *slot = task.clone(), + None => mirror.tasks.push(task.clone()), + } + true + } + LayoutDelta::TaskRemoved { task } => { + mirror.tasks.retain(|t| t.id != *task); + true + } LayoutDelta::TabMoved { tab, to } => { let Some(from) = mirror.tabs.iter().position(|t| t.id == *tab) else { return false; @@ -2915,6 +3030,14 @@ impl Tty7App { self.adopt_sidebar_groups(groups.clone(), cx); true } + LayoutDelta::TaskPut { task } => { + self.board_task_put(task.clone()); + true + } + LayoutDelta::TaskRemoved { task } => { + self.board_task_removed(*task); + true + } LayoutDelta::TabMoved { tab, to } => { if let Some(from) = index_of(&self.tabs, *tab) { let active_id = self.tabs.get(self.active).map(|t| t.tree_id.get());