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());
|