From 5594d653f57b00a9e7fccd05bb26673a04c6217c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 18:49:38 +0300 Subject: [PATCH 01/29] feat(tui): group and sort the Agents sidebar from Appearance --- config.example.toml | 29 ++- gitbooks/developers/configuration.md | 2 +- src/sdk/src/config/appearance/mod.rs | 5 +- src/sdk/src/config/appearance/types.rs | 75 ++++++ src/sdk/src/config/mod.rs | 2 +- src/sdk/src/config/types_tests.rs | 24 ++ src/tui/src/ui/app/appearance.rs | 68 ++++- src/tui/src/ui/app/rail/mod.rs | 36 ++- src/tui/src/ui/app/rail/organize.rs | 241 ++++++++++++++++++ src/tui/src/ui/app/rail/organize_tests.rs | 217 ++++++++++++++++ src/tui/src/ui/app/rail/tests.rs | 6 +- src/tui/src/ui/app/rail/types.rs | 19 ++ src/tui/src/ui/app/render/agents/rail/mod.rs | 12 + src/tui/src/ui/app/render/agents/summary.rs | 4 +- .../app/render/settings/appearance_usage.rs | 45 +++- src/tui/tests/feature_settings.rs | 52 ++++ 16 files changed, 805 insertions(+), 32 deletions(-) create mode 100644 src/tui/src/ui/app/rail/organize.rs create mode 100644 src/tui/src/ui/app/rail/organize_tests.rs diff --git a/config.example.toml b/config.example.toml index 84252d455..2a86f8aa2 100644 --- a/config.example.toml +++ b/config.example.toml @@ -263,10 +263,33 @@ tokenEnv = "MEDULLA_TOKEN" # selectionFg = "white" # text drawn on top of the primary selection background # dimBorder = "darkgray" # panel borders -# Legacy harness-row toggles, kept so an older config keeps working. They are -# read only when [statusLine] below is absent, in which case `false` means that -# field is hidden. Settings › Status line writes [statusLine] and takes over. +# --------------------------------------------------------------------------- +# appearance — how the Agents sidebar arranges itself, plus the legacy +# harness-row toggles. Settings › Appearance edits all of these live. +# +# sidebarGrouping picks what heads the sidebar's sections: +# "host" one section per machine, drawn only once a second host exists +# (the default) +# "path" one per workspace directory, so a checkout's agents read together +# "harness" one per coding-agent CLI +# "none" one flat list, no headers +# +# sidebarSort orders the agents in a section and the sessions under an agent: +# "created" declaration order for agents, oldest-first for sessions (default) +# "recent" most recently active first +# "name" alphabetical +# +# Grouping only moves the headers: every agent keeps its own sessions, so no row +# disappears whichever way these are set. +# +# showHarnessBranch/showHarnessPath are legacy, kept so an older config keeps +# working. They are read only when [statusLine] below is absent, in which case +# `false` means that field is hidden. Settings › Status line writes +# [statusLine] and takes over. +# --------------------------------------------------------------------------- # [appearance] +# sidebarGrouping = "host" +# sidebarSort = "created" # showHarnessBranch = true # showHarnessPath = true diff --git a/gitbooks/developers/configuration.md b/gitbooks/developers/configuration.md index 4a674fd5a..b8d9809bf 100644 --- a/gitbooks/developers/configuration.md +++ b/gitbooks/developers/configuration.md @@ -58,7 +58,7 @@ Config is merged from lowest to highest precedence (highest wins): Files are merged field-by-field (a recursive table merge), so a project-local file can override just `backend.baseUrl` without discarding the rest of a global file. [TOML](https://toml.io/) is the primary format; `--config ` still accepts either `.toml` or `.json` (parser chosen by extension) and bypasses file discovery, but env vars and CLI flags still override it. The Config tab shows the merged effective config and lists the source files that contributed. -Every section is optional; with no file anywhere, all defaults apply. Sections: `backend`, `host` (whether this device also runs the work it orchestrates, and the workspace and roots it advertises), `link` (host-link identity, forwarder, and peer roster for the daemon and Overview panel), `hub` (the persisted worker roster and selected default worker, so a fleet survives a restart), `stateDir` (default `/state`; `MEDULLA_STATE_DIR` overrides), `opencode` (worker display, model, agent, workspace, concurrency), `workflow` (the daemon's workspace allowlist, and the workspace roots whose `MEDULLA.md` rides every backend session mint), `fleet` (the declared `Host → Harness → Workspace → Agent` capacity chain and the agent-template catalog), `router` (a custom OpenAI-compatible router the daemon spawns harnesses against; absent leaves every harness unrouted), `budget` (operator-declared per-provider budgets; absent leaves every harness advertising an estimate), `onboarding` (welcome-flow completion state), `update` (`check = true`/`false` for the background release check; `MEDULLA_NO_UPDATE_CHECK` env kill-switch), `theme` (TUI colors — `primary`/`accent`/`selectionFg`/`dimBorder` as [ratatui](https://ratatui.rs/) color names or `#rrggbb`; the Settings › Appearance subpage edits and persists these), `statusLine` (how a harness row on the Agents rail is laid out — each of `state`/`harness`/`control`/`branch`/`path` takes a `line1`/`line2`/`line3`/`hidden` placement, a `*When` visibility of `always`/`active`/`alert`, and where it applies a `*Style` spelling; the Settings › Status line subpage edits these with a live preview, and the older `appearance.showHarnessBranch`/`showHarnessPath` booleans are read only when this section is absent), and `medulla.contextWindowTokens` (Context tab usage hint; the orchestration limits section also carries pass/step/depth/task/token bounds). Inference and tracing are server-side concerns — the TUI has no config for them; unknown sections are ignored. +Every section is optional; with no file anywhere, all defaults apply. Sections: `backend`, `host` (whether this device also runs the work it orchestrates, and the workspace and roots it advertises), `link` (host-link identity, forwarder, and peer roster for the daemon and Overview panel), `hub` (the persisted worker roster and selected default worker, so a fleet survives a restart), `stateDir` (default `/state`; `MEDULLA_STATE_DIR` overrides), `opencode` (worker display, model, agent, workspace, concurrency), `workflow` (the daemon's workspace allowlist, and the workspace roots whose `MEDULLA.md` rides every backend session mint), `fleet` (the declared `Host → Harness → Workspace → Agent` capacity chain and the agent-template catalog), `router` (a custom OpenAI-compatible router the daemon spawns harnesses against; absent leaves every harness unrouted), `budget` (operator-declared per-provider budgets; absent leaves every harness advertising an estimate), `onboarding` (welcome-flow completion state), `update` (`check = true`/`false` for the background release check; `MEDULLA_NO_UPDATE_CHECK` env kill-switch), `theme` (TUI colors — `primary`/`accent`/`selectionFg`/`dimBorder` as [ratatui](https://ratatui.rs/) color names or `#rrggbb`; the Settings › Appearance subpage edits and persists these), `statusLine` (how a harness row on the Agents rail is laid out — each of `state`/`harness`/`control`/`branch`/`path` takes a `line1`/`line2`/`line3`/`hidden` placement, a `*When` visibility of `always`/`active`/`alert`, and where it applies a `*Style` spelling; the Settings › Status line subpage edits these with a live preview, and the older `appearance.showHarnessBranch`/`showHarnessPath` booleans are read only when this section is absent), `appearance` (Agents-sidebar layout — `sidebarGrouping` of `host`/`path`/`harness`/`none` picks what the sidebar's section headers are, `sidebarSort` of `created`/`recent`/`name` orders the agents in a section and the sessions under an agent, alongside the resource-indicator keys; the Settings › Appearance subpage edits these live), and `medulla.contextWindowTokens` (Context tab usage hint; the orchestration limits section also carries pass/step/depth/task/token bounds). Inference and tracing are server-side concerns — the TUI has no config for them; unknown sections are ignored. There is no `memory` section: the persona-memory layer is out of this build, and its config schema went with it. diff --git a/src/sdk/src/config/appearance/mod.rs b/src/sdk/src/config/appearance/mod.rs index e4a8f6df8..54925b7da 100644 --- a/src/sdk/src/config/appearance/mod.rs +++ b/src/sdk/src/config/appearance/mod.rs @@ -1,5 +1,6 @@ -//! Configuration for local-process resource indicators in the TUI chrome. +//! Configuration for the TUI's chrome: resource indicators, and how the Agents +//! sidebar groups and orders its rows. mod types; -pub use types::{AppearanceConfig, ResourceDisplay}; +pub use types::{AppearanceConfig, ResourceDisplay, SidebarGrouping, SidebarSort}; diff --git a/src/sdk/src/config/appearance/types.rs b/src/sdk/src/config/appearance/types.rs index c0e8b6c39..746c79b3f 100644 --- a/src/sdk/src/config/appearance/types.rs +++ b/src/sdk/src/config/appearance/types.rs @@ -17,6 +17,75 @@ pub enum ResourceDisplay { Bar, } +/// How the Agents sidebar sections its agent rows. +/// +/// The sidebar is a `Host → Agent → Session` tree; this chooses what the top +/// level is. Only the *sectioning* changes — every agent keeps its own sessions +/// under it — so no row disappears whichever way it is set. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum SidebarGrouping { + /// Section by host, and only once a second host exists. The default: with + /// one machine the header would say nothing the operator does not know. + #[default] + Host, + /// Section by the directory an agent works in, so one checkout's agents read + /// together however many hosts or harnesses they span. + Path, + /// Section by the harness an agent runs (`claude`, `codex`, …). + Harness, + /// No section headers at all — one flat list of agents. + None, +} + +impl SidebarGrouping { + /// The label shown in Settings → Appearance. + pub const fn label(self) -> &'static str { + match self { + Self::Host => "host", + Self::Path => "path", + Self::Harness => "harness", + Self::None => "none", + } + } + + /// Every value, in the order the settings row cycles through them. + pub const ALL: [Self; 4] = [Self::Host, Self::Path, Self::Harness, Self::None]; +} + +/// How the Agents sidebar orders the rows inside one section. +/// +/// Applied at both levels the operator reads: the agents in a section, and the +/// sessions under an agent. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum SidebarSort { + /// Declaration order for agents, start time for sessions — oldest first. + /// The default, because it is the only order that does not move under you + /// while you read it. + #[default] + Created, + /// Most recently active first: the session that last produced output, and + /// the agent whose most recent session did. + Recent, + /// Alphabetical by label. + Name, +} + +impl SidebarSort { + /// The label shown in Settings → Appearance. + pub const fn label(self) -> &'static str { + match self { + Self::Created => "created", + Self::Recent => "recent", + Self::Name => "name", + } + } + + /// Every value, in the order the settings row cycles through them. + pub const ALL: [Self; 3] = [Self::Created, Self::Recent, Self::Name]; +} + /// TUI display preferences retained under the `[appearance]` section. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default, rename_all = "camelCase")] @@ -39,6 +108,10 @@ pub struct AppearanceConfig { pub device_ram: ResourceDisplay, /// How to show whole-device disk-capacity pressure in the Agents sidebar. pub device_disk: ResourceDisplay, + /// What the Agents sidebar sections its agents by. + pub sidebar_grouping: SidebarGrouping, + /// How the Agents sidebar orders agents and the sessions under them. + pub sidebar_sort: SidebarSort, } impl AppearanceConfig { @@ -54,6 +127,8 @@ impl AppearanceConfig { device_cpu: ResourceDisplay::Off, device_ram: ResourceDisplay::Off, device_disk: ResourceDisplay::Off, + sidebar_grouping: SidebarGrouping::Host, + sidebar_sort: SidebarSort::Created, } } } diff --git a/src/sdk/src/config/mod.rs b/src/sdk/src/config/mod.rs index 134baab2e..d88675d3e 100644 --- a/src/sdk/src/config/mod.rs +++ b/src/sdk/src/config/mod.rs @@ -40,7 +40,7 @@ pub use agent_declarations::{ agent_declaration, agent_declarations_for_host, declare_agent, declared_agent_ids, load_agent_declarations, remove_agent_declaration, undeclare_agent, upsert_agent_declaration, }; -pub use appearance::{AppearanceConfig, ResourceDisplay}; +pub use appearance::{AppearanceConfig, ResourceDisplay, SidebarGrouping, SidebarSort}; pub use core_socket::{validate_core_socket, CoreSocketError, CoreSocketSource}; pub use custom_harnesses::{ load_custom_harnesses, load_layered_custom_harnesses, CustomHarnessConfig, diff --git a/src/sdk/src/config/types_tests.rs b/src/sdk/src/config/types_tests.rs index 1d2e405ed..123037412 100644 --- a/src/sdk/src/config/types_tests.rs +++ b/src/sdk/src/config/types_tests.rs @@ -59,6 +59,30 @@ fn appearance_session_titles_can_be_hidden() { assert!(!cfg.appearance.show_session_titles); } +#[test] +fn sidebar_layout_defaults_to_the_tree_in_declaration_order() { + let cfg: TuiConfig = serde_json::from_str("{}").unwrap(); + + assert_eq!( + cfg.appearance.sidebar_grouping, + super::SidebarGrouping::Host + ); + assert_eq!(cfg.appearance.sidebar_sort, super::SidebarSort::Created); +} + +#[test] +fn sidebar_layout_parses_the_operators_choice() { + let cfg: TuiConfig = + serde_json::from_str(r#"{"appearance":{"sidebarGrouping":"path","sidebarSort":"recent"}}"#) + .unwrap(); + + assert_eq!( + cfg.appearance.sidebar_grouping, + super::SidebarGrouping::Path + ); + assert_eq!(cfg.appearance.sidebar_sort, super::SidebarSort::Recent); +} + #[test] fn appearance_harness_details_parse_independently() { let cfg: TuiConfig = serde_json::from_str( diff --git a/src/tui/src/ui/app/appearance.rs b/src/tui/src/ui/app/appearance.rs index dac83186f..d49473181 100644 --- a/src/tui/src/ui/app/appearance.rs +++ b/src/tui/src/ui/app/appearance.rs @@ -16,8 +16,15 @@ use crate::ui::theme::{color_to_string, THEME_ROLES}; use super::types::App; -/// Non-theme rows: session titles, then process and device resource indicators. -pub(super) const APPEARANCE_OPTION_ROWS: usize = 7; +/// Non-theme rows: session titles, the process and device resource indicators, +/// and the two Agents-sidebar layout controls. +pub(super) const APPEARANCE_OPTION_ROWS: usize = 9; + +/// The option offset of the sidebar grouping row, past the resource indicators. +pub(super) const SIDEBAR_GROUPING_OPTION: usize = 7; + +/// The option offset of the sidebar sort row. +pub(super) const SIDEBAR_SORT_OPTION: usize = 8; /// Number of behavior controls shown after the editable theme colors. pub(super) const ATTENTION_ROWS: usize = 1; @@ -26,6 +33,20 @@ pub(super) const ATTENTION_ROWS: usize = 1; pub(super) const APPEARANCE_ROWS: usize = THEME_ROLES.len() + ATTENTION_ROWS + APPEARANCE_OPTION_ROWS; +/// The next value after `current` in `choices`, wrapping in either direction. +/// +/// A value that is not in `choices` — a config hand-edited to something the +/// build does not know — starts the cycle from the first entry rather than +/// refusing to move, so the row is never stuck. +fn cycled(choices: &[T], current: T, forward: bool) -> T { + let at = choices.iter().position(|choice| *choice == current); + match at { + Some(at) if forward => choices[(at + 1) % choices.len()], + Some(at) => choices[(at + choices.len() - 1) % choices.len()], + None => choices[0], + } +} + impl App { /// Cycle the selected colour role and persist the theme. pub(super) fn cycle_appearance_row(&mut self, forward: bool) { @@ -43,11 +64,14 @@ impl App { self.persist_theme_value_now("Attention blink", value.into()); } else { let option = index - THEME_ROLES.len() - ATTENTION_ROWS; - if option == 3 { - self.toggle_session_titles(); - } else { - let resource = if option < 3 { option } else { option - 1 }; - self.cycle_resource_display(resource, forward); + match option { + 3 => self.toggle_session_titles(), + SIDEBAR_GROUPING_OPTION => self.cycle_sidebar_grouping(forward), + SIDEBAR_SORT_OPTION => self.cycle_sidebar_sort(forward), + _ => { + let resource = if option < 3 { option } else { option - 1 }; + self.cycle_resource_display(resource, forward); + } } } } @@ -60,6 +84,26 @@ impl App { self.persist_appearance_now("Session titles", if shown { "on" } else { "off" }.into()); } + /// Cycle and persist what the Agents sidebar sections its agents by. + /// + /// Takes effect on the next frame without any rebuild: the rail is assembled + /// from the loaded config every time it is drawn, so the operator sees the + /// arrangement they picked while the settings row is still under the cursor. + fn cycle_sidebar_grouping(&mut self, forward: bool) { + let current = self.loaded.config.appearance.sidebar_grouping; + let next = cycled(&medulla::config::SidebarGrouping::ALL, current, forward); + self.loaded.config.appearance.sidebar_grouping = next; + self.persist_appearance_now("Sidebar grouping", next.label().into()); + } + + /// Cycle and persist how the Agents sidebar orders agents and sessions. + fn cycle_sidebar_sort(&mut self, forward: bool) { + let current = self.loaded.config.appearance.sidebar_sort; + let next = cycled(&medulla::config::SidebarSort::ALL, current, forward); + self.loaded.config.appearance.sidebar_sort = next; + self.persist_appearance_now("Sidebar sort", next.label().into()); + } + /// Cycle and persist one resource indicator, process-scoped or device-wide. /// /// `index` is the row's offset past the theme roles, in the order the @@ -153,6 +197,16 @@ impl App { "showHarnessPath".into(), toml::Value::Boolean(self.loaded.config.appearance.show_harness_path), ); + section.insert( + "sidebarGrouping".into(), + toml::Value::String( + format!("{:?}", appearance.sidebar_grouping).to_lowercase(), + ), + ); + section.insert( + "sidebarSort".into(), + toml::Value::String(format!("{:?}", appearance.sidebar_sort).to_lowercase()), + ); match medulla::config::persist_section(path, "appearance", section) { Ok(()) => self.set_status(format!("Appearance · {name} → {value} (saved)")), Err(error) => self.set_status(format!("Appearance save failed: {error}")), diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index a77a2019a..fb9d8915f 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -42,6 +42,9 @@ use crate::worker::pty::SessionRow; mod cleanup; #[cfg(test)] mod cleanup_tests; +mod organize; +#[cfg(test)] +mod organize_tests; pub(in crate::ui::app) mod resolve; // Kept apart from `tests` rather than nested inside it: the assembly rules and // the served-dispatch merge are separate responsibilities, and one file for @@ -52,7 +55,9 @@ mod merge_tests; pub(in crate::ui::app) mod tests; mod types; -pub use types::{AgentRailRow, HostRailRow, RailRow, SessionRailRow, WorkflowRunRailRow}; +pub use types::{ + AgentRailRow, GroupRailRow, HostRailRow, RailRow, SessionRailRow, WorkflowRunRailRow, +}; /// The label on the rail's "declare an agent" row. /// @@ -139,8 +144,12 @@ impl App { let lanes = self.lanes(); let (lane_rows, folded) = self.split_fold(&lanes); let mut hosts = place_agents(&self.host_tree(), folded); - let orphans = self.attach_sessions(&mut hosts); - self.flatten(lane_rows, hosts, orphans) + let mut orphans = self.attach_sessions(&mut hosts); + let appearance = &self.loaded.config.appearance; + let sections = + organize::organize(hosts, appearance.sidebar_grouping, appearance.sidebar_sort); + organize::sort_sessions(&mut orphans, appearance.sidebar_sort); + self.flatten(lane_rows, sections, orphans) } /// Split the folded rows into the non-agent ones and the per-agent groups. @@ -288,7 +297,7 @@ impl App { fn flatten( &self, lane_rows: Vec, - hosts: Vec, + sections: Vec, orphans: Vec, ) -> Vec { let mut rows: Vec = lane_rows.into_iter().map(RailRow::Lane).collect(); @@ -301,7 +310,7 @@ impl App { // Only over a tree that exists — see [`RailRow::AgentsHeader`]. Counted // from the groups rather than from `rows`, because the host rows that // wrap them have not been pushed yet. - if hosts.iter().any(|host| !host.agents.is_empty()) { + if sections.iter().any(|section| !section.agents.is_empty()) { rows.push(RailRow::AgentsHeader); } // Which agents this machine may open a session under: a session is @@ -315,15 +324,16 @@ impl App { } else { Vec::new() }; - // Progressive disclosure: one host is the common case, and a permanent - // `mac-studio ▸` wrapper would add a level of nesting to the surface an - // operator uses most. - let show_hosts = hosts.len() > 1; - for mut host in hosts { - if show_hosts { - rows.push(RailRow::Host(host.row)); + // Which sections exist, in which order, and whether they are headed at + // all is [`organize`]'s answer, not this one's: it is the operator's + // Appearance setting, and the assembly here is about what exists. + for mut section in sections { + match section.header { + organize::SectionHeader::Host(host) => rows.push(RailRow::Host(host)), + organize::SectionHeader::Group(group) => rows.push(RailRow::Group(group)), + organize::SectionHeader::None => {} } - for group in &mut host.agents { + for group in &mut section.agents { let offers_session = declared .iter() .any(|agent_id| agent_id.trim() == group.row.agent_id.trim()); diff --git a/src/tui/src/ui/app/rail/organize.rs b/src/tui/src/ui/app/rail/organize.rs new file mode 100644 index 000000000..a24899ded --- /dev/null +++ b/src/tui/src/ui/app/rail/organize.rs @@ -0,0 +1,241 @@ +//! How the rail's tree is sectioned and ordered before it is flattened. +//! +//! The assembly in [`super`] decides *what exists*: which agents, on which +//! hosts, with which sessions under them. This module decides how that is +//! presented — the two questions an operator with more than a handful of agents +//! actually asks, and the two the Appearance page now exposes: +//! +//! * **Grouping** picks the top level. By host (the default, and drawn only once +//! a second host exists), by the directory an agent works in, by the harness it +//! runs, or not at all. Only the *headers* change: every agent keeps its own +//! sessions, so nothing that had a row loses one whichever way it is set. +//! * **Sorting** orders the rows inside a section, at both levels the eye reads: +//! the agents in a section, and the sessions under an agent. +//! +//! Kept apart from the assembly because they are genuinely separate concerns — +//! the placement rules answer to the roster and the PTY manager, these answer to +//! one config value each — and because the assembly file was already at this +//! repository's size ceiling. + +use medulla::config::{SidebarGrouping, SidebarSort}; + +use super::{AgentGroup, GroupRailRow, HostGroup, HostRailRow, SessionRailRow}; + +/// What heads one section of the rail. +pub(super) enum SectionHeader { + /// A host row — emitted only when a second host exists to tell it from. + Host(HostRailRow), + /// A grouping header: a workspace directory, or a harness name. + Group(GroupRailRow), + /// No header at all: the agents sit at the top level. + None, +} + +/// One section of the rail: a header and the agents under it. +pub(super) struct Section { + /// What heads it, if anything. + pub header: SectionHeader, + /// The agents in it, already ordered. + pub agents: Vec, +} + +/// Section and order the placed tree according to the operator's preferences. +/// +/// Host grouping keeps the tree as placed; every other grouping flattens the +/// hosts away first, because an agent's directory or harness is a fact about the +/// agent rather than about the machine, and keeping both levels would mean +/// reading the same checkout twice under two hosts. +pub(super) fn organize( + hosts: Vec, + grouping: SidebarGrouping, + sort: SidebarSort, +) -> Vec
{ + let mut sections = match grouping { + SidebarGrouping::Host => by_host(hosts), + SidebarGrouping::Path => by_key(hosts, agent_path), + SidebarGrouping::Harness => by_key(hosts, agent_harness), + SidebarGrouping::None => vec![Section { + header: SectionHeader::None, + agents: hosts.into_iter().flat_map(|host| host.agents).collect(), + }], + }; + for section in &mut sections { + sort_agents(&mut section.agents, sort); + for agent in &mut section.agents { + sort_sessions(&mut agent.sessions, sort); + } + } + order_sections(&mut sections, sort); + sections +} + +/// Keep the placed tree, dropping the header when there is only one host. +/// +/// Progressive disclosure, unchanged from before grouping was configurable: with +/// just the local machine a permanent `mac-studio ▸` wrapper would add a level of +/// nesting to the surface an operator uses most. +fn by_host(hosts: Vec) -> Vec
{ + let show = hosts.len() > 1; + hosts + .into_iter() + .map(|host| Section { + header: if show { + SectionHeader::Host(host.row) + } else { + SectionHeader::None + }, + agents: host.agents, + }) + .collect() +} + +/// Section every agent by one derived key, preserving first-seen order. +fn by_key(hosts: Vec, key: fn(&AgentGroup) -> String) -> Vec
{ + let mut sections: Vec
= Vec::new(); + for agent in hosts.into_iter().flat_map(|host| host.agents) { + let label = key(&agent); + match sections.iter_mut().find(|section| match §ion.header { + SectionHeader::Group(group) => group.label == label, + _ => false, + }) { + Some(section) => section.agents.push(agent), + None => sections.push(Section { + header: SectionHeader::Group(GroupRailRow { + label: label.clone(), + }), + agents: vec![agent], + }), + } + } + sections +} + +/// The directory an agent works in, as its section label. +/// +/// An agent the projection knows nothing about — a lane the backend rosters, +/// a peer session — has no directory, and is collected under one heading rather +/// than given a section of its own per agent. +fn agent_path(agent: &AgentGroup) -> String { + agent + .row + .workspace() + .map(str::trim) + .filter(|workspace| !workspace.is_empty()) + .map_or_else(|| "no path".to_string(), str::to_string) +} + +/// The harness an agent runs, as its section label. +fn agent_harness(agent: &AgentGroup) -> String { + agent + .row + .harness() + .map(str::trim) + .filter(|harness| !harness.is_empty()) + .map_or_else(|| "no harness".to_string(), str::to_string) +} + +/// Order the sections themselves. +/// +/// Alphabetical for the orders that are about identity, most-recently-active +/// first for the one that is about activity. Host sections are left in the +/// projection's order whatever the sort: that order is the tree's, shared with +/// the Hosts tab, and the two lenses are meant to list hosts the same way. +fn order_sections(sections: &mut [Section], sort: SidebarSort) { + if sections + .iter() + .any(|section| !matches!(section.header, SectionHeader::Group(_))) + { + return; + } + match sort { + SidebarSort::Recent => sections.sort_by_key(|section| { + std::cmp::Reverse( + section + .agents + .iter() + .map(agent_activity) + .max() + .unwrap_or(i64::MIN), + ) + }), + SidebarSort::Created | SidebarSort::Name => { + sections.sort_by_key(|section| match §ion.header { + SectionHeader::Group(group) => group.label.to_lowercase(), + _ => String::new(), + }) + } + } +} + +/// Order the agents inside one section. +/// +/// `Created` leaves the projection's order alone — declaration order, which is +/// what the operator wrote — because an agent has no creation timestamp to sort +/// by and re-ordering it would only shuffle the list without answering anything. +fn sort_agents(agents: &mut [AgentGroup], sort: SidebarSort) { + match sort { + SidebarSort::Created => {} + SidebarSort::Recent => { + agents.sort_by_key(|agent| std::cmp::Reverse(agent_activity(agent))); + } + SidebarSort::Name => agents.sort_by_key(|agent| agent.row.label().to_lowercase()), + } +} + +/// Order the sessions under one agent. +/// +/// A dispatched session with no live pty carries no start time, so `Created` +/// sorts it to the front and the stable sort leaves those in the order the fold +/// produced them — which is the order the rail listed them in before grouping +/// existed, so the default arrangement is unchanged. +pub(super) fn sort_sessions(sessions: &mut [SessionRailRow], sort: SidebarSort) { + match sort { + SidebarSort::Created => sessions.sort_by_key(session_started), + SidebarSort::Recent => { + sessions.sort_by_key(|session| std::cmp::Reverse(session_activity(session))); + } + SidebarSort::Name => sessions.sort_by_key(|session| session_label(session).to_lowercase()), + } +} + +/// When a session started, or [`i64::MIN`] when only a dispatch describes it. +fn session_started(session: &SessionRailRow) -> i64 { + session + .local + .as_ref() + .map_or(i64::MIN, |local| local.started_at) +} + +/// The most recent thing known about a session: its last output byte, or the +/// last event its task folded. +fn session_activity(session: &SessionRailRow) -> i64 { + let local = session.local.as_ref().map(|local| local.last_output_at); + let task = session.task.as_ref().map(|task| task.last_at); + local.into_iter().chain(task).max().unwrap_or(i64::MIN) +} + +/// What a session sorts as by name: what the operator called it, else its task, +/// else the pty's own label. +fn session_label(session: &SessionRailRow) -> String { + if let Some(name) = session.name() { + return name.to_string(); + } + if let Some(task) = &session.task { + return task.task_id.clone(); + } + session + .local + .as_ref() + .map(|local| local.label.clone()) + .unwrap_or_default() +} + +/// The most recent activity under an agent, for the activity orders. +fn agent_activity(agent: &AgentGroup) -> i64 { + agent + .sessions + .iter() + .map(session_activity) + .max() + .unwrap_or(i64::MIN) +} diff --git a/src/tui/src/ui/app/rail/organize_tests.rs b/src/tui/src/ui/app/rail/organize_tests.rs new file mode 100644 index 000000000..85154ca0e --- /dev/null +++ b/src/tui/src/ui/app/rail/organize_tests.rs @@ -0,0 +1,217 @@ +//! How the sidebar's grouping and sorting preferences change what the rail +//! lists — and, as much to the point, what they must not change: every agent +//! keeps a row and every session stays under its own agent whichever way the +//! two settings are turned. + +use medulla::config::{SidebarGrouping, SidebarSort}; +use medulla::runtime::AgentDeclaration; + +use super::organize::sort_sessions; +use super::tests::{app, stub_session}; +use super::{RailRow, SessionRailRow}; +use crate::ui::app::App; + +/// Three agents across two checkouts and two harnesses, in declaration order. +fn app_with_agents() -> App { + let mut app = app(); + app.loaded.config.fleet.agent_declarations = vec![ + AgentDeclaration::new("zed", "", "claude", "/work/beta"), + AgentDeclaration::new("acorn", "", "codex", "/work/alpha"), + AgentDeclaration::new("mint", "", "claude", "/work/alpha"), + ]; + app +} + +/// The rail's sections: each header label (`None` for the unheaded top level) +/// with the agents listed under it, restricted to the declared agents so the +/// mock runtime's own folded lanes cannot make an assertion flaky. +fn sections(app: &App) -> Vec<(Option, Vec)> { + let declared: Vec = app + .loaded + .config + .fleet + .agent_declarations + .iter() + .map(|declaration| declaration.agent_id.clone()) + .collect(); + let mut sections: Vec<(Option, Vec)> = vec![(None, Vec::new())]; + for row in app.rail_rows() { + match row { + RailRow::Host(host) => sections.push((Some(host.label), Vec::new())), + RailRow::Group(group) => sections.push((Some(group.label), Vec::new())), + RailRow::Agent(agent) if declared.contains(&agent.agent_id) => { + sections + .last_mut() + .expect("a section is always open") + .1 + .push(agent.agent_id); + } + _ => {} + } + } + // The mock runtime folds lanes of its own, which section themselves under + // "no path"/"no harness"; those sections hold no declared agent and are + // dropped so an assertion is about what the test declared. + sections.retain(|(_, agents)| !agents.is_empty()); + sections +} + +#[test] +fn the_default_leaves_one_machines_tree_unsectioned() { + // The setting exists to be changed, not to change what an operator who has + // never opened it sees: with one host and the default grouping the rail is + // the flat list it was before grouping was configurable, in declaration + // order. + let app = app_with_agents(); + assert_eq!( + app.loaded.config.appearance.sidebar_grouping, + SidebarGrouping::Host + ); + assert_eq!( + sections(&app), + vec![(None, vec!["zed".into(), "acorn".into(), "mint".into()])] + ); +} + +#[test] +fn grouping_by_path_heads_each_checkout_once() { + let mut app = app_with_agents(); + app.loaded.config.appearance.sidebar_grouping = SidebarGrouping::Path; + + assert_eq!( + sections(&app), + vec![ + ( + Some("/work/alpha".into()), + vec!["acorn".into(), "mint".into()] + ), + (Some("/work/beta".into()), vec!["zed".into()]), + ], + "one header per directory, alphabetical, with its agents under it" + ); +} + +#[test] +fn grouping_by_harness_sections_by_the_cli_each_agent_runs() { + let mut app = app_with_agents(); + app.loaded.config.appearance.sidebar_grouping = SidebarGrouping::Harness; + + let sections = sections(&app); + let claude = sections + .iter() + .find(|(label, _)| label.as_deref() == Some("claude")) + .expect("the claude agents are sectioned together"); + assert_eq!(claude.1, vec!["zed".to_string(), "mint".to_string()]); + assert!(sections + .iter() + .any(|(label, agents)| label.as_deref() == Some("codex") && agents == &["acorn"])); +} + +#[test] +fn grouping_by_none_lists_every_agent_without_a_header() { + let mut app = app_with_agents(); + app.loaded.config.appearance.sidebar_grouping = SidebarGrouping::None; + + assert_eq!( + sections(&app), + vec![(None, vec!["zed".into(), "acorn".into(), "mint".into()])] + ); +} + +#[test] +fn no_grouping_loses_an_agent() { + // The one property that has to hold across all four: sectioning is a + // presentation of the same tree, so the set of agents on the rail is the + // same set however it is grouped. + let mut app = app_with_agents(); + let mut seen: Vec> = Vec::new(); + for grouping in SidebarGrouping::ALL { + app.loaded.config.appearance.sidebar_grouping = grouping; + let mut agents: Vec = sections(&app) + .into_iter() + .flat_map(|(_, agents)| agents) + .collect(); + agents.sort(); + seen.push(agents); + } + assert!( + seen.windows(2).all(|pair| pair[0] == pair[1]), + "every grouping lists the same agents: {seen:?}" + ); +} + +#[test] +fn sorting_by_name_is_alphabetical_within_a_section() { + let mut app = app_with_agents(); + app.loaded.config.appearance.sidebar_sort = SidebarSort::Name; + + assert_eq!( + sections(&app), + vec![(None, vec!["acorn".into(), "mint".into(), "zed".into()])] + ); +} + +/// A session row for a live pty with the given start and last-output times. +fn session(id: &str, started_at: i64, last_output_at: i64) -> SessionRailRow { + let mut local = stub_session(id); + local.started_at = started_at; + local.last_output_at = last_output_at; + local.name = Some(id.to_string()); + SessionRailRow { + agent_id: Some("agent".into()), + lane_index: None, + task: None, + local: Some(local), + last: false, + } +} + +/// The ids of `sessions` after sorting, for the session-order assertions. +fn sorted(mut sessions: Vec, sort: SidebarSort) -> Vec { + sort_sessions(&mut sessions, sort); + sessions + .into_iter() + .filter_map(|session| session.session_id().map(str::to_string)) + .collect() +} + +#[test] +fn created_sorts_sessions_oldest_first() { + let rows = vec![ + session("middle", 200, 500), + session("oldest", 100, 900), + session("newest", 300, 100), + ]; + assert_eq!( + sorted(rows, SidebarSort::Created), + vec!["oldest", "middle", "newest"], + "the default is the order a session list grows in" + ); +} + +#[test] +fn recent_sorts_by_the_last_thing_a_session_did() { + let rows = vec![ + session("quiet", 300, 100), + session("loud", 100, 900), + session("middling", 200, 500), + ]; + assert_eq!( + sorted(rows, SidebarSort::Recent), + vec!["loud", "middling", "quiet"], + "most recent output first, whatever order they started in" + ); +} + +#[test] +fn name_sorts_sessions_by_what_the_operator_called_them() { + let rows = vec![ + session("zulu", 100, 100), + session("alpha", 200, 200), + session("mike", 300, 300), + ]; + assert_eq!( + sorted(rows, SidebarSort::Name), + vec!["alpha", "mike", "zulu"] + ); +} diff --git a/src/tui/src/ui/app/rail/tests.rs b/src/tui/src/ui/app/rail/tests.rs index 0c9ebad19..95f3ad74b 100644 --- a/src/tui/src/ui/app/rail/tests.rs +++ b/src/tui/src/ui/app/rail/tests.rs @@ -446,7 +446,7 @@ fn a_row_answers_for_the_agent_and_the_lane_behind_it() { } // Hosts, the heading and the create action are about no agent and // no lane. - RailRow::Host(_) | RailRow::NewAgent | RailRow::AgentsHeader => { + RailRow::Host(_) | RailRow::Group(_) | RailRow::NewAgent | RailRow::AgentsHeader => { assert_eq!(row.agent_id(), None); assert_eq!(row.lane_index(), None); assert_eq!(row.session_id(), None); @@ -483,7 +483,9 @@ fn only_the_rows_that_name_something_take_the_cursor() { )]; for row in app.rail_rows() { match row { - RailRow::Host(_) => assert!(!row.selectable(), "a host header is a label"), + RailRow::Host(_) | RailRow::Group(_) => { + assert!(!row.selectable(), "a section header is a label") + } RailRow::AgentsHeader => { assert!(!row.selectable(), "the agents heading is a label") } diff --git a/src/tui/src/ui/app/rail/types.rs b/src/tui/src/ui/app/rail/types.rs index 91bebae4a..5dd7b3208 100644 --- a/src/tui/src/ui/app/rail/types.rs +++ b/src/tui/src/ui/app/rail/types.rs @@ -35,6 +35,20 @@ pub struct HostRailRow { pub local: bool, } +/// One grouping header, when the sidebar is sectioned by something other than +/// its hosts. +/// +/// The rail's default sectioning is the tree's own — `Host → Agent → Session` — +/// and [`HostRailRow`] heads it. An operator who groups by path or by harness +/// asked for a different top level, so the header is a different row rather than +/// a host row carrying a directory in its label: nothing downstream should have +/// to guess whether `▸ ~/work/medulla` names a machine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GroupRailRow { + /// What the section is: a workspace directory, or a harness name. + pub label: String, +} + /// One agent in the tree — `harness × workspace` on a host. /// /// Sourced from the shared `Host → Agent` projection @@ -153,6 +167,8 @@ pub struct WorkflowRunRailRow { pub enum RailRow { /// A host header, emitted only once a remote host exists. Host(HostRailRow), + /// A grouping header, when the sidebar is sectioned by path or by harness. + Group(GroupRailRow), /// A declared (or folded) agent. Agent(AgentRailRow), /// One session of the agent above it. @@ -212,6 +228,8 @@ impl RailRow { pub fn selectable(&self) -> bool { match self { RailRow::Host(_) => false, + // A section label, like the host header it stands in for. + RailRow::Group(_) => false, RailRow::Agent(_) => true, RailRow::Session(_) => true, RailRow::NewAgent => true, @@ -260,6 +278,7 @@ impl RailRow { RailRow::Session(row) => row.lane_index, RailRow::Lane(row) => row.lane_index(), RailRow::Host(_) + | RailRow::Group(_) | RailRow::NewAgent | RailRow::AgentsHeader | RailRow::NewSession { .. } diff --git a/src/tui/src/ui/app/render/agents/rail/mod.rs b/src/tui/src/ui/app/render/agents/rail/mod.rs index 39b5faa17..eced5e2cc 100644 --- a/src/tui/src/ui/app/render/agents/rail/mod.rs +++ b/src/tui/src/ui/app/render/agents/rail/mod.rs @@ -339,6 +339,18 @@ impl App { .fg(color("blue")) .add_modifier(Modifier::BOLD), )), + // Shaped like the host header it stands in for, so a rail grouped by + // path or harness reads the same way as one grouped by host — the + // heading is the same kind of thing, only the question differs. The + // label is shortened from the left because a workspace's identity is + // its tail: two checkouts of one repository differ in the last + // segment and agree on the first forty characters. + RailRow::Group(group) => TLine::from(Span::styled( + format!("▸ {}", crate::ui::util::clip_left(&group.label, 30)), + Style::default() + .fg(color("blue")) + .add_modifier(Modifier::BOLD), + )), RailRow::Agent(agent) => { self.declared_agent_line(agent, lanes, active, waiting_sessions) } diff --git a/src/tui/src/ui/app/render/agents/summary.rs b/src/tui/src/ui/app/render/agents/summary.rs index 8b6fac821..16334810d 100644 --- a/src/tui/src/ui/app/render/agents/summary.rs +++ b/src/tui/src/ui/app/render/agents/summary.rs @@ -205,7 +205,7 @@ fn sessions_under(selection: &Selection) -> usize { .take_while(|row| { !matches!( row, - RailRow::Agent(_) | RailRow::Host(_) | RailRow::NewAgent + RailRow::Agent(_) | RailRow::Host(_) | RailRow::Group(_) | RailRow::NewAgent ) }) .filter(|row| matches!(row, RailRow::Session(_))) @@ -218,7 +218,7 @@ fn agents_under(selection: &Selection) -> usize { .rows .iter() .skip(selection.active + 1) - .take_while(|row| !matches!(row, RailRow::Host(_))) + .take_while(|row| !matches!(row, RailRow::Host(_) | RailRow::Group(_))) .filter(|row| matches!(row, RailRow::Agent(_))) .count() } diff --git a/src/tui/src/ui/app/render/settings/appearance_usage.rs b/src/tui/src/ui/app/render/settings/appearance_usage.rs index 644bf8c67..487cbf005 100644 --- a/src/tui/src/ui/app/render/settings/appearance_usage.rs +++ b/src/tui/src/ui/app/render/settings/appearance_usage.rs @@ -11,7 +11,7 @@ use crate::ui::stream; use crate::ui::theme::{color_to_string, THEME_ROLES}; use crate::ui::util::clip; -use super::super::super::appearance::{APPEARANCE_ROWS, ATTENTION_ROWS}; +use super::super::super::appearance::{APPEARANCE_ROWS, ATTENTION_ROWS, SIDEBAR_GROUPING_OPTION}; use super::super::super::types::App; impl App { @@ -173,6 +173,49 @@ impl App { ))); } lines.push(TLine::from("")); + lines.push(TLine::from(Span::styled("Agents sidebar", heading))); + lines.push(TLine::from(Span::styled( + " How the sidebar sections its agents, and what order rows come in.", + description, + ))); + lines.push(TLine::from("")); + for (offset, (label, value, hint)) in [ + ( + "Group by", + self.loaded.config.appearance.sidebar_grouping.label(), + "host · path · harness · none", + ), + ( + "Sort by", + self.loaded.config.appearance.sidebar_sort.label(), + "created · recent · name", + ), + ] + .into_iter() + .enumerate() + { + let index = THEME_ROLES.len() + ATTENTION_ROWS + SIDEBAR_GROUPING_OPTION + offset; + if index == sel { + selected_line_index = lines.len(); + } + let style = if index == sel { + self.theme.selection() + } else { + Style::default() + }; + let marker = if index == sel { " ▸ " } else { " " }; + lines.push(TLine::from(vec![ + Span::styled(format!("{marker}{label:<20} {value:<10}"), style), + // The other rows on this page cycle between two or three states + // an operator can guess at; these cycle between four named ones, + // and a row reading only "path" gives no clue what else there is. + Span::styled( + hint.to_string(), + Style::default().add_modifier(Modifier::DIM), + ), + ])); + } + lines.push(TLine::from("")); lines.push(TLine::from(Span::styled( "j/k select · ←/→ or Enter change · applies live", Style::default().add_modifier(Modifier::DIM), diff --git a/src/tui/tests/feature_settings.rs b/src/tui/tests/feature_settings.rs index ecdc7af0f..b2c0c667c 100644 --- a/src/tui/tests/feature_settings.rs +++ b/src/tui/tests/feature_settings.rs @@ -212,6 +212,58 @@ fn appearance_cycles_and_persists_process_indicators() { assert!(saved.contains("diskIo = \"off\""), "{saved}"); } +#[test] +fn appearance_cycles_and_persists_the_sidebar_layout() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let mut app = settings_app(); + app.set_config_path(path.clone()); + let _ = key(&mut app, KeyCode::Char('2')); + // Five color rows, the blink toggle, and the seven option rows before it. + for _ in 0..13 { + let _ = key(&mut app, KeyCode::Char('j')); + } + let _ = key(&mut app, KeyCode::Right); + + let out = text_of(&draw(&mut app, 180, 60)); + assert!(out.contains("Group by path"), "{out}"); + assert!( + app.status().contains("Sidebar grouping \u{2192} path"), + "status names the setting and its new value: {}", + app.status() + ); + let saved = std::fs::read_to_string(&path).unwrap(); + assert!(saved.contains("sidebarGrouping = \"path\""), "{saved}"); + + // The sort row is the next one down, and cycles independently. + let _ = key(&mut app, KeyCode::Char('j')); + let _ = key(&mut app, KeyCode::Right); + + let out = text_of(&draw(&mut app, 180, 60)); + assert!(out.contains("Sort by recent"), "{out}"); + let saved = std::fs::read_to_string(&path).unwrap(); + assert!(saved.contains("sidebarSort = \"recent\""), "{saved}"); + assert!( + saved.contains("sidebarGrouping = \"path\""), + "the grouping survives the next write: {saved}" + ); +} + +#[test] +fn appearance_sidebar_grouping_wraps_backwards() { + // Left from the default is the last value, not a stuck row: the cycle is + // how every other control on this page behaves. + let mut app = settings_app(); + let _ = key(&mut app, KeyCode::Char('2')); + for _ in 0..13 { + let _ = key(&mut app, KeyCode::Char('j')); + } + let _ = key(&mut app, KeyCode::Left); + + let out = text_of(&draw(&mut app, 180, 60)); + assert!(out.contains("Group by none"), "{out}"); +} + #[test] fn appearance_persists_process_indicators_to_json() { let dir = tempfile::tempdir().unwrap(); From d8489551ee1c5b92a544e01c836202b1c6671c5f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 19:30:17 +0300 Subject: [PATCH 02/29] fix(rail): preserve grouping headers in cursor resolution Co-authored-by: Medulla --- src/tui/src/ui/app/rail/cursor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/src/ui/app/rail/cursor.rs b/src/tui/src/ui/app/rail/cursor.rs index 35cf1b834..64f407b43 100644 --- a/src/tui/src/ui/app/rail/cursor.rs +++ b/src/tui/src/ui/app/rail/cursor.rs @@ -46,7 +46,7 @@ pub(in crate::ui::app) fn rail_anchor(row: &RailRow, lanes: &[AgentLane]) -> Opt RailRow::Lane(AgentRow::More { lane_index, .. }) => lanes .get(*lane_index) .map(|lane| RailAnchor::Overflow(lane.key.clone())), - RailRow::Host(_) | RailRow::AgentsHeader | RailRow::Lane(_) => None, + RailRow::Host(_) | RailRow::Group(_) | RailRow::AgentsHeader | RailRow::Lane(_) => None, } } From 1e2adaacfc9a94ca8c49e8b8e67c645af9cb6bf2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 19:55:42 +0300 Subject: [PATCH 03/29] fix(rail): sort tasks before paging Co-authored-by: Medulla --- src/tui/src/ui/app/rail/merge_tests.rs | 1 + src/tui/src/ui/app/rail/mod.rs | 65 ++++++++++------ .../app/rail/{organize.rs => organize/mod.rs} | 3 + .../{organize_tests.rs => organize/tests.rs} | 77 ++++++++++++++++++- 4 files changed, 121 insertions(+), 25 deletions(-) rename src/tui/src/ui/app/rail/{organize.rs => organize/mod.rs} (99%) rename src/tui/src/ui/app/rail/{organize_tests.rs => organize/tests.rs} (75%) diff --git a/src/tui/src/ui/app/rail/merge_tests.rs b/src/tui/src/ui/app/rail/merge_tests.rs index da66ba1e9..80feca276 100644 --- a/src/tui/src/ui/app/rail/merge_tests.rs +++ b/src/tui/src/ui/app/rail/merge_tests.rs @@ -39,6 +39,7 @@ fn group(sessions: Vec) -> AgentGroup { lane_index: Some(0), }, sessions, + visible_tasks: 0, hidden: 0, overflow: false, } diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index 93688c953..63690c8f2 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -46,8 +46,6 @@ mod cursor; #[cfg(test)] mod cursor_tests; mod organize; -#[cfg(test)] -mod organize_tests; pub(in crate::ui::app) mod resolve; // Kept apart from `tests` rather than nested inside it: the assembly rules and // the served-dispatch merge are separate responsibilities, and one file for @@ -84,6 +82,12 @@ struct AgentGroup { row: AgentRailRow, /// Its sessions, dispatched and operator-started alike. sessions: Vec, + /// How many task-backed sessions the fold has currently revealed. + /// + /// The complete task set stays here until [`organize`] applies the chosen + /// order. Keeping this boundary separate from the task data means a name + /// or activity sort cannot be applied only to the first visible page. + visible_tasks: usize, /// Sessions the fold's own page already hid, carried so the counts add up. hidden: usize, /// Whether the fold drew an overflow row under this agent's lane. @@ -184,19 +188,11 @@ impl App { } groups.push(self.group_for_lane(lane, lane_index)); } - AgentRow::Sub { - lane_index, task, .. - } => { + AgentRow::Sub { .. } => { let Some(group) = groups.last_mut() else { continue; }; - group.sessions.push(SessionRailRow { - agent_id: Some(group.row.agent_id.clone()), - lane_index: Some(lane_index), - task: Some(task), - local: None, - last: false, - }); + group.visible_tasks += 1; } AgentRow::More { hidden, .. } => { if let Some(group) = groups.last_mut() { @@ -229,12 +225,24 @@ impl App { .unwrap_or_default(); AgentGroup { row: AgentRailRow { - agent_id, + agent_id: agent_id.clone(), host_id, agent: None, lane_index: Some(lane_index), }, - sessions: Vec::new(), + sessions: lane + .tasks + .iter() + .cloned() + .map(|task| SessionRailRow { + agent_id: Some(agent_id.clone()), + lane_index: Some(lane_index), + task: Some(task), + local: None, + last: false, + }) + .collect(), + visible_tasks: 0, hidden: 0, overflow: false, } @@ -527,6 +535,7 @@ fn placed_agent( lane_index: None, }, sessions: Vec::new(), + visible_tasks: 0, hidden: 0, overflow: false, }); @@ -617,12 +626,11 @@ fn run_rows_under( /// the fold produced for an agent declared somewhere else — because the flow it /// opens reads the declaration for the harness and the directory to start in. /// -/// Paging is the fold's, not the rail's (#171): `agent_rows` reveals a page of -/// task sublanes at a time and marks the rest with an overflow row, so a second -/// cap here would clip the page the operator just asked to see. The overflow row -/// is re-emitted under the group and stays selectable, which is what makes -/// `Enter` on it page the lane open — and, once the lane is fully revealed, fold -/// it back. +/// The fold determines the page size and overflow state (#171), while this +/// function selects that many task rows only after [`organize`] has applied the +/// configured order. The overflow row is re-emitted under the group and stays +/// selectable, which is what makes `Enter` on it page the lane open — and, once +/// the lane is fully revealed, fold it back. fn push_group( rows: &mut Vec, group: &mut AgentGroup, @@ -630,8 +638,21 @@ fn push_group( runs: &medulla::control_socket::HarnessRunRegistry, ) { rows.push(RailRow::Agent(group.row.clone())); - let shown = group.sessions.len(); - for (index, session) in group.sessions.iter_mut().enumerate() { + let task_limit = group.visible_tasks; + let mut visible_tasks = 0; + let mut shown_sessions: Vec<_> = group + .sessions + .iter_mut() + .filter(|session| { + if session.task.is_none() { + return true; + } + visible_tasks += 1; + visible_tasks <= task_limit + }) + .collect(); + let shown = shown_sessions.len(); + for (index, session) in shown_sessions.iter_mut().enumerate() { // The action row below closes the group when it is offered, so the last // session is only the tree's last leaf when neither it nor the overflow // row follows. diff --git a/src/tui/src/ui/app/rail/organize.rs b/src/tui/src/ui/app/rail/organize/mod.rs similarity index 99% rename from src/tui/src/ui/app/rail/organize.rs rename to src/tui/src/ui/app/rail/organize/mod.rs index a24899ded..5d89fbbdb 100644 --- a/src/tui/src/ui/app/rail/organize.rs +++ b/src/tui/src/ui/app/rail/organize/mod.rs @@ -21,6 +21,9 @@ use medulla::config::{SidebarGrouping, SidebarSort}; use super::{AgentGroup, GroupRailRow, HostGroup, HostRailRow, SessionRailRow}; +#[cfg(test)] +mod tests; + /// What heads one section of the rail. pub(super) enum SectionHeader { /// A host row — emitted only when a second host exists to tell it from. diff --git a/src/tui/src/ui/app/rail/organize_tests.rs b/src/tui/src/ui/app/rail/organize/tests.rs similarity index 75% rename from src/tui/src/ui/app/rail/organize_tests.rs rename to src/tui/src/ui/app/rail/organize/tests.rs index 85154ca0e..aeb588001 100644 --- a/src/tui/src/ui/app/rail/organize_tests.rs +++ b/src/tui/src/ui/app/rail/organize/tests.rs @@ -6,9 +6,9 @@ use medulla::config::{SidebarGrouping, SidebarSort}; use medulla::runtime::AgentDeclaration; -use super::organize::sort_sessions; -use super::tests::{app, stub_session}; -use super::{RailRow, SessionRailRow}; +use super::super::tests::{app, stub_session}; +use super::super::{AgentGroup, AgentRailRow, GroupRailRow, RailRow, SessionRailRow}; +use super::{order_sections, sort_agents, sort_sessions, Section, SectionHeader}; use crate::ui::app::App; /// Three agents across two checkouts and two harnesses, in declaration order. @@ -215,3 +215,74 @@ fn name_sorts_sessions_by_what_the_operator_called_them() { vec!["alpha", "mike", "zulu"] ); } + +/// An agent group with one task whose activity timestamp controls recent order. +fn active_agent(label: &str, last_at: i64) -> AgentGroup { + AgentGroup { + row: AgentRailRow { + agent_id: label.into(), + host_id: String::new(), + agent: None, + lane_index: None, + }, + sessions: vec![SessionRailRow { + agent_id: Some(label.into()), + lane_index: None, + task: Some(medulla::ui::agents::TaskState { + task_id: format!("task-{label}"), + status: medulla::ui::agents::TaskStatus::Running, + turns: 0, + last_at, + turn_blocks: Vec::new(), + attention: None, + question_id: None, + work: None, + }), + local: None, + last: false, + }], + visible_tasks: 1, + hidden: 0, + overflow: false, + } +} + +#[test] +fn recent_sorts_agents_by_their_most_recent_session() { + let mut agents = vec![active_agent("quiet", 100), active_agent("loud", 900)]; + + sort_agents(&mut agents, SidebarSort::Recent); + + assert_eq!( + agents + .iter() + .map(|agent| agent.row.agent_id.as_str()) + .collect::>(), + vec!["loud", "quiet"] + ); +} + +#[test] +fn recent_sorts_path_sections_by_their_most_recent_agent() { + let mut sections = vec![ + Section { + header: SectionHeader::Group(GroupRailRow { + label: "/work/older".into(), + }), + agents: vec![active_agent("older", 100)], + }, + Section { + header: SectionHeader::Group(GroupRailRow { + label: "/work/newer".into(), + }), + agents: vec![active_agent("newer", 900)], + }, + ]; + + order_sections(&mut sections, SidebarSort::Recent); + + assert!(matches!( + §ions[0].header, + SectionHeader::Group(group) if group.label == "/work/newer" + )); +} From fe6323187af9a63172da8be09ef4cb0e68a83beb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 20:22:34 +0300 Subject: [PATCH 04/29] fix(rail): restore declaration order when grouping by path or harness The organize step now flattens host sections before applying path or harness grouping, and sorts by declaration order when the sidebar sort is set to Created. This prevents agents from different hosts from being interleaved in a way that breaks the intended declaration order. Co-authored-by: Medulla --- src/tui/src/ui/app/rail/merge_tests.rs | 58 ++++++++++++++++++++++- src/tui/src/ui/app/rail/mod.rs | 13 +++-- src/tui/src/ui/app/rail/organize/mod.rs | 54 ++++++++++++--------- src/tui/src/ui/app/rail/organize/tests.rs | 44 ++++++++++++++++- src/tui/src/ui/app/rail/organize/types.rs | 21 ++++++++ 5 files changed, 158 insertions(+), 32 deletions(-) create mode 100644 src/tui/src/ui/app/rail/organize/types.rs diff --git a/src/tui/src/ui/app/rail/merge_tests.rs b/src/tui/src/ui/app/rail/merge_tests.rs index 80feca276..cdfa8af2c 100644 --- a/src/tui/src/ui/app/rail/merge_tests.rs +++ b/src/tui/src/ui/app/rail/merge_tests.rs @@ -7,9 +7,9 @@ //! is folded into, without standing up a hub to answer the task-to-session //! lookup for real. -use super::tests::stub_session; +use super::tests::{app, stub_session}; use super::{task_row_serving, AgentGroup, AgentRailRow, SessionRailRow}; -use medulla::ui::agents::{TaskState, TaskStatus}; +use medulla::ui::agents::{AgentLane, AgentRole, TaskState, TaskStatus}; fn task_row(task_id: &str) -> SessionRailRow { SessionRailRow { @@ -89,3 +89,57 @@ fn a_settled_task_keeps_its_retained_session_on_a_row_of_its_own() { assert!(task_row_serving(&mut groups, "w_1", |_| None).is_none()); } + +#[test] +fn paging_starts_with_the_fold_running_first_task_order() { + let lane = AgentLane { + key: "agent:shell".into(), + label: "shell".into(), + role: AgentRole::Agent, + turns: Vec::new(), + last_at: 0, + tasks: vec![ + TaskState { + task_id: "completed-recently".into(), + status: TaskStatus::Done, + turns: 0, + last_at: 900, + turn_blocks: Vec::new(), + attention: None, + question_id: None, + work: None, + }, + TaskState { + task_id: "running-older".into(), + status: TaskStatus::Running, + turns: 0, + last_at: 100, + turn_blocks: Vec::new(), + attention: None, + question_id: None, + work: None, + }, + ], + context_tokens: None, + usage: Default::default(), + harness_label: None, + agent_id: Some("shell".into()), + session_id: None, + parent_agent_id: None, + descriptor: None, + active_tasks: 1, + work: None, + }; + + let group = app().group_for_lane(&lane, 0); + + assert_eq!( + group + .sessions + .iter() + .filter_map(|session| session.task.as_ref().map(|task| task.task_id.as_str())) + .collect::>(), + vec!["running-older", "completed-recently"], + "the first task page must match the fold's running-first order" + ); +} diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index 63690c8f2..cf88f2b6b 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -36,7 +36,7 @@ use medulla::runtime::AgentDeclaration; use medulla::ui::hosts::{HostAgentRow, HostKind, HostRow}; use super::types::App; -use crate::ui::agents::{AgentLane, AgentRole, AgentRow}; +use crate::ui::agents::{ordered_tasks, AgentLane, AgentRole, AgentRow}; use crate::worker::pty::SessionRow; mod cleanup; @@ -166,8 +166,12 @@ impl App { let mut hosts = place_agents(&self.host_tree(), folded); let mut orphans = self.attach_sessions(&mut hosts); let appearance = &self.loaded.config.appearance; - let sections = - organize::organize(hosts, appearance.sidebar_grouping, appearance.sidebar_sort); + let sections = organize::organize( + hosts, + &self.loaded.config.fleet.agent_declarations, + appearance.sidebar_grouping, + appearance.sidebar_sort, + ); organize::sort_sessions(&mut orphans, appearance.sidebar_sort); self.flatten(lane_rows, sections, orphans) } @@ -230,8 +234,7 @@ impl App { agent: None, lane_index: Some(lane_index), }, - sessions: lane - .tasks + sessions: ordered_tasks(&lane.tasks) .iter() .cloned() .map(|task| SessionRailRow { diff --git a/src/tui/src/ui/app/rail/organize/mod.rs b/src/tui/src/ui/app/rail/organize/mod.rs index 5d89fbbdb..1afd9178a 100644 --- a/src/tui/src/ui/app/rail/organize/mod.rs +++ b/src/tui/src/ui/app/rail/organize/mod.rs @@ -18,29 +18,15 @@ //! repository's size ceiling. use medulla::config::{SidebarGrouping, SidebarSort}; +use medulla::runtime::AgentDeclaration; -use super::{AgentGroup, GroupRailRow, HostGroup, HostRailRow, SessionRailRow}; +use super::{AgentGroup, GroupRailRow, HostGroup, SessionRailRow}; #[cfg(test)] mod tests; +mod types; -/// What heads one section of the rail. -pub(super) enum SectionHeader { - /// A host row — emitted only when a second host exists to tell it from. - Host(HostRailRow), - /// A grouping header: a workspace directory, or a harness name. - Group(GroupRailRow), - /// No header at all: the agents sit at the top level. - None, -} - -/// One section of the rail: a header and the agents under it. -pub(super) struct Section { - /// What heads it, if anything. - pub header: SectionHeader, - /// The agents in it, already ordered. - pub agents: Vec, -} +pub(super) use types::{Section, SectionHeader}; /// Section and order the placed tree according to the operator's preferences. /// @@ -50,16 +36,19 @@ pub(super) struct Section { /// reading the same checkout twice under two hosts. pub(super) fn organize( hosts: Vec, + declarations: &[AgentDeclaration], grouping: SidebarGrouping, sort: SidebarSort, ) -> Vec
{ let mut sections = match grouping { SidebarGrouping::Host => by_host(hosts), - SidebarGrouping::Path => by_key(hosts, agent_path), - SidebarGrouping::Harness => by_key(hosts, agent_harness), + SidebarGrouping::Path => by_key(flatten_agents(hosts, declarations, sort), agent_path), + SidebarGrouping::Harness => { + by_key(flatten_agents(hosts, declarations, sort), agent_harness) + } SidebarGrouping::None => vec![Section { header: SectionHeader::None, - agents: hosts.into_iter().flat_map(|host| host.agents).collect(), + agents: flatten_agents(hosts, declarations, sort), }], }; for section in &mut sections { @@ -93,9 +82,9 @@ fn by_host(hosts: Vec) -> Vec
{ } /// Section every agent by one derived key, preserving first-seen order. -fn by_key(hosts: Vec, key: fn(&AgentGroup) -> String) -> Vec
{ +fn by_key(agents: Vec, key: fn(&AgentGroup) -> String) -> Vec
{ let mut sections: Vec
= Vec::new(); - for agent in hosts.into_iter().flat_map(|host| host.agents) { + for agent in agents { let label = key(&agent); match sections.iter_mut().find(|section| match §ion.header { SectionHeader::Group(group) => group.label == label, @@ -113,6 +102,25 @@ fn by_key(hosts: Vec, key: fn(&AgentGroup) -> String) -> Vec
sections } +/// Flatten host sections, restoring declaration order before any non-host +/// grouping can interleave agents from separate hosts. +fn flatten_agents( + hosts: Vec, + declarations: &[AgentDeclaration], + sort: SidebarSort, +) -> Vec { + let mut agents: Vec<_> = hosts.into_iter().flat_map(|host| host.agents).collect(); + if sort == SidebarSort::Created { + agents.sort_by_key(|agent| { + declarations + .iter() + .position(|declaration| declaration.agent_id.trim() == agent.row.agent_id.trim()) + .unwrap_or(usize::MAX) + }); + } + agents +} + /// The directory an agent works in, as its section label. /// /// An agent the projection knows nothing about — a lane the backend rosters, diff --git a/src/tui/src/ui/app/rail/organize/tests.rs b/src/tui/src/ui/app/rail/organize/tests.rs index aeb588001..4c8e1d95d 100644 --- a/src/tui/src/ui/app/rail/organize/tests.rs +++ b/src/tui/src/ui/app/rail/organize/tests.rs @@ -7,8 +7,10 @@ use medulla::config::{SidebarGrouping, SidebarSort}; use medulla::runtime::AgentDeclaration; use super::super::tests::{app, stub_session}; -use super::super::{AgentGroup, AgentRailRow, GroupRailRow, RailRow, SessionRailRow}; -use super::{order_sections, sort_agents, sort_sessions, Section, SectionHeader}; +use super::super::{ + AgentGroup, AgentRailRow, GroupRailRow, HostGroup, HostRailRow, RailRow, SessionRailRow, +}; +use super::{flatten_agents, order_sections, sort_agents, sort_sessions, Section, SectionHeader}; use crate::ui::app::App; /// Three agents across two checkouts and two harnesses, in declaration order. @@ -286,3 +288,41 @@ fn recent_sorts_path_sections_by_their_most_recent_agent() { SectionHeader::Group(group) if group.label == "/work/newer" )); } + +#[test] +fn created_keeps_declaration_order_after_flattening_hosts() { + let declarations = vec![ + AgentDeclaration::new("first", "host-one", "codex", "/one"), + AgentDeclaration::new("second", "host-two", "codex", "/two"), + AgentDeclaration::new("third", "host-one", "codex", "/one"), + ]; + let hosts = vec![ + HostGroup { + row: HostRailRow { + host_id: "host-one".into(), + label: "one".into(), + local: true, + }, + agents: vec![active_agent("first", 0), active_agent("third", 0)], + }, + HostGroup { + row: HostRailRow { + host_id: "host-two".into(), + label: "two".into(), + local: false, + }, + agents: vec![active_agent("second", 0)], + }, + ]; + + let agents = flatten_agents(hosts, &declarations, SidebarSort::Created); + + assert_eq!( + agents + .iter() + .map(|agent| agent.row.agent_id.as_str()) + .collect::>(), + vec!["first", "second", "third"], + "grouping must not turn A(host one), B(host two), C(host one) into A, C, B" + ); +} diff --git a/src/tui/src/ui/app/rail/organize/types.rs b/src/tui/src/ui/app/rail/organize/types.rs new file mode 100644 index 000000000..fb01267ec --- /dev/null +++ b/src/tui/src/ui/app/rail/organize/types.rs @@ -0,0 +1,21 @@ +//! Data types that describe the rail's organized section tree. + +use super::super::{AgentGroup, GroupRailRow, HostRailRow}; + +/// What heads one section of the rail. +pub(in crate::ui::app::rail) enum SectionHeader { + /// A host row — emitted only when a second host exists to tell it from. + Host(HostRailRow), + /// A grouping header: a workspace directory, or a harness name. + Group(GroupRailRow), + /// No header at all: the agents sit at the top level. + None, +} + +/// One section of the rail: a header and the agents under it. +pub(in crate::ui::app::rail) struct Section { + /// What heads it, if anything. + pub header: SectionHeader, + /// The agents in it, already ordered. + pub agents: Vec, +} From bba2703a819a54361f057cdb60d0e32fe4597606 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 20:47:05 +0300 Subject: [PATCH 05/29] fix(rail): retain pinned and active task rows Co-authored-by: Medulla --- src/tui/src/ui/app/rail/merge_tests.rs | 99 +++++++++++++++++++++++++- src/tui/src/ui/app/rail/mod.rs | 30 +++++++- 2 files changed, 126 insertions(+), 3 deletions(-) diff --git a/src/tui/src/ui/app/rail/merge_tests.rs b/src/tui/src/ui/app/rail/merge_tests.rs index cdfa8af2c..1ea3c9f0a 100644 --- a/src/tui/src/ui/app/rail/merge_tests.rs +++ b/src/tui/src/ui/app/rail/merge_tests.rs @@ -8,7 +8,10 @@ //! lookup for real. use super::tests::{app, stub_session}; -use super::{task_row_serving, AgentGroup, AgentRailRow, SessionRailRow}; +use super::{ + push_group, task_row_serving, AgentGroup, AgentRailRow, RailAnchor, RailRow, SessionRailRow, +}; +use medulla::control_socket::{HarnessRunStatus, RunReport}; use medulla::ui::agents::{AgentLane, AgentRole, TaskState, TaskStatus}; fn task_row(task_id: &str) -> SessionRailRow { @@ -143,3 +146,97 @@ fn paging_starts_with_the_fold_running_first_task_order() { "the first task page must match the fold's running-first order" ); } + +#[test] +fn paging_keeps_the_anchored_task_after_recent_sorting() { + let lanes = vec![AgentLane { + key: "agent:shell".into(), + label: "shell".into(), + role: AgentRole::Agent, + turns: Vec::new(), + last_at: 0, + tasks: Vec::new(), + context_tokens: None, + usage: Default::default(), + harness_label: None, + agent_id: Some("shell".into()), + session_id: None, + parent_agent_id: None, + descriptor: None, + active_tasks: 0, + work: None, + }]; + let mut owner = group(vec![ + task_row("newest"), + task_row("middle"), + task_row("selected"), + ]); + owner.visible_tasks = 1; + owner.overflow = true; + let mut rows = Vec::new(); + + push_group( + &mut rows, + &mut owner, + false, + &medulla::control_socket::HarnessRunRegistry::new(), + &lanes, + Some(&RailAnchor::Task { + lane: "agent:shell".into(), + task_id: "selected".into(), + }), + ); + + assert!(rows.iter().any(|row| matches!( + row, + RailRow::Session(session) + if session.task.as_ref().is_some_and(|task| task.task_id == "selected") + ))); +} + +#[test] +fn paging_keeps_a_task_backed_session_with_an_active_workflow_run() { + let lanes = vec![AgentLane { + key: "agent:shell".into(), + label: "shell".into(), + role: AgentRole::Agent, + turns: Vec::new(), + last_at: 0, + tasks: Vec::new(), + context_tokens: None, + usage: Default::default(), + harness_label: None, + agent_id: Some("shell".into()), + session_id: None, + parent_agent_id: None, + descriptor: None, + active_tasks: 0, + work: None, + }]; + let mut active = task_row("active-workflow"); + let mut local = stub_session("pty-1"); + local.mcp_grant_session = Some("grant-1".into()); + active.local = Some(local); + let mut owner = group(vec![task_row("newest"), active]); + owner.visible_tasks = 1; + owner.overflow = true; + let runs = medulla::control_socket::HarnessRunRegistry::new(); + runs.report( + "grant-1", + RunReport { + run_id: "run-1".into(), + workflow_id: "workflow".into(), + status: HarnessRunStatus::Running, + detail: None, + node: None, + }, + ); + let mut rows = Vec::new(); + + push_group(&mut rows, &mut owner, false, &runs, &lanes, None); + + assert!(rows.iter().any(|row| matches!( + row, + RailRow::WorkflowRun(run) if run.run.run_id == "run-1" + ))); +} diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index cf88f2b6b..1b84ae160 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -173,7 +173,7 @@ impl App { appearance.sidebar_sort, ); organize::sort_sessions(&mut orphans, appearance.sidebar_sort); - self.flatten(lane_rows, sections, orphans) + self.flatten(lane_rows, sections, orphans, lanes) } /// Split the folded rows into the non-agent ones and the per-agent groups. @@ -326,6 +326,7 @@ impl App { lane_rows: Vec, sections: Vec, orphans: Vec, + lanes: &[AgentLane], ) -> Vec { let mut rows: Vec = lane_rows.into_iter().map(RailRow::Lane).collect(); // A device that hosts nothing cannot declare an agent on itself, so the @@ -364,7 +365,14 @@ impl App { let offers_session = declared .iter() .any(|agent_id| agent_id.trim() == group.row.agent_id.trim()); - push_group(&mut rows, group, offers_session, &self.harness_runs); + push_group( + &mut rows, + group, + offers_session, + &self.harness_runs, + lanes, + self.agent_anchor.as_ref(), + ); } } for mut session in orphans { @@ -639,10 +647,23 @@ fn push_group( group: &mut AgentGroup, offers_session: bool, runs: &medulla::control_socket::HarnessRunRegistry, + lanes: &[AgentLane], + anchor: Option<&RailAnchor>, ) { rows.push(RailRow::Agent(group.row.clone())); let task_limit = group.visible_tasks; let mut visible_tasks = 0; + let pinned_task = group + .row + .lane_index + .and_then(|index| lanes.get(index)) + .and_then(|lane| match anchor { + Some(RailAnchor::Task { + lane: anchored_lane, + task_id, + }) if anchored_lane == &lane.key => Some(task_id.as_str()), + _ => None, + }); let mut shown_sessions: Vec<_> = group .sessions .iter_mut() @@ -652,6 +673,11 @@ fn push_group( } visible_tasks += 1; visible_tasks <= task_limit + || session + .task + .as_ref() + .is_some_and(|task| Some(task.task_id.as_str()) == pinned_task) + || !run_rows_under(session, runs).is_empty() }) .collect(); let shown = shown_sessions.len(); From af0ee14e816d44ee7b4a2f432365e73e81c468e5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 21:12:55 +0300 Subject: [PATCH 06/29] perf(rail): avoid duplicate task clones Co-authored-by: Medulla --- src/tui/src/ui/app/rail/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index 1b84ae160..932443585 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -235,8 +235,7 @@ impl App { lane_index: Some(lane_index), }, sessions: ordered_tasks(&lane.tasks) - .iter() - .cloned() + .into_iter() .map(|task| SessionRailRow { agent_id: Some(agent_id.clone()), lane_index: Some(lane_index), From a878298deaacc5a627a1e20155a8686baeb7fe14 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 21:38:49 +0300 Subject: [PATCH 07/29] fix(rail): preserve retained task paging Co-authored-by: Medulla --- src/tui/src/ui/app/rail/merge_tests.rs | 47 ++++++++++++++++++++++- src/tui/src/ui/app/rail/mod.rs | 19 ++++++--- src/tui/src/ui/app/rail/organize/mod.rs | 19 ++++++--- src/tui/src/ui/app/rail/organize/tests.rs | 27 +++++++++++++ 4 files changed, 101 insertions(+), 11 deletions(-) diff --git a/src/tui/src/ui/app/rail/merge_tests.rs b/src/tui/src/ui/app/rail/merge_tests.rs index 1ea3c9f0a..17390a477 100644 --- a/src/tui/src/ui/app/rail/merge_tests.rs +++ b/src/tui/src/ui/app/rail/merge_tests.rs @@ -12,7 +12,7 @@ use super::{ push_group, task_row_serving, AgentGroup, AgentRailRow, RailAnchor, RailRow, SessionRailRow, }; use medulla::control_socket::{HarnessRunStatus, RunReport}; -use medulla::ui::agents::{AgentLane, AgentRole, TaskState, TaskStatus}; +use medulla::ui::agents::{AgentLane, AgentRole, AgentRow, TaskState, TaskStatus}; fn task_row(task_id: &str) -> SessionRailRow { SessionRailRow { @@ -240,3 +240,48 @@ fn paging_keeps_a_task_backed_session_with_an_active_workflow_run() { RailRow::WorkflowRun(run) if run.run.run_id == "run-1" ))); } + +#[test] +fn paging_hides_the_overflow_action_when_retention_shows_every_task() { + let lanes = vec![AgentLane { + key: "agent:shell".into(), + label: "shell".into(), + role: AgentRole::Agent, + turns: Vec::new(), + last_at: 0, + tasks: Vec::new(), + context_tokens: None, + usage: Default::default(), + harness_label: None, + agent_id: Some("shell".into()), + session_id: None, + parent_agent_id: None, + descriptor: None, + active_tasks: 0, + work: None, + }]; + let mut owner = group(vec![task_row("first"), task_row("pinned")]); + owner.visible_tasks = 1; + owner.hidden = 1; + owner.overflow = true; + let mut rows = Vec::new(); + + push_group( + &mut rows, + &mut owner, + false, + &medulla::control_socket::HarnessRunRegistry::new(), + &lanes, + Some(&RailAnchor::Task { + lane: "agent:shell".into(), + task_id: "pinned".into(), + }), + ); + + assert!( + !rows + .iter() + .any(|row| matches!(row, RailRow::Lane(AgentRow::More { .. }))), + "the overflow action is absent when retaining a task reveals every task" + ); +} diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index 932443585..10204d451 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -652,6 +652,7 @@ fn push_group( rows.push(RailRow::Agent(group.row.clone())); let task_limit = group.visible_tasks; let mut visible_tasks = 0; + let mut hidden_tasks = 0; let pinned_task = group .row .lane_index @@ -671,29 +672,37 @@ fn push_group( return true; } visible_tasks += 1; - visible_tasks <= task_limit + let shown = visible_tasks <= task_limit || session .task .as_ref() .is_some_and(|task| Some(task.task_id.as_str()) == pinned_task) - || !run_rows_under(session, runs).is_empty() + || !run_rows_under(session, runs).is_empty(); + if !shown { + hidden_tasks += 1; + } + shown }) .collect(); + // A pinned task or one with an active workflow run may extend the fold's + // nominal page. Recount what was actually omitted so the overflow action + // neither overstates the remainder nor survives after showing every task. + let show_overflow = group.overflow && (group.hidden == 0 || hidden_tasks > 0); let shown = shown_sessions.len(); for (index, session) in shown_sessions.iter_mut().enumerate() { // The action row below closes the group when it is offered, so the last // session is only the tree's last leaf when neither it nor the overflow // row follows. - session.last = !offers_session && !group.overflow && index + 1 == shown; + session.last = !offers_session && !show_overflow && index + 1 == shown; let session = Box::new(session.clone()); let run_rows = run_rows_under(&session, runs); rows.push(RailRow::Session(session)); rows.extend(run_rows); } - if group.overflow { + if show_overflow { rows.push(RailRow::Lane(AgentRow::More { lane_index: group.row.lane_index.unwrap_or(0), - hidden: group.hidden, + hidden: hidden_tasks, })); } // Last, under the sessions it adds to: the group reads as a list of what diff --git a/src/tui/src/ui/app/rail/organize/mod.rs b/src/tui/src/ui/app/rail/organize/mod.rs index 1afd9178a..5e6d4d614 100644 --- a/src/tui/src/ui/app/rail/organize/mod.rs +++ b/src/tui/src/ui/app/rail/organize/mod.rs @@ -209,12 +209,21 @@ pub(super) fn sort_sessions(sessions: &mut [SessionRailRow], sort: SidebarSort) } } -/// When a session started, or [`i64::MIN`] when only a dispatch describes it. +/// When a local-only session started, or [`i64::MIN`] for dispatches. +/// +/// A task may be enriched with a local PTY after `ordered_tasks` established +/// its running-first fold order. Treating that row as locally started would +/// reorder it behind task-only rows, so task-backed rows deliberately retain +/// their stable fold position. fn session_started(session: &SessionRailRow) -> i64 { - session - .local - .as_ref() - .map_or(i64::MIN, |local| local.started_at) + if session.task.is_some() { + i64::MIN + } else { + session + .local + .as_ref() + .map_or(i64::MIN, |local| local.started_at) + } } /// The most recent thing known about a session: its last output byte, or the diff --git a/src/tui/src/ui/app/rail/organize/tests.rs b/src/tui/src/ui/app/rail/organize/tests.rs index 4c8e1d95d..49dedb099 100644 --- a/src/tui/src/ui/app/rail/organize/tests.rs +++ b/src/tui/src/ui/app/rail/organize/tests.rs @@ -191,6 +191,33 @@ fn created_sorts_sessions_oldest_first() { ); } +#[test] +fn created_keeps_task_rows_in_fold_order_after_pty_enrichment() { + let mut served = active_agent("served", 0) + .sessions + .pop() + .expect("one task session"); + let mut local = stub_session("pty"); + local.started_at = 100; + served.local = Some(local); + let task_only = active_agent("task-only", 0) + .sessions + .pop() + .expect("one task session"); + + let mut sessions = vec![served, task_only]; + sort_sessions(&mut sessions, SidebarSort::Created); + + assert_eq!( + sessions + .iter() + .filter_map(|session| session.task.as_ref().map(|task| task.task_id.as_str())) + .collect::>(), + vec!["task-served", "task-task-only"], + "a live PTY must not move a task-backed row out of the fold's order" + ); +} + #[test] fn recent_sorts_by_the_last_thing_a_session_did() { let rows = vec![ From 6ba749c3e8f3fd8f72240bb4a4fa2beeae011cdd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 21:44:40 +0300 Subject: [PATCH 08/29] refactor(rail): isolate task paging Co-authored-by: Medulla --- src/tui/src/ui/app/rail/merge_tests.rs | 6 ++ src/tui/src/ui/app/rail/mod.rs | 79 +------------------------- src/tui/src/ui/app/rail/paging.rs | 79 ++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 76 deletions(-) create mode 100644 src/tui/src/ui/app/rail/paging.rs diff --git a/src/tui/src/ui/app/rail/merge_tests.rs b/src/tui/src/ui/app/rail/merge_tests.rs index 17390a477..e0a55a7b6 100644 --- a/src/tui/src/ui/app/rail/merge_tests.rs +++ b/src/tui/src/ui/app/rail/merge_tests.rs @@ -7,10 +7,12 @@ //! is folded into, without standing up a hub to answer the task-to-session //! lookup for real. +use super::organize::sort_sessions; use super::tests::{app, stub_session}; use super::{ push_group, task_row_serving, AgentGroup, AgentRailRow, RailAnchor, RailRow, SessionRailRow, }; +use medulla::config::SidebarSort; use medulla::control_socket::{HarnessRunStatus, RunReport}; use medulla::ui::agents::{AgentLane, AgentRole, AgentRow, TaskState, TaskStatus}; @@ -171,6 +173,10 @@ fn paging_keeps_the_anchored_task_after_recent_sorting() { task_row("middle"), task_row("selected"), ]); + owner.sessions[0].task.as_mut().expect("task row").last_at = 900; + owner.sessions[1].task.as_mut().expect("task row").last_at = 500; + owner.sessions[2].task.as_mut().expect("task row").last_at = 100; + sort_sessions(&mut owner.sessions, SidebarSort::Recent); owner.visible_tasks = 1; owner.overflow = true; let mut rows = Vec::new(); diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index 10204d451..9e172a8a7 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -46,6 +46,7 @@ mod cursor; #[cfg(test)] mod cursor_tests; mod organize; +mod paging; pub(in crate::ui::app) mod resolve; // Kept apart from `tests` rather than nested inside it: the assembly rules and // the served-dispatch merge are separate responsibilities, and one file for @@ -629,18 +630,7 @@ fn run_rows_under( .collect() } -/// Push one agent row and the sessions under it, tree-marked. -/// -/// `offers_session` closes the group with the `+ New session` action. It is off -/// for an agent this machine does not declare — a remote host's agent, or a lane -/// the fold produced for an agent declared somewhere else — because the flow it -/// opens reads the declaration for the harness and the directory to start in. -/// -/// The fold determines the page size and overflow state (#171), while this -/// function selects that many task rows only after [`organize`] has applied the -/// configured order. The overflow row is re-emitted under the group and stays -/// selectable, which is what makes `Enter` on it page the lane open — and, once -/// the lane is fully revealed, fold it back. +/// Delegate paged agent-group rendering to its focused implementation module. fn push_group( rows: &mut Vec, group: &mut AgentGroup, @@ -649,68 +639,5 @@ fn push_group( lanes: &[AgentLane], anchor: Option<&RailAnchor>, ) { - rows.push(RailRow::Agent(group.row.clone())); - let task_limit = group.visible_tasks; - let mut visible_tasks = 0; - let mut hidden_tasks = 0; - let pinned_task = group - .row - .lane_index - .and_then(|index| lanes.get(index)) - .and_then(|lane| match anchor { - Some(RailAnchor::Task { - lane: anchored_lane, - task_id, - }) if anchored_lane == &lane.key => Some(task_id.as_str()), - _ => None, - }); - let mut shown_sessions: Vec<_> = group - .sessions - .iter_mut() - .filter(|session| { - if session.task.is_none() { - return true; - } - visible_tasks += 1; - let shown = visible_tasks <= task_limit - || session - .task - .as_ref() - .is_some_and(|task| Some(task.task_id.as_str()) == pinned_task) - || !run_rows_under(session, runs).is_empty(); - if !shown { - hidden_tasks += 1; - } - shown - }) - .collect(); - // A pinned task or one with an active workflow run may extend the fold's - // nominal page. Recount what was actually omitted so the overflow action - // neither overstates the remainder nor survives after showing every task. - let show_overflow = group.overflow && (group.hidden == 0 || hidden_tasks > 0); - let shown = shown_sessions.len(); - for (index, session) in shown_sessions.iter_mut().enumerate() { - // The action row below closes the group when it is offered, so the last - // session is only the tree's last leaf when neither it nor the overflow - // row follows. - session.last = !offers_session && !show_overflow && index + 1 == shown; - let session = Box::new(session.clone()); - let run_rows = run_rows_under(&session, runs); - rows.push(RailRow::Session(session)); - rows.extend(run_rows); - } - if show_overflow { - rows.push(RailRow::Lane(AgentRow::More { - lane_index: group.row.lane_index.unwrap_or(0), - hidden: hidden_tasks, - })); - } - // Last, under the sessions it adds to: the group reads as a list of what - // this agent is running, and the action that starts one more belongs at the - // end of that list rather than above it. - if offers_session { - rows.push(RailRow::NewSession { - agent_id: group.row.agent_id.clone(), - }); - } + paging::push_group(rows, group, offers_session, runs, lanes, anchor); } diff --git a/src/tui/src/ui/app/rail/paging.rs b/src/tui/src/ui/app/rail/paging.rs new file mode 100644 index 000000000..f3881146c --- /dev/null +++ b/src/tui/src/ui/app/rail/paging.rs @@ -0,0 +1,79 @@ +//! Paging and rendering of sessions nested below one agent rail row. +//! +//! This keeps selection and retention rules together: a page can retain the +//! cursor's task or one with a live workflow run, and its overflow count must +//! describe the tasks actually omitted after those retention rules apply. + +use medulla::control_socket::HarnessRunRegistry; + +use super::{run_rows_under, AgentGroup, RailAnchor, RailRow}; +use crate::ui::agents::{AgentLane, AgentRow}; + +/// Push one agent row, its selected session rows, and its optional actions. +pub(super) fn push_group( + rows: &mut Vec, + group: &mut AgentGroup, + offers_session: bool, + runs: &HarnessRunRegistry, + lanes: &[AgentLane], + anchor: Option<&RailAnchor>, +) { + rows.push(RailRow::Agent(group.row.clone())); + let task_limit = group.visible_tasks; + let mut visible_tasks = 0; + let mut hidden_tasks = 0; + let pinned_task = group + .row + .lane_index + .and_then(|index| lanes.get(index)) + .and_then(|lane| match anchor { + Some(RailAnchor::Task { + lane: anchored_lane, + task_id, + }) if anchored_lane == &lane.key => Some(task_id.as_str()), + _ => None, + }); + let mut shown_sessions: Vec<_> = group + .sessions + .iter_mut() + .filter(|session| { + if session.task.is_none() { + return true; + } + visible_tasks += 1; + let shown = visible_tasks <= task_limit + || session + .task + .as_ref() + .is_some_and(|task| Some(task.task_id.as_str()) == pinned_task) + || !run_rows_under(session, runs).is_empty(); + if !shown { + hidden_tasks += 1; + } + shown + }) + .collect(); + // A pinned task or one with an active workflow run may extend the fold's + // nominal page. Recount what was actually omitted so the overflow action + // neither overstates the remainder nor survives after showing every task. + let show_overflow = group.overflow && (group.hidden == 0 || hidden_tasks > 0); + let shown = shown_sessions.len(); + for (index, session) in shown_sessions.iter_mut().enumerate() { + session.last = !offers_session && !show_overflow && index + 1 == shown; + let session = Box::new(session.clone()); + let run_rows = run_rows_under(&session, runs); + rows.push(RailRow::Session(session)); + rows.extend(run_rows); + } + if show_overflow { + rows.push(RailRow::Lane(AgentRow::More { + lane_index: group.row.lane_index.unwrap_or(0), + hidden: hidden_tasks, + })); + } + if offers_session { + rows.push(RailRow::NewSession { + agent_id: group.row.agent_id.clone(), + }); + } +} From 045c178b56fbeaa0f1a2776c98a28aebf9accb4b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 22:11:27 +0300 Subject: [PATCH 09/29] fix(rail): preserve lane activity in recent sorting Co-authored-by: Medulla --- src/tui/src/ui/app/rail/merge_tests.rs | 1 + src/tui/src/ui/app/rail/mod.rs | 8 +++ src/tui/src/ui/app/rail/organize/mod.rs | 26 ++++++-- src/tui/src/ui/app/rail/organize/tests.rs | 76 +++++++++++++++++++++++ 4 files changed, 105 insertions(+), 6 deletions(-) diff --git a/src/tui/src/ui/app/rail/merge_tests.rs b/src/tui/src/ui/app/rail/merge_tests.rs index e0a55a7b6..a359f0c46 100644 --- a/src/tui/src/ui/app/rail/merge_tests.rs +++ b/src/tui/src/ui/app/rail/merge_tests.rs @@ -44,6 +44,7 @@ fn group(sessions: Vec) -> AgentGroup { lane_index: Some(0), }, sessions, + last_at: 0, visible_tasks: 0, hidden: 0, overflow: false, diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index 9e172a8a7..15254ad3e 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -83,6 +83,12 @@ struct AgentGroup { row: AgentRailRow, /// Its sessions, dispatched and operator-started alike. sessions: Vec, + /// The most recent lane-level event, including activity with no task row. + /// + /// Peer-session lanes carry transcript activity directly on the lane rather + /// than as a task. Preserve that timestamp so recent sorting still moves a + /// peer agent whose output is changing. + last_at: i64, /// How many task-backed sessions the fold has currently revealed. /// /// The complete task set stays here until [`organize`] applies the chosen @@ -245,6 +251,7 @@ impl App { last: false, }) .collect(), + last_at: lane.last_at, visible_tasks: 0, hidden: 0, overflow: false, @@ -546,6 +553,7 @@ fn placed_agent( lane_index: None, }, sessions: Vec::new(), + last_at: 0, visible_tasks: 0, hidden: 0, overflow: false, diff --git a/src/tui/src/ui/app/rail/organize/mod.rs b/src/tui/src/ui/app/rail/organize/mod.rs index 5e6d4d614..23ee1213e 100644 --- a/src/tui/src/ui/app/rail/organize/mod.rs +++ b/src/tui/src/ui/app/rail/organize/mod.rs @@ -130,9 +130,25 @@ fn agent_path(agent: &AgentGroup) -> String { agent .row .workspace() - .map(str::trim) + .map(normalize_path_label) .filter(|workspace| !workspace.is_empty()) - .map_or_else(|| "no path".to_string(), str::to_string) + .unwrap_or_else(|| "no path".to_string()) +} + +/// Normalize a workspace directory for grouping without changing the root. +/// +/// Declarations and spawned working directories commonly differ only by a +/// trailing separator. Grouping uses the same comparison rule as session +/// ownership resolution so that representation detail does not create a second +/// section header. +fn normalize_path_label(path: &str) -> String { + let trimmed = path.trim(); + let stripped = trimmed.trim_end_matches('/'); + if stripped.is_empty() { + trimmed.to_string() + } else { + stripped.to_string() + } } /// The harness an agent runs, as its section label. @@ -252,10 +268,8 @@ fn session_label(session: &SessionRailRow) -> String { /// The most recent activity under an agent, for the activity orders. fn agent_activity(agent: &AgentGroup) -> i64 { - agent - .sessions - .iter() - .map(session_activity) + std::iter::once(agent.last_at) + .chain(agent.sessions.iter().map(session_activity)) .max() .unwrap_or(i64::MIN) } diff --git a/src/tui/src/ui/app/rail/organize/tests.rs b/src/tui/src/ui/app/rail/organize/tests.rs index 49dedb099..b4291a11c 100644 --- a/src/tui/src/ui/app/rail/organize/tests.rs +++ b/src/tui/src/ui/app/rail/organize/tests.rs @@ -93,6 +93,24 @@ fn grouping_by_path_heads_each_checkout_once() { ); } +#[test] +fn grouping_by_path_ignores_a_trailing_separator() { + let mut app = app_with_agents(); + app.loaded.config.appearance.sidebar_grouping = SidebarGrouping::Path; + app.loaded.config.fleet.agent_declarations[2].workspace.path = "/work/alpha/".into(); + + assert_eq!( + sections(&app), + vec![ + ( + Some("/work/alpha".into()), + vec!["acorn".into(), "mint".into()] + ), + (Some("/work/beta".into()), vec!["zed".into()]), + ] + ); +} + #[test] fn grouping_by_harness_sections_by_the_cli_each_agent_runs() { let mut app = app_with_agents(); @@ -270,12 +288,30 @@ fn active_agent(label: &str, last_at: i64) -> AgentGroup { local: None, last: false, }], + last_at, visible_tasks: 1, hidden: 0, overflow: false, } } +/// An agent group whose activity is reported only at the lane level. +fn peer_agent(label: &str, last_at: i64) -> AgentGroup { + AgentGroup { + row: AgentRailRow { + agent_id: label.into(), + host_id: String::new(), + agent: None, + lane_index: None, + }, + sessions: Vec::new(), + last_at, + visible_tasks: 0, + hidden: 0, + overflow: false, + } +} + #[test] fn recent_sorts_agents_by_their_most_recent_session() { let mut agents = vec![active_agent("quiet", 100), active_agent("loud", 900)]; @@ -291,6 +327,46 @@ fn recent_sorts_agents_by_their_most_recent_session() { ); } +#[test] +fn recent_sorts_agents_by_peer_lane_activity() { + let mut agents = vec![active_agent("task", 500), peer_agent("peer", 900)]; + + sort_agents(&mut agents, SidebarSort::Recent); + + assert_eq!( + agents + .iter() + .map(|agent| agent.row.agent_id.as_str()) + .collect::>(), + vec!["peer", "task"] + ); +} + +#[test] +fn recent_sorts_sections_by_peer_lane_activity() { + let mut sections = vec![ + Section { + header: SectionHeader::Group(GroupRailRow { + label: "/work/task".into(), + }), + agents: vec![active_agent("task", 500)], + }, + Section { + header: SectionHeader::Group(GroupRailRow { + label: "/work/peer".into(), + }), + agents: vec![peer_agent("peer", 900)], + }, + ]; + + order_sections(&mut sections, SidebarSort::Recent); + + assert!(matches!( + §ions[0].header, + SectionHeader::Group(group) if group.label == "/work/peer" + )); +} + #[test] fn recent_sorts_path_sections_by_their_most_recent_agent() { let mut sections = vec![ From 5ffa33fa26ceeaf7954e23d3ca936a3f10672bcb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 22:41:44 +0300 Subject: [PATCH 10/29] test(rail): colocate paging tests Co-authored-by: Medulla --- src/tui/src/ui/app/rail/merge_tests.rs | 208 +----------------- .../ui/app/rail/{paging.rs => paging/mod.rs} | 3 + src/tui/src/ui/app/rail/paging/tests.rs | 198 +++++++++++++++++ 3 files changed, 204 insertions(+), 205 deletions(-) rename src/tui/src/ui/app/rail/{paging.rs => paging/mod.rs} (99%) create mode 100644 src/tui/src/ui/app/rail/paging/tests.rs diff --git a/src/tui/src/ui/app/rail/merge_tests.rs b/src/tui/src/ui/app/rail/merge_tests.rs index a359f0c46..54920b773 100644 --- a/src/tui/src/ui/app/rail/merge_tests.rs +++ b/src/tui/src/ui/app/rail/merge_tests.rs @@ -7,14 +7,9 @@ //! is folded into, without standing up a hub to answer the task-to-session //! lookup for real. -use super::organize::sort_sessions; -use super::tests::{app, stub_session}; -use super::{ - push_group, task_row_serving, AgentGroup, AgentRailRow, RailAnchor, RailRow, SessionRailRow, -}; -use medulla::config::SidebarSort; -use medulla::control_socket::{HarnessRunStatus, RunReport}; -use medulla::ui::agents::{AgentLane, AgentRole, AgentRow, TaskState, TaskStatus}; +use super::tests::stub_session; +use super::{task_row_serving, AgentGroup, AgentRailRow, SessionRailRow}; +use medulla::ui::agents::{TaskState, TaskStatus}; fn task_row(task_id: &str) -> SessionRailRow { SessionRailRow { @@ -95,200 +90,3 @@ fn a_settled_task_keeps_its_retained_session_on_a_row_of_its_own() { assert!(task_row_serving(&mut groups, "w_1", |_| None).is_none()); } - -#[test] -fn paging_starts_with_the_fold_running_first_task_order() { - let lane = AgentLane { - key: "agent:shell".into(), - label: "shell".into(), - role: AgentRole::Agent, - turns: Vec::new(), - last_at: 0, - tasks: vec![ - TaskState { - task_id: "completed-recently".into(), - status: TaskStatus::Done, - turns: 0, - last_at: 900, - turn_blocks: Vec::new(), - attention: None, - question_id: None, - work: None, - }, - TaskState { - task_id: "running-older".into(), - status: TaskStatus::Running, - turns: 0, - last_at: 100, - turn_blocks: Vec::new(), - attention: None, - question_id: None, - work: None, - }, - ], - context_tokens: None, - usage: Default::default(), - harness_label: None, - agent_id: Some("shell".into()), - session_id: None, - parent_agent_id: None, - descriptor: None, - active_tasks: 1, - work: None, - }; - - let group = app().group_for_lane(&lane, 0); - - assert_eq!( - group - .sessions - .iter() - .filter_map(|session| session.task.as_ref().map(|task| task.task_id.as_str())) - .collect::>(), - vec!["running-older", "completed-recently"], - "the first task page must match the fold's running-first order" - ); -} - -#[test] -fn paging_keeps_the_anchored_task_after_recent_sorting() { - let lanes = vec![AgentLane { - key: "agent:shell".into(), - label: "shell".into(), - role: AgentRole::Agent, - turns: Vec::new(), - last_at: 0, - tasks: Vec::new(), - context_tokens: None, - usage: Default::default(), - harness_label: None, - agent_id: Some("shell".into()), - session_id: None, - parent_agent_id: None, - descriptor: None, - active_tasks: 0, - work: None, - }]; - let mut owner = group(vec![ - task_row("newest"), - task_row("middle"), - task_row("selected"), - ]); - owner.sessions[0].task.as_mut().expect("task row").last_at = 900; - owner.sessions[1].task.as_mut().expect("task row").last_at = 500; - owner.sessions[2].task.as_mut().expect("task row").last_at = 100; - sort_sessions(&mut owner.sessions, SidebarSort::Recent); - owner.visible_tasks = 1; - owner.overflow = true; - let mut rows = Vec::new(); - - push_group( - &mut rows, - &mut owner, - false, - &medulla::control_socket::HarnessRunRegistry::new(), - &lanes, - Some(&RailAnchor::Task { - lane: "agent:shell".into(), - task_id: "selected".into(), - }), - ); - - assert!(rows.iter().any(|row| matches!( - row, - RailRow::Session(session) - if session.task.as_ref().is_some_and(|task| task.task_id == "selected") - ))); -} - -#[test] -fn paging_keeps_a_task_backed_session_with_an_active_workflow_run() { - let lanes = vec![AgentLane { - key: "agent:shell".into(), - label: "shell".into(), - role: AgentRole::Agent, - turns: Vec::new(), - last_at: 0, - tasks: Vec::new(), - context_tokens: None, - usage: Default::default(), - harness_label: None, - agent_id: Some("shell".into()), - session_id: None, - parent_agent_id: None, - descriptor: None, - active_tasks: 0, - work: None, - }]; - let mut active = task_row("active-workflow"); - let mut local = stub_session("pty-1"); - local.mcp_grant_session = Some("grant-1".into()); - active.local = Some(local); - let mut owner = group(vec![task_row("newest"), active]); - owner.visible_tasks = 1; - owner.overflow = true; - let runs = medulla::control_socket::HarnessRunRegistry::new(); - runs.report( - "grant-1", - RunReport { - run_id: "run-1".into(), - workflow_id: "workflow".into(), - status: HarnessRunStatus::Running, - detail: None, - node: None, - }, - ); - let mut rows = Vec::new(); - - push_group(&mut rows, &mut owner, false, &runs, &lanes, None); - - assert!(rows.iter().any(|row| matches!( - row, - RailRow::WorkflowRun(run) if run.run.run_id == "run-1" - ))); -} - -#[test] -fn paging_hides_the_overflow_action_when_retention_shows_every_task() { - let lanes = vec![AgentLane { - key: "agent:shell".into(), - label: "shell".into(), - role: AgentRole::Agent, - turns: Vec::new(), - last_at: 0, - tasks: Vec::new(), - context_tokens: None, - usage: Default::default(), - harness_label: None, - agent_id: Some("shell".into()), - session_id: None, - parent_agent_id: None, - descriptor: None, - active_tasks: 0, - work: None, - }]; - let mut owner = group(vec![task_row("first"), task_row("pinned")]); - owner.visible_tasks = 1; - owner.hidden = 1; - owner.overflow = true; - let mut rows = Vec::new(); - - push_group( - &mut rows, - &mut owner, - false, - &medulla::control_socket::HarnessRunRegistry::new(), - &lanes, - Some(&RailAnchor::Task { - lane: "agent:shell".into(), - task_id: "pinned".into(), - }), - ); - - assert!( - !rows - .iter() - .any(|row| matches!(row, RailRow::Lane(AgentRow::More { .. }))), - "the overflow action is absent when retaining a task reveals every task" - ); -} diff --git a/src/tui/src/ui/app/rail/paging.rs b/src/tui/src/ui/app/rail/paging/mod.rs similarity index 99% rename from src/tui/src/ui/app/rail/paging.rs rename to src/tui/src/ui/app/rail/paging/mod.rs index f3881146c..114a2c626 100644 --- a/src/tui/src/ui/app/rail/paging.rs +++ b/src/tui/src/ui/app/rail/paging/mod.rs @@ -77,3 +77,6 @@ pub(super) fn push_group( }); } } + +#[cfg(test)] +mod tests; diff --git a/src/tui/src/ui/app/rail/paging/tests.rs b/src/tui/src/ui/app/rail/paging/tests.rs new file mode 100644 index 000000000..e07e2c58b --- /dev/null +++ b/src/tui/src/ui/app/rail/paging/tests.rs @@ -0,0 +1,198 @@ +//! Tests for session paging, retention, and overflow rendering. + +use super::super::organize::sort_sessions; +use super::super::tests::app; +use super::super::{AgentGroup, AgentRailRow, RailAnchor, RailRow, SessionRailRow}; +use super::push_group; +use medulla::config::SidebarSort; +use medulla::control_socket::{HarnessRunStatus, RunReport}; +use medulla::ui::agents::{AgentLane, AgentRole, AgentRow, TaskState, TaskStatus}; + +fn task_row(task_id: &str) -> SessionRailRow { + SessionRailRow { + agent_id: Some("shell".to_string()), + lane_index: Some(0), + task: Some(TaskState { + task_id: task_id.to_string(), + status: TaskStatus::Running, + turns: 0, + last_at: 0, + turn_blocks: Vec::new(), + attention: None, + question_id: None, + work: None, + }), + local: None, + last: false, + } +} + +fn group(sessions: Vec) -> AgentGroup { + AgentGroup { + row: AgentRailRow { + agent_id: "shell".to_string(), + host_id: String::new(), + agent: None, + lane_index: Some(0), + }, + sessions, + last_at: 0, + visible_tasks: 0, + hidden: 0, + overflow: false, + } +} + +fn shell_lane() -> AgentLane { + AgentLane { + key: "agent:shell".into(), + label: "shell".into(), + role: AgentRole::Agent, + turns: Vec::new(), + last_at: 0, + tasks: Vec::new(), + context_tokens: None, + usage: Default::default(), + harness_label: None, + agent_id: Some("shell".into()), + session_id: None, + parent_agent_id: None, + descriptor: None, + active_tasks: 0, + work: None, + } +} + +#[test] +fn paging_starts_with_the_fold_running_first_task_order() { + let mut lane = shell_lane(); + lane.tasks = vec![ + TaskState { + task_id: "completed-recently".into(), + status: TaskStatus::Done, + turns: 0, + last_at: 900, + turn_blocks: Vec::new(), + attention: None, + question_id: None, + work: None, + }, + TaskState { + task_id: "running-older".into(), + status: TaskStatus::Running, + turns: 0, + last_at: 100, + turn_blocks: Vec::new(), + attention: None, + question_id: None, + work: None, + }, + ]; + + let group = app().group_for_lane(&lane, 0); + + assert_eq!( + group + .sessions + .iter() + .filter_map(|session| session.task.as_ref().map(|task| task.task_id.as_str())) + .collect::>(), + vec!["running-older", "completed-recently"], + "the first task page must match the fold's running-first order" + ); +} + +#[test] +fn paging_keeps_the_anchored_task_after_recent_sorting() { + let lanes = vec![shell_lane()]; + let mut owner = group(vec![ + task_row("newest"), + task_row("middle"), + task_row("selected"), + ]); + owner.sessions[0].task.as_mut().expect("task row").last_at = 900; + owner.sessions[1].task.as_mut().expect("task row").last_at = 500; + owner.sessions[2].task.as_mut().expect("task row").last_at = 100; + sort_sessions(&mut owner.sessions, SidebarSort::Recent); + owner.visible_tasks = 1; + owner.overflow = true; + let mut rows = Vec::new(); + + push_group( + &mut rows, + &mut owner, + false, + &medulla::control_socket::HarnessRunRegistry::new(), + &lanes, + Some(&RailAnchor::Task { + lane: "agent:shell".into(), + task_id: "selected".into(), + }), + ); + + assert!(rows.iter().any(|row| matches!( + row, + RailRow::Session(session) + if session.task.as_ref().is_some_and(|task| task.task_id == "selected") + ))); +} + +#[test] +fn paging_keeps_a_task_backed_session_with_an_active_workflow_run() { + let lanes = vec![shell_lane()]; + let mut active = task_row("active-workflow"); + let mut local = super::super::tests::stub_session("pty-1"); + local.mcp_grant_session = Some("grant-1".into()); + active.local = Some(local); + let mut owner = group(vec![task_row("newest"), active]); + owner.visible_tasks = 1; + owner.overflow = true; + let runs = medulla::control_socket::HarnessRunRegistry::new(); + runs.report( + "grant-1", + RunReport { + run_id: "run-1".into(), + workflow_id: "workflow".into(), + status: HarnessRunStatus::Running, + detail: None, + node: None, + }, + ); + let mut rows = Vec::new(); + + push_group(&mut rows, &mut owner, false, &runs, &lanes, None); + + assert!(rows.iter().any(|row| matches!( + row, + RailRow::WorkflowRun(run) if run.run.run_id == "run-1" + ))); +} + +#[test] +fn paging_hides_the_overflow_action_when_retention_shows_every_task() { + let lanes = vec![shell_lane()]; + let mut owner = group(vec![task_row("first"), task_row("pinned")]); + owner.visible_tasks = 1; + owner.hidden = 1; + owner.overflow = true; + let mut rows = Vec::new(); + + push_group( + &mut rows, + &mut owner, + false, + &medulla::control_socket::HarnessRunRegistry::new(), + &lanes, + Some(&RailAnchor::Task { + lane: "agent:shell".into(), + task_id: "pinned".into(), + }), + ); + + assert!( + !rows + .iter() + .any(|row| matches!(row, RailRow::Lane(AgentRow::More { .. }))), + "the overflow action is absent when retaining a task reveals every task" + ); +} From 59b6955a49d013fe112be752248492afc6dfcff6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 22:49:32 +0300 Subject: [PATCH 11/29] fix(rail): group harnesses case-insensitively Co-authored-by: Medulla --- src/tui/src/ui/app/rail/organize/mod.rs | 22 ++++++++++++++++------ src/tui/src/ui/app/rail/organize/tests.rs | 16 ++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/tui/src/ui/app/rail/organize/mod.rs b/src/tui/src/ui/app/rail/organize/mod.rs index 23ee1213e..3dd1c36dc 100644 --- a/src/tui/src/ui/app/rail/organize/mod.rs +++ b/src/tui/src/ui/app/rail/organize/mod.rs @@ -42,10 +42,16 @@ pub(super) fn organize( ) -> Vec
{ let mut sections = match grouping { SidebarGrouping::Host => by_host(hosts), - SidebarGrouping::Path => by_key(flatten_agents(hosts, declarations, sort), agent_path), - SidebarGrouping::Harness => { - by_key(flatten_agents(hosts, declarations, sort), agent_harness) - } + SidebarGrouping::Path => by_key( + flatten_agents(hosts, declarations, sort), + agent_path, + str::eq, + ), + SidebarGrouping::Harness => by_key( + flatten_agents(hosts, declarations, sort), + agent_harness, + str::eq_ignore_ascii_case, + ), SidebarGrouping::None => vec![Section { header: SectionHeader::None, agents: flatten_agents(hosts, declarations, sort), @@ -82,12 +88,16 @@ fn by_host(hosts: Vec) -> Vec
{ } /// Section every agent by one derived key, preserving first-seen order. -fn by_key(agents: Vec, key: fn(&AgentGroup) -> String) -> Vec
{ +fn by_key( + agents: Vec, + key: fn(&AgentGroup) -> String, + keys_match: fn(&str, &str) -> bool, +) -> Vec
{ let mut sections: Vec
= Vec::new(); for agent in agents { let label = key(&agent); match sections.iter_mut().find(|section| match §ion.header { - SectionHeader::Group(group) => group.label == label, + SectionHeader::Group(group) => keys_match(&group.label, &label), _ => false, }) { Some(section) => section.agents.push(agent), diff --git a/src/tui/src/ui/app/rail/organize/tests.rs b/src/tui/src/ui/app/rail/organize/tests.rs index b4291a11c..15e2c4b25 100644 --- a/src/tui/src/ui/app/rail/organize/tests.rs +++ b/src/tui/src/ui/app/rail/organize/tests.rs @@ -127,6 +127,22 @@ fn grouping_by_harness_sections_by_the_cli_each_agent_runs() { .any(|(label, agents)| label.as_deref() == Some("codex") && agents == &["acorn"])); } +#[test] +fn grouping_by_harness_ignores_ascii_case() { + let mut app = app_with_agents(); + app.loaded.config.appearance.sidebar_grouping = SidebarGrouping::Harness; + app.loaded.config.fleet.agent_declarations[2].harness = "Claude".into(); + + assert_eq!( + sections(&app), + vec![ + (Some("claude".into()), vec!["zed".into(), "mint".into()]), + (Some("codex".into()), vec!["acorn".into()]), + ], + "the first configured spelling remains the label for one shared harness section" + ); +} + #[test] fn grouping_by_none_lists_every_agent_without_a_header() { let mut app = app_with_agents(); From d98286ef3f16f3fc5c5598a1f58e47f68ff88f99 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 23:15:19 +0300 Subject: [PATCH 12/29] fix(rail): preserve peer grouping metadata Co-authored-by: Medulla --- src/tui/src/ui/app/rail/merge_tests.rs | 2 + src/tui/src/ui/app/rail/mod.rs | 44 ++-------------- src/tui/src/ui/app/rail/organize/mod.rs | 3 +- src/tui/src/ui/app/rail/organize/tests.rs | 30 +++++++++++ src/tui/src/ui/app/rail/paging/tests.rs | 2 + src/tui/src/ui/app/rail/types.rs | 63 +++++++++++++++++++++++ 6 files changed, 104 insertions(+), 40 deletions(-) diff --git a/src/tui/src/ui/app/rail/merge_tests.rs b/src/tui/src/ui/app/rail/merge_tests.rs index 54920b773..a5f272fb0 100644 --- a/src/tui/src/ui/app/rail/merge_tests.rs +++ b/src/tui/src/ui/app/rail/merge_tests.rs @@ -40,6 +40,8 @@ fn group(sessions: Vec) -> AgentGroup { }, sessions, last_at: 0, + lane_label: None, + harness_label: None, visible_tasks: 0, hidden: 0, overflow: false, diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index 15254ad3e..407a16d2e 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -60,6 +60,7 @@ mod types; pub(in crate::ui::app) use cursor::rail_anchor; #[cfg(test)] pub(in crate::ui::app) use cursor::resolve_rail_cursor; +use types::{AgentGroup, HostGroup}; pub use types::{ AgentRailRow, GroupRailRow, HostRailRow, RailAnchor, RailRow, SessionRailRow, WorkflowRunRailRow, @@ -77,45 +78,6 @@ pub(in crate::ui::app) const NEW_AGENT_LABEL: &str = "+ New agent"; /// one agent's group rather than an action on the machine. pub(in crate::ui::app) const NEW_SESSION_LABEL: &str = "+ new session"; -/// One agent and the sessions hanging off it, before the tree is flattened. -struct AgentGroup { - /// The agent row itself. - row: AgentRailRow, - /// Its sessions, dispatched and operator-started alike. - sessions: Vec, - /// The most recent lane-level event, including activity with no task row. - /// - /// Peer-session lanes carry transcript activity directly on the lane rather - /// than as a task. Preserve that timestamp so recent sorting still moves a - /// peer agent whose output is changing. - last_at: i64, - /// How many task-backed sessions the fold has currently revealed. - /// - /// The complete task set stays here until [`organize`] applies the chosen - /// order. Keeping this boundary separate from the task data means a name - /// or activity sort cannot be applied only to the first visible page. - visible_tasks: usize, - /// Sessions the fold's own page already hid, carried so the counts add up. - hidden: usize, - /// Whether the fold drew an overflow row under this agent's lane. - /// - /// The rail does **not** re-cap what the fold already paged (#171): the fold - /// reveals `SUBTASK_PAGE` sessions per page and decides when the `+N more` - /// row exists, including the fully-revealed case where it is instead the - /// `show less` control and `hidden` is zero. A second cap here would clip - /// below the page the operator just asked for, so this only records that the - /// row is owed. - overflow: bool, -} - -/// One host and the agents placed on it, before the tree is flattened. -struct HostGroup { - /// The host row, drawn only once there is more than one of them. - row: HostRailRow, - /// Its agents, in the order the shared projection lists them. - agents: Vec, -} - impl App { /// The agent declarations this machine's config records. /// @@ -252,6 +214,8 @@ impl App { }) .collect(), last_at: lane.last_at, + lane_label: Some(lane.label.clone()), + harness_label: lane.harness_label.clone(), visible_tasks: 0, hidden: 0, overflow: false, @@ -554,6 +518,8 @@ fn placed_agent( }, sessions: Vec::new(), last_at: 0, + lane_label: None, + harness_label: None, visible_tasks: 0, hidden: 0, overflow: false, diff --git a/src/tui/src/ui/app/rail/organize/mod.rs b/src/tui/src/ui/app/rail/organize/mod.rs index 3dd1c36dc..f2d96d8c4 100644 --- a/src/tui/src/ui/app/rail/organize/mod.rs +++ b/src/tui/src/ui/app/rail/organize/mod.rs @@ -166,6 +166,7 @@ fn agent_harness(agent: &AgentGroup) -> String { agent .row .harness() + .or(agent.harness_label.as_deref()) .map(str::trim) .filter(|harness| !harness.is_empty()) .map_or_else(|| "no harness".to_string(), str::to_string) @@ -215,7 +216,7 @@ fn sort_agents(agents: &mut [AgentGroup], sort: SidebarSort) { SidebarSort::Recent => { agents.sort_by_key(|agent| std::cmp::Reverse(agent_activity(agent))); } - SidebarSort::Name => agents.sort_by_key(|agent| agent.row.label().to_lowercase()), + SidebarSort::Name => agents.sort_by_key(|agent| agent.label().to_lowercase()), } } diff --git a/src/tui/src/ui/app/rail/organize/tests.rs b/src/tui/src/ui/app/rail/organize/tests.rs index 15e2c4b25..5c07a726f 100644 --- a/src/tui/src/ui/app/rail/organize/tests.rs +++ b/src/tui/src/ui/app/rail/organize/tests.rs @@ -305,6 +305,8 @@ fn active_agent(label: &str, last_at: i64) -> AgentGroup { last: false, }], last_at, + lane_label: None, + harness_label: None, visible_tasks: 1, hidden: 0, overflow: false, @@ -322,6 +324,8 @@ fn peer_agent(label: &str, last_at: i64) -> AgentGroup { }, sessions: Vec::new(), last_at, + lane_label: Some(label.into()), + harness_label: None, visible_tasks: 0, hidden: 0, overflow: false, @@ -358,6 +362,32 @@ fn recent_sorts_agents_by_peer_lane_activity() { ); } +#[test] +fn name_sorts_lane_only_agents_by_their_displayed_labels() { + let mut zulu = peer_agent("opaque-a", 0); + zulu.lane_label = Some("Zulu".into()); + let mut alpha = peer_agent("opaque-z", 0); + alpha.lane_label = Some("Alpha".into()); + + let mut agents = vec![zulu, alpha]; + sort_agents(&mut agents, SidebarSort::Name); + + assert_eq!(agents[0].row.agent_id, "opaque-z"); +} + +#[test] +fn harness_grouping_uses_a_lane_only_agents_reported_harness() { + let mut peer = peer_agent("peer", 0); + peer.harness_label = Some("CODEX".into()); + + let sections = super::by_key(vec![peer], super::agent_harness, str::eq_ignore_ascii_case); + + assert!(matches!( + §ions[0].header, + SectionHeader::Group(group) if group.label == "CODEX" + )); +} + #[test] fn recent_sorts_sections_by_peer_lane_activity() { let mut sections = vec![ diff --git a/src/tui/src/ui/app/rail/paging/tests.rs b/src/tui/src/ui/app/rail/paging/tests.rs index e07e2c58b..d53016ecd 100644 --- a/src/tui/src/ui/app/rail/paging/tests.rs +++ b/src/tui/src/ui/app/rail/paging/tests.rs @@ -37,6 +37,8 @@ fn group(sessions: Vec) -> AgentGroup { }, sessions, last_at: 0, + lane_label: None, + harness_label: None, visible_tasks: 0, hidden: 0, overflow: false, diff --git a/src/tui/src/ui/app/rail/types.rs b/src/tui/src/ui/app/rail/types.rs index 0abf54f9d..bcacecba7 100644 --- a/src/tui/src/ui/app/rail/types.rs +++ b/src/tui/src/ui/app/rail/types.rs @@ -102,6 +102,69 @@ pub struct AgentRailRow { pub lane_index: Option, } +/// One agent and the sessions hanging off it, before the tree is flattened. +/// +/// This preserves lane-only metadata that the shared host projection cannot +/// provide, so peer sessions group and sort by the labels they actually report. +#[derive(Debug, Clone)] +pub(super) struct AgentGroup { + /// The agent row itself. + pub(super) row: AgentRailRow, + /// Its sessions, dispatched and operator-started alike. + pub(super) sessions: Vec, + /// The most recent lane-level event, including activity with no task row. + /// + /// Peer-session lanes carry transcript activity directly on the lane rather + /// than as a task. Preserve that timestamp so recent sorting still moves a + /// peer agent whose output is changing. + pub(super) last_at: i64, + /// The display label reported by a lane-only agent. + pub(super) lane_label: Option, + /// The harness label reported by a lane-only agent. + pub(super) harness_label: Option, + /// How many task-backed sessions the fold has currently revealed. + /// + /// The complete task set stays here until organization applies the chosen + /// order. Keeping this boundary separate from task data means a name or + /// activity sort cannot be applied only to the first visible page. + pub(super) visible_tasks: usize, + /// Sessions the fold's own page already hid, carried so the counts add up. + pub(super) hidden: usize, + /// Whether the fold drew an overflow row under this agent's lane. + /// + /// The rail does not re-cap what the fold already paged: the fold reveals a + /// page of sessions and owns both the `+N more` and `show less` states. A + /// second cap here would clip below the page the operator selected. + pub(super) overflow: bool, +} + +impl AgentGroup { + /// The name to use for ordering this agent in the rail. + /// + /// A declared agent takes its shared-projection label. A lane-only agent + /// instead uses the display label its reporting peer supplied, before + /// falling back to the stable id. + pub(super) fn label(&self) -> String { + if self.row.agent.is_some() { + self.row.label() + } else { + self.lane_label + .clone() + .filter(|label| !label.trim().is_empty()) + .unwrap_or_else(|| self.row.label()) + } + } +} + +/// One host and its agents before the tree is flattened. +#[derive(Debug, Clone)] +pub(super) struct HostGroup { + /// The host row, drawn only once there is more than one of them. + pub(super) row: HostRailRow, + /// Its agents, in the order the shared projection lists them. + pub(super) agents: Vec, +} + impl AgentRailRow { /// What to call this agent: whatever the shared projection resolved (its /// declared name, else its roster label), falling back to the id itself. From e2062397118d48cd3ecd0a7ad2c680a049fb8e93 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 23:40:16 +0300 Subject: [PATCH 13/29] fix(rail): sort sessions by terminal title Co-authored-by: Medulla --- src/tui/src/ui/app/rail/organize/mod.rs | 12 ++++++++++-- src/tui/src/ui/app/rail/organize/tests.rs | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/tui/src/ui/app/rail/organize/mod.rs b/src/tui/src/ui/app/rail/organize/mod.rs index f2d96d8c4..134b79ef0 100644 --- a/src/tui/src/ui/app/rail/organize/mod.rs +++ b/src/tui/src/ui/app/rail/organize/mod.rs @@ -262,7 +262,7 @@ fn session_activity(session: &SessionRailRow) -> i64 { } /// What a session sorts as by name: what the operator called it, else its task, -/// else the pty's own label. +/// then its terminal title, else the pty's own label. fn session_label(session: &SessionRailRow) -> String { if let Some(name) = session.name() { return name.to_string(); @@ -273,7 +273,15 @@ fn session_label(session: &SessionRailRow) -> String { session .local .as_ref() - .map(|local| local.label.clone()) + .map(|local| { + local + .thread_name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or(&local.label) + .to_string() + }) .unwrap_or_default() } diff --git a/src/tui/src/ui/app/rail/organize/tests.rs b/src/tui/src/ui/app/rail/organize/tests.rs index 5c07a726f..35cf0e787 100644 --- a/src/tui/src/ui/app/rail/organize/tests.rs +++ b/src/tui/src/ui/app/rail/organize/tests.rs @@ -279,6 +279,24 @@ fn name_sorts_sessions_by_what_the_operator_called_them() { ); } +#[test] +fn name_sorts_local_sessions_by_their_visible_thread_titles() { + let mut zulu = session("zulu", 100, 100); + let mut alpha = session("alpha", 200, 200); + for session in [&mut zulu, &mut alpha] { + let local = session.local.as_mut().expect("live pty session"); + local.name = None; + } + zulu.local.as_mut().expect("live pty session").thread_name = Some("Zulu thread".into()); + alpha.local.as_mut().expect("live pty session").thread_name = Some("Alpha thread".into()); + + assert_eq!( + sorted(vec![zulu, alpha], SidebarSort::Name), + vec!["alpha", "zulu"], + "local sessions without launch names sort by the titles visible in the rail" + ); +} + /// An agent group with one task whose activity timestamp controls recent order. fn active_agent(label: &str, last_at: i64) -> AgentGroup { AgentGroup { From 41297e6a13d1a819e235692315034a908c566d35 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 00:05:57 +0300 Subject: [PATCH 14/29] fix(rail): avoid redundant task row clones Co-authored-by: Medulla --- src/tui/src/ui/app/input/mod.rs | 2 ++ src/tui/src/ui/app/input/nav.rs | 16 +++------ src/tui/src/ui/app/rail/mod.rs | 43 +++++++++++-------------- src/tui/src/ui/app/rail/paging/tests.rs | 24 ++++++++++++++ 4 files changed, 49 insertions(+), 36 deletions(-) diff --git a/src/tui/src/ui/app/input/mod.rs b/src/tui/src/ui/app/input/mod.rs index 38e645f32..3971eff3c 100644 --- a/src/tui/src/ui/app/input/mod.rs +++ b/src/tui/src/ui/app/input/mod.rs @@ -17,6 +17,8 @@ mod paste; #[cfg(test)] mod tests; +pub(in crate::ui::app) use nav::SUBTASK_PAGE; + impl App { /// Route a terminal event to the key, mouse, or paste handler, producing any /// command the event loop must run. diff --git a/src/tui/src/ui/app/input/nav.rs b/src/tui/src/ui/app/input/nav.rs index 612531f03..0189c5284 100644 --- a/src/tui/src/ui/app/input/nav.rs +++ b/src/tui/src/ui/app/input/nav.rs @@ -8,7 +8,9 @@ use super::super::rail::RailRow; use super::super::types::{App, Cmd}; -use crate::ui::agents::{agent_row_model_paged, AgentRole, AgentRow, TaskStatus}; +#[cfg(test)] +use crate::ui::agents::agent_row_model_paged; +use crate::ui::agents::{AgentRole, AgentRow, TaskStatus}; use crate::ui::composer::Draft; /// How many of a lane's task sublanes one page reveals. @@ -25,15 +27,7 @@ impl App { #[cfg(test)] pub(in crate::ui::app) fn agent_rows(&self) -> Vec { let lanes = self.lanes(); - self.agent_rows_in(&lanes) - } - - /// Build paged fold rows from one already-captured lane snapshot. - pub(in crate::ui::app) fn agent_rows_in( - &self, - lanes: &[crate::ui::agents::AgentLane], - ) -> Vec { - agent_row_model_paged(lanes, SUBTASK_PAGE, |lane| { + agent_row_model_paged(&lanes, SUBTASK_PAGE, |lane| { self.subtask_pages.get(&lane.key).copied().unwrap_or(0) }) } @@ -90,7 +84,7 @@ impl App { } /// How many sublanes a lane reveals at its current expansion. - fn revealed_subtasks(&self, key: &str) -> usize { + pub(in crate::ui::app) fn revealed_subtasks(&self, key: &str) -> usize { SUBTASK_PAGE.saturating_mul( self.subtask_pages .get(key) diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index 407a16d2e..b11c63e0a 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -37,6 +37,7 @@ use medulla::ui::hosts::{HostAgentRow, HostKind, HostRow}; use super::types::App; use crate::ui::agents::{ordered_tasks, AgentLane, AgentRole, AgentRow}; +use crate::ui::app::input::SUBTASK_PAGE; use crate::worker::pty::SessionRow; mod cleanup; @@ -149,32 +150,24 @@ impl App { fn split_fold(&self, lanes: &[AgentLane]) -> (Vec, Vec) { let mut lane_rows: Vec = Vec::new(); let mut groups: Vec = Vec::new(); - for row in self.agent_rows_in(lanes) { - match row { - AgentRow::Lane { lane_index } => { - let Some(lane) = lanes.get(lane_index) else { - continue; - }; - if lane.role != AgentRole::Agent { - lane_rows.push(row); - continue; - } - groups.push(self.group_for_lane(lane, lane_index)); - } - AgentRow::Sub { .. } => { - let Some(group) = groups.last_mut() else { - continue; - }; - group.visible_tasks += 1; - } - AgentRow::More { hidden, .. } => { - if let Some(group) = groups.last_mut() { - group.hidden += hidden; - group.overflow = true; - } - } - AgentRow::Separator => lane_rows.push(row), + let first_function = lanes.iter().position(|lane| lane.role.is_function()); + for (lane_index, lane) in lanes.iter().enumerate() { + if Some(lane_index) == first_function { + lane_rows.push(AgentRow::Separator); } + if lane.role != AgentRole::Agent { + lane_rows.push(AgentRow::Lane { lane_index }); + continue; + } + + let mut group = self.group_for_lane(lane, lane_index); + if lane.key.starts_with("agent:") && !lane.tasks.is_empty() { + let task_limit = self.revealed_subtasks(&lane.key); + group.visible_tasks = lane.tasks.len().min(task_limit); + group.hidden = lane.tasks.len() - group.visible_tasks; + group.overflow = group.hidden > 0 || group.visible_tasks > SUBTASK_PAGE; + } + groups.push(group); } (lane_rows, groups) } diff --git a/src/tui/src/ui/app/rail/paging/tests.rs b/src/tui/src/ui/app/rail/paging/tests.rs index d53016ecd..628e90c0f 100644 --- a/src/tui/src/ui/app/rail/paging/tests.rs +++ b/src/tui/src/ui/app/rail/paging/tests.rs @@ -65,6 +65,30 @@ fn shell_lane() -> AgentLane { } } +#[test] +fn split_fold_derives_the_first_page_counts_from_the_lane_tasks() { + let mut lane = shell_lane(); + lane.tasks = (0..11) + .map(|index| TaskState { + task_id: format!("task-{index}"), + status: TaskStatus::Done, + turns: 0, + last_at: index, + turn_blocks: Vec::new(), + attention: None, + question_id: None, + work: None, + }) + .collect(); + + let (_, groups) = app().split_fold(&[lane]); + let group = groups.first().expect("the agent lane becomes one group"); + + assert_eq!(group.visible_tasks, 10); + assert_eq!(group.hidden, 1); + assert!(group.overflow); +} + #[test] fn paging_starts_with_the_fold_running_first_task_order() { let mut lane = shell_lane(); From d7f6b3b0b1016b3060d476be8b1a90d8aafdc311 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 00:33:34 +0300 Subject: [PATCH 15/29] feat(rail): add grouping headers for sessions by workspace or harness Introduce a new `GroupRailRow` variant to display section headings when sessions are grouped by workspace or harness, along with internal `AgentGroup` and `HostGroup` structures to organize agents and sessions before flattening the rail. This prepares the rail for grouped rendering while keeping existing row behavior unchanged. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/tui/src/ui/app/rail/types.rs | 76 +++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/src/tui/src/ui/app/rail/types.rs b/src/tui/src/ui/app/rail/types.rs index fbea259c7..099b5e17e 100644 --- a/src/tui/src/ui/app/rail/types.rs +++ b/src/tui/src/ui/app/rail/types.rs @@ -62,6 +62,13 @@ pub struct HostRailRow { pub local: bool, } +/// A section heading when sessions are grouped by workspace or harness. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GroupRailRow { + /// The workspace or harness shared by the sessions below this heading. + pub label: String, +} + /// One agent — `harness × workspace` on a host — resolved but never rendered. /// /// The rail groups sessions by agent and takes each session's lane from the @@ -90,6 +97,69 @@ pub struct AgentRailRow { pub lane_index: Option, } +/// One agent and its sessions before grouping and sorting flatten the rail. +#[derive(Debug, Clone)] +pub(super) struct AgentGroup { + /// The resolved agent identity. + pub(super) row: AgentRailRow, + /// Sessions belonging to the agent. + pub(super) sessions: Vec, + /// Latest lane activity, including peer-lane activity without a task row. + pub(super) last_at: i64, + /// Display label supplied by a lane-only peer agent. + pub(super) lane_label: Option, + /// Harness label supplied by a lane-only peer agent. + pub(super) harness_label: Option, + /// Sessions hidden by the fold's paging boundary. + pub(super) hidden: usize, + /// Whether the fold supplied an overflow control for this group. + pub(super) overflow: bool, +} + +impl AgentGroup { + /// The ordering label, preferring a peer lane's display label when needed. + pub(super) fn label(&self) -> String { + if self.row.agent.is_some() { + self.row.label() + } else { + self.lane_label + .clone() + .filter(|label| !label.trim().is_empty()) + .unwrap_or_else(|| self.row.label()) + } + } +} + +/// One host and its resolved agents before the rail is flattened. +#[derive(Debug, Clone)] +pub(super) struct HostGroup { + /// The host header. + pub(super) row: HostRailRow, + /// Agents that belong to the host. + pub(super) agents: Vec, +} + +impl AgentRailRow { + /// The resolved display label, falling back to the stable agent id. + pub fn label(&self) -> String { + self.agent + .as_ref() + .map(|agent| agent.label.clone()) + .filter(|label| !label.trim().is_empty()) + .unwrap_or_else(|| self.agent_id.clone()) + } + + /// The declared harness, when the shared host projection knows it. + pub fn harness(&self) -> Option<&str> { + self.agent.as_ref()?.harness.as_deref() + } + + /// The declared workspace, when the shared host projection knows it. + pub fn workspace(&self) -> Option<&str> { + self.agent.as_ref()?.workspace.as_deref() + } +} + /// One session of an agent — **one row type, whatever started it**. /// /// An orchestrator dispatch arrives as a [`TaskState`] folded from the event @@ -163,6 +233,8 @@ pub struct WorkflowRunRailRow { pub enum RailRow { /// A host header, emitted only once a remote host exists. Host(HostRailRow), + /// A workspace or harness grouping header. + Group(GroupRailRow), /// One session running on the host above it. /// /// Boxed because a session row carries both a whole [`TaskState`] and a @@ -201,7 +273,7 @@ impl RailRow { /// Whether the cursor may land on this row. pub fn selectable(&self) -> bool { match self { - RailRow::Host(_) => false, + RailRow::Host(_) | RailRow::Group(_) => false, RailRow::Session(_) => true, RailRow::NewSession => true, // Selectable so `Enter` can open the workflow it belongs to; the @@ -253,7 +325,7 @@ impl RailRow { match self { RailRow::Session(row) => row.lane_index, RailRow::Overflow { lane_index, .. } => Some(*lane_index), - RailRow::Host(_) | RailRow::NewSession | RailRow::WorkflowRun(_) => None, + RailRow::Host(_) | RailRow::Group(_) | RailRow::NewSession | RailRow::WorkflowRun(_) => None, } } From 9e1962ae4dacaa15b103a5c9f12e39402319168b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 00:38:48 +0300 Subject: [PATCH 16/29] refactor(rail): extract rail organization into a dedicated module The rail's grouping and sorting logic is moved into a new `organize` module, separating it from the main rail assembly code. This change also introduces support for group section headers in the rail display, allowing agents to be organized under configurable group labels rather than only by host. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/tui/src/ui/app/input/mod.rs | 2 - src/tui/src/ui/app/rail/cursor.rs | 2 +- src/tui/src/ui/app/rail/merge_tests.rs | 1 - src/tui/src/ui/app/rail/mod.rs | 67 ++++++++--------- src/tui/src/ui/app/rail/organize/tests.rs | 73 ++++++++++++------- src/tui/src/ui/app/rail/tests.rs | 6 +- src/tui/src/ui/app/rail/types.rs | 5 +- .../src/ui/app/render/sessions/rail/mod.rs | 6 ++ 8 files changed, 91 insertions(+), 71 deletions(-) diff --git a/src/tui/src/ui/app/input/mod.rs b/src/tui/src/ui/app/input/mod.rs index 3971eff3c..38e645f32 100644 --- a/src/tui/src/ui/app/input/mod.rs +++ b/src/tui/src/ui/app/input/mod.rs @@ -17,8 +17,6 @@ mod paste; #[cfg(test)] mod tests; -pub(in crate::ui::app) use nav::SUBTASK_PAGE; - impl App { /// Route a terminal event to the key, mouse, or paste handler, producing any /// command the event loop must run. diff --git a/src/tui/src/ui/app/rail/cursor.rs b/src/tui/src/ui/app/rail/cursor.rs index 9c4e02e49..74b98dc39 100644 --- a/src/tui/src/ui/app/rail/cursor.rs +++ b/src/tui/src/ui/app/rail/cursor.rs @@ -35,7 +35,7 @@ pub(in crate::ui::app) fn rail_anchor(row: &RailRow, lanes: &[AgentLane]) -> Opt RailRow::Overflow { lane_index, .. } => lanes .get(*lane_index) .map(|lane| RailAnchor::Overflow(lane.key.clone())), - RailRow::Host(_) => None, + RailRow::Host(_) | RailRow::Group(_) => None, } } diff --git a/src/tui/src/ui/app/rail/merge_tests.rs b/src/tui/src/ui/app/rail/merge_tests.rs index a5f272fb0..0ceeceeda 100644 --- a/src/tui/src/ui/app/rail/merge_tests.rs +++ b/src/tui/src/ui/app/rail/merge_tests.rs @@ -42,7 +42,6 @@ fn group(sessions: Vec) -> AgentGroup { last_at: 0, lane_label: None, harness_label: None, - visible_tasks: 0, hidden: 0, overflow: false, } diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index f02e09d7b..280f8278d 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -44,6 +44,7 @@ mod cleanup_tests; mod cursor; #[cfg(test)] mod cursor_tests; +mod organize; pub(in crate::ui::app) mod resolve; // Kept apart from `tests` rather than nested inside it: the assembly rules and // the served-dispatch merge are separate responsibilities, and one file for @@ -57,8 +58,10 @@ mod types; pub(in crate::ui::app) use cursor::rail_anchor; #[cfg(test)] pub(in crate::ui::app) use cursor::resolve_rail_cursor; +use types::{AgentGroup, HostGroup}; pub use types::{ - AgentRailRow, HostRailRow, RailAnchor, RailRow, SessionRailRow, WorkflowRunRailRow, + AgentRailRow, GroupRailRow, HostRailRow, RailAnchor, RailRow, SessionRailRow, + WorkflowRunRailRow, }; /// The label on the rail's "open a session" row. @@ -67,33 +70,6 @@ pub use types::{ /// as one: it acts on the machine, not on a row beneath it. pub(in crate::ui::app) const NEW_SESSION_LABEL: &str = "+ New session"; -/// One agent and the sessions hanging off it, before the tree is flattened. -struct AgentGroup { - /// The agent row itself. - row: AgentRailRow, - /// Its sessions, dispatched and operator-started alike. - sessions: Vec, - /// Sessions the fold's own page already hid, carried so the counts add up. - hidden: usize, - /// Whether the fold drew an overflow row under this agent's lane. - /// - /// The rail does **not** re-cap what the fold already paged (#171): the fold - /// reveals `SUBTASK_PAGE` sessions per page and decides when the `+N more` - /// row exists, including the fully-revealed case where it is instead the - /// `show less` control and `hidden` is zero. A second cap here would clip - /// below the page the operator just asked for, so this only records that the - /// row is owed. - overflow: bool, -} - -/// One host and the agents placed on it, before the tree is flattened. -struct HostGroup { - /// The host row, drawn only once there is more than one of them. - row: HostRailRow, - /// Its agents, in the order the shared projection lists them. - agents: Vec, -} - impl App { /// The agent declarations this machine's config records. /// @@ -149,8 +125,16 @@ impl App { pub(super) fn rail_rows_in(&self, lanes: &[AgentLane]) -> Vec { let folded = self.split_fold(lanes); let mut hosts = place_agents(&self.host_tree(), folded); - let orphans = self.attach_sessions(&mut hosts); - self.flatten(hosts, orphans) + let mut orphans = self.attach_sessions(&mut hosts); + let appearance = &self.loaded.config.appearance; + let sections = organize::organize( + hosts, + self.agent_declarations(), + appearance.sidebar_grouping, + appearance.sidebar_sort, + ); + organize::sort_sessions(&mut orphans, appearance.sidebar_sort); + self.flatten(sections, orphans) } /// Fold the lane rows into per-agent groups. @@ -223,6 +207,9 @@ impl App { lane_index: Some(lane_index), }, sessions: Vec::new(), + last_at: lane.last_at, + lane_label: Some(lane.label.clone()), + harness_label: lane.harness_label.clone(), hidden: 0, overflow: false, } @@ -302,7 +289,11 @@ impl App { /// The agent groups survive the flattening without being rendered: they are /// what decides a session's order and its lane, and the sessions of one /// agent still come out contiguous. What they no longer get is a row. - fn flatten(&self, hosts: Vec, orphans: Vec) -> Vec { + fn flatten( + &self, + sections: Vec, + orphans: Vec, + ) -> Vec { let mut rows: Vec = Vec::new(); // A device that hosts nothing has nowhere to start a session, so the // action is absent there rather than present and refusing. @@ -312,12 +303,13 @@ impl App { // Progressive disclosure: one host is the common case, and a permanent // `mac-studio ▸` wrapper would add a level of nesting to the surface an // operator uses most. - let show_hosts = hosts.len() > 1; - for mut host in hosts { - if show_hosts { - rows.push(RailRow::Host(host.row)); + for mut section in sections { + match section.header { + organize::SectionHeader::Host(host) => rows.push(RailRow::Host(host)), + organize::SectionHeader::Group(group) => rows.push(RailRow::Group(group)), + organize::SectionHeader::None => {} } - for group in &mut host.agents { + for group in &mut section.agents { push_sessions(&mut rows, group, &self.harness_runs); } } @@ -492,6 +484,9 @@ fn placed_agent( lane_index: None, }, sessions: Vec::new(), + last_at: 0, + lane_label: None, + harness_label: None, hidden: 0, overflow: false, }); diff --git a/src/tui/src/ui/app/rail/organize/tests.rs b/src/tui/src/ui/app/rail/organize/tests.rs index 35cf0e787..ab6327eed 100644 --- a/src/tui/src/ui/app/rail/organize/tests.rs +++ b/src/tui/src/ui/app/rail/organize/tests.rs @@ -8,7 +8,7 @@ use medulla::runtime::AgentDeclaration; use super::super::tests::{app, stub_session}; use super::super::{ - AgentGroup, AgentRailRow, GroupRailRow, HostGroup, HostRailRow, RailRow, SessionRailRow, + AgentGroup, AgentRailRow, GroupRailRow, HostGroup, HostRailRow, SessionRailRow, }; use super::{flatten_agents, order_sections, sort_agents, sort_sessions, Section, SectionHeader}; use crate::ui::app::App; @@ -24,38 +24,57 @@ fn app_with_agents() -> App { app } -/// The rail's sections: each header label (`None` for the unheaded top level) -/// with the agents listed under it, restricted to the declared agents so the -/// mock runtime's own folded lanes cannot make an assertion flaky. +/// The configured sections for the declared agents, without unrelated mock lanes. fn sections(app: &App) -> Vec<(Option, Vec)> { - let declared: Vec = app + let agents = app .loaded .config .fleet .agent_declarations .iter() - .map(|declaration| declaration.agent_id.clone()) + .map(|declaration| AgentGroup { + row: AgentRailRow { + agent_id: declaration.agent_id.clone(), + host_id: String::new(), + agent: None, + lane_index: None, + }, + sessions: Vec::new(), + last_at: 0, + lane_label: None, + harness_label: None, + hidden: 0, + overflow: false, + }) .collect(); - let mut sections: Vec<(Option, Vec)> = vec![(None, Vec::new())]; - for row in app.rail_rows() { - match row { - RailRow::Host(host) => sections.push((Some(host.label), Vec::new())), - RailRow::Group(group) => sections.push((Some(group.label), Vec::new())), - RailRow::Agent(agent) if declared.contains(&agent.agent_id) => { - sections - .last_mut() - .expect("a section is always open") - .1 - .push(agent.agent_id); - } - _ => {} - } - } - // The mock runtime folds lanes of its own, which section themselves under - // "no path"/"no harness"; those sections hold no declared agent and are - // dropped so an assertion is about what the test declared. - sections.retain(|(_, agents)| !agents.is_empty()); - sections + super::organize( + vec![HostGroup { + row: HostRailRow { + host_id: String::new(), + label: "local".into(), + local: true, + }, + agents, + }], + &app.loaded.config.fleet.agent_declarations, + app.loaded.config.appearance.sidebar_grouping, + app.loaded.config.appearance.sidebar_sort, + ) + .into_iter() + .map(|section| { + let header = match section.header { + SectionHeader::Host(host) => Some(host.label), + SectionHeader::Group(group) => Some(group.label), + SectionHeader::None => None, + }; + let agents = section + .agents + .into_iter() + .map(|agent| agent.row.agent_id) + .collect(); + (header, agents) + }) + .collect() } #[test] @@ -325,7 +344,6 @@ fn active_agent(label: &str, last_at: i64) -> AgentGroup { last_at, lane_label: None, harness_label: None, - visible_tasks: 1, hidden: 0, overflow: false, } @@ -344,7 +362,6 @@ fn peer_agent(label: &str, last_at: i64) -> AgentGroup { last_at, lane_label: Some(label.into()), harness_label: None, - visible_tasks: 0, hidden: 0, overflow: false, } diff --git a/src/tui/src/ui/app/rail/tests.rs b/src/tui/src/ui/app/rail/tests.rs index fc43cdb9c..08be3bcdb 100644 --- a/src/tui/src/ui/app/rail/tests.rs +++ b/src/tui/src/ui/app/rail/tests.rs @@ -310,7 +310,7 @@ fn a_row_answers_for_the_lane_behind_it() { } // The host header and the action row are about no lane and no // session. - RailRow::Host(_) | RailRow::NewSession => { + RailRow::Host(_) | RailRow::Group(_) | RailRow::NewSession => { assert_eq!(row.lane_index(), None); assert_eq!(row.session_id(), None); assert!(row.task().is_none()); @@ -341,7 +341,9 @@ fn only_the_rows_that_name_something_take_the_cursor() { )]; for row in app.rail_rows() { match row { - RailRow::Host(_) => assert!(!row.selectable(), "a host header is a label"), + RailRow::Host(_) | RailRow::Group(_) => { + assert!(!row.selectable(), "a section header is a label") + } RailRow::Session(_) | RailRow::NewSession | RailRow::WorkflowRun(_) diff --git a/src/tui/src/ui/app/rail/types.rs b/src/tui/src/ui/app/rail/types.rs index 099b5e17e..969f3b451 100644 --- a/src/tui/src/ui/app/rail/types.rs +++ b/src/tui/src/ui/app/rail/types.rs @@ -325,7 +325,10 @@ impl RailRow { match self { RailRow::Session(row) => row.lane_index, RailRow::Overflow { lane_index, .. } => Some(*lane_index), - RailRow::Host(_) | RailRow::Group(_) | RailRow::NewSession | RailRow::WorkflowRun(_) => None, + RailRow::Host(_) + | RailRow::Group(_) + | RailRow::NewSession + | RailRow::WorkflowRun(_) => None, } } diff --git a/src/tui/src/ui/app/render/sessions/rail/mod.rs b/src/tui/src/ui/app/render/sessions/rail/mod.rs index 42a287e1e..42bc46678 100644 --- a/src/tui/src/ui/app/render/sessions/rail/mod.rs +++ b/src/tui/src/ui/app/render/sessions/rail/mod.rs @@ -267,6 +267,12 @@ impl App { .fg(color("blue")) .add_modifier(Modifier::BOLD), )), + RailRow::Group(group) => TLine::from(Span::styled( + format!("▸ {}", group.label), + Style::default() + .fg(color("blue")) + .add_modifier(Modifier::BOLD), + )), RailRow::NewSession => self.new_session_line(active), RailRow::Overflow { hidden, .. } => self.overflow_line(*hidden, active), RailRow::WorkflowRun(run) => self.workflow_run_line(run, active, now), From cca1e7a72935c491c8a39696cb4cf64b8a4289b9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 01:07:22 +0300 Subject: [PATCH 17/29] fix(rail): skip empty grouped sections when flattening rows Sections whose agent groups have no sessions and no overflow now produce no rows at all, so a group header no longer appears above an empty area. The organize fixture was also updated to carry declaration-derived workspace and harness data, and a regression test covers the new filtering behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/tui/src/ui/app/rail/mod.rs | 7 ++++- src/tui/src/ui/app/rail/organize/tests.rs | 17 +++++++++++- src/tui/src/ui/app/rail/tests.rs | 33 ++++++++++++++++++++++- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index 280f8278d..7eb4b4c6f 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -303,7 +303,12 @@ impl App { // Progressive disclosure: one host is the common case, and a permanent // `mac-studio ▸` wrapper would add a level of nesting to the surface an // operator uses most. - for mut section in sections { + for mut section in sections.into_iter().filter(|section| { + section + .agents + .iter() + .any(|group| !group.sessions.is_empty() || group.overflow) + }) { match section.header { organize::SectionHeader::Host(host) => rows.push(RailRow::Host(host)), organize::SectionHeader::Group(group) => rows.push(RailRow::Group(group)), diff --git a/src/tui/src/ui/app/rail/organize/tests.rs b/src/tui/src/ui/app/rail/organize/tests.rs index ab6327eed..57801ad91 100644 --- a/src/tui/src/ui/app/rail/organize/tests.rs +++ b/src/tui/src/ui/app/rail/organize/tests.rs @@ -36,7 +36,22 @@ fn sections(app: &App) -> Vec<(Option, Vec)> { row: AgentRailRow { agent_id: declaration.agent_id.clone(), host_id: String::new(), - agent: None, + // `organize` receives the already placed tree in production, + // whose rows carry their declaration-derived workspace and + // harness. Keep this fixture faithful so path and harness + // grouping exercise the values the implementation reads. + agent: Some(HostAgentRow { + agent_id: declaration.agent_id.clone(), + label: declaration.agent_id.clone(), + harness: Some(declaration.harness.clone()), + workspace: Some(declaration.workspace.path.clone()), + roles: Vec::new(), + max_sessions: None, + declared: true, + editable: true, + live: false, + selected: false, + }), lane_index: None, }, sessions: Vec::new(), diff --git a/src/tui/src/ui/app/rail/tests.rs b/src/tui/src/ui/app/rail/tests.rs index 08be3bcdb..0b836dac7 100644 --- a/src/tui/src/ui/app/rail/tests.rs +++ b/src/tui/src/ui/app/rail/tests.rs @@ -10,7 +10,7 @@ use medulla::protocol::HarnessProvider; use medulla::runtime::mock::MockRuntime; use medulla::runtime::{AgentDeclaration, Runtime}; -use super::{RailRow, NEW_SESSION_LABEL}; +use super::{AgentGroup, AgentRailRow, GroupRailRow, RailRow, NEW_SESSION_LABEL}; use crate::ui::app::App; use crate::worker::pty::PtyManager; @@ -116,6 +116,37 @@ fn the_sessions_of_one_agent_stay_contiguous() { ); } +#[test] +fn empty_grouped_sections_do_not_emit_a_header() { + let rows = app().flatten( + vec![super::organize::Section { + header: super::organize::SectionHeader::Group(GroupRailRow { + label: "/quiet".to_string(), + }), + agents: vec![AgentGroup { + row: AgentRailRow { + agent_id: "quiet".to_string(), + host_id: String::new(), + agent: None, + lane_index: None, + }, + sessions: Vec::new(), + last_at: 0, + lane_label: None, + harness_label: None, + hidden: 0, + overflow: false, + }], + }], + Vec::new(), + ); + + assert!( + !rows.iter().any(|row| matches!(row, RailRow::Group(_))), + "a group without a rendered session must not leave an empty heading" + ); +} + // Unix-only: starts a real child on a real pseudo-terminal via `/bin/sh`, // which Windows has no equivalent of. The row model under test is // portable; only this way of standing a session up is not. From 4f5440abfa6be0fa28088adcf2e9c4c675d8da9b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 01:35:50 +0300 Subject: [PATCH 18/29] refactor(rail): derive group sessions from lane tasks The rail now builds agent group sessions directly from ordered lane tasks instead of flattening pre-assembled agent rows, which simplifies the split_fold logic and removes the need for the offers_session parameter in paging. This change also introduces a visible_tasks field to track how many task-backed sessions the lane pager reveals, and replaces the Lane overflow row with a dedicated Overflow variant. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/tui/src/ui/app/rail/merge_tests.rs | 1 + src/tui/src/ui/app/rail/mod.rs | 65 +++++++++++------------ src/tui/src/ui/app/rail/organize/tests.rs | 4 ++ src/tui/src/ui/app/rail/paging/mod.rs | 15 ++---- src/tui/src/ui/app/rail/paging/tests.rs | 8 ++- src/tui/src/ui/app/rail/tests.rs | 1 + src/tui/src/ui/app/rail/types.rs | 2 + 7 files changed, 46 insertions(+), 50 deletions(-) diff --git a/src/tui/src/ui/app/rail/merge_tests.rs b/src/tui/src/ui/app/rail/merge_tests.rs index 0ceeceeda..a5f272fb0 100644 --- a/src/tui/src/ui/app/rail/merge_tests.rs +++ b/src/tui/src/ui/app/rail/merge_tests.rs @@ -42,6 +42,7 @@ fn group(sessions: Vec) -> AgentGroup { last_at: 0, lane_label: None, harness_label: None, + visible_tasks: 0, hidden: 0, overflow: false, } diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index 7eb4b4c6f..af814b335 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -35,7 +35,7 @@ use medulla::runtime::AgentDeclaration; use medulla::ui::hosts::{HostAgentRow, HostKind, HostRow}; use super::types::App; -use crate::ui::agents::{AgentLane, AgentRole, AgentRow}; +use crate::ui::agents::{ordered_tasks, AgentLane, AgentRole}; use crate::worker::pty::SessionRow; mod cleanup; @@ -45,6 +45,7 @@ mod cursor; #[cfg(test)] mod cursor_tests; mod organize; +mod paging; pub(in crate::ui::app) mod resolve; // Kept apart from `tests` rather than nested inside it: the assembly rules and // the served-dispatch merge are separate responsibilities, and one file for @@ -134,7 +135,7 @@ impl App { appearance.sidebar_sort, ); organize::sort_sessions(&mut orphans, appearance.sidebar_sort); - self.flatten(sections, orphans) + self.flatten(sections, orphans, lanes) } /// Fold the lane rows into per-agent groups. @@ -144,42 +145,29 @@ impl App { /// carried through as rows of their own; the rail lists sessions now, and /// none of those is one. fn split_fold(&self, lanes: &[AgentLane]) -> Vec { - let mut groups: Vec = Vec::new(); - for row in self.agent_rows_in(lanes) { - match row { - AgentRow::Lane { lane_index } => { - let Some(lane) = lanes.get(lane_index) else { - continue; - }; - if lane.role != AgentRole::Agent { - continue; - } - groups.push(self.group_for_lane(lane, lane_index)); - } - AgentRow::Sub { - lane_index, task, .. - } => { - let Some(group) = groups.last_mut() else { - continue; - }; - group.sessions.push(SessionRailRow { + lanes + .iter() + .enumerate() + .filter(|(_, lane)| lane.role == AgentRole::Agent) + .map(|(lane_index, lane)| { + let mut group = self.group_for_lane(lane, lane_index); + group.sessions = ordered_tasks(&lane.tasks) + .into_iter() + .map(|task| SessionRailRow { agent_id: Some(group.row.agent_id.clone()), lane_index: Some(lane_index), task: Some(task), local: None, last: false, - }); - } - AgentRow::More { hidden, .. } => { - if let Some(group) = groups.last_mut() { - group.hidden += hidden; - group.overflow = true; - } - } - AgentRow::Separator => continue, - } - } - groups + }) + .collect(); + group.visible_tasks = self.revealed_subtasks(&lane.key).min(group.sessions.len()); + group.hidden = group.sessions.len().saturating_sub(group.visible_tasks); + group.overflow = group.hidden > 0 + || group.visible_tasks > crate::ui::app::input::nav::SUBTASK_PAGE; + group + }) + .collect() } /// The group an agent-role lane opens. @@ -210,6 +198,7 @@ impl App { last_at: lane.last_at, lane_label: Some(lane.label.clone()), harness_label: lane.harness_label.clone(), + visible_tasks: 0, hidden: 0, overflow: false, } @@ -293,6 +282,7 @@ impl App { &self, sections: Vec, orphans: Vec, + lanes: &[AgentLane], ) -> Vec { let mut rows: Vec = Vec::new(); // A device that hosts nothing has nowhere to start a session, so the @@ -315,7 +305,13 @@ impl App { organize::SectionHeader::None => {} } for group in &mut section.agents { - push_sessions(&mut rows, group, &self.harness_runs); + paging::push_group( + &mut rows, + group, + &self.harness_runs, + lanes, + self.agent_anchor.as_ref(), + ); } } for mut session in orphans { @@ -492,6 +488,7 @@ fn placed_agent( last_at: 0, lane_label: None, harness_label: None, + visible_tasks: 0, hidden: 0, overflow: false, }); diff --git a/src/tui/src/ui/app/rail/organize/tests.rs b/src/tui/src/ui/app/rail/organize/tests.rs index 57801ad91..c26cc483e 100644 --- a/src/tui/src/ui/app/rail/organize/tests.rs +++ b/src/tui/src/ui/app/rail/organize/tests.rs @@ -5,6 +5,7 @@ use medulla::config::{SidebarGrouping, SidebarSort}; use medulla::runtime::AgentDeclaration; +use medulla::ui::hosts::HostAgentRow; use super::super::tests::{app, stub_session}; use super::super::{ @@ -58,6 +59,7 @@ fn sections(app: &App) -> Vec<(Option, Vec)> { last_at: 0, lane_label: None, harness_label: None, + visible_tasks: 0, hidden: 0, overflow: false, }) @@ -359,6 +361,7 @@ fn active_agent(label: &str, last_at: i64) -> AgentGroup { last_at, lane_label: None, harness_label: None, + visible_tasks: 0, hidden: 0, overflow: false, } @@ -377,6 +380,7 @@ fn peer_agent(label: &str, last_at: i64) -> AgentGroup { last_at, lane_label: Some(label.into()), harness_label: None, + visible_tasks: 0, hidden: 0, overflow: false, } diff --git a/src/tui/src/ui/app/rail/paging/mod.rs b/src/tui/src/ui/app/rail/paging/mod.rs index 114a2c626..4751befaf 100644 --- a/src/tui/src/ui/app/rail/paging/mod.rs +++ b/src/tui/src/ui/app/rail/paging/mod.rs @@ -7,18 +7,16 @@ use medulla::control_socket::HarnessRunRegistry; use super::{run_rows_under, AgentGroup, RailAnchor, RailRow}; -use crate::ui::agents::{AgentLane, AgentRow}; +use crate::ui::agents::AgentLane; -/// Push one agent row, its selected session rows, and its optional actions. +/// Push one agent's selected session rows and its optional paging control. pub(super) fn push_group( rows: &mut Vec, group: &mut AgentGroup, - offers_session: bool, runs: &HarnessRunRegistry, lanes: &[AgentLane], anchor: Option<&RailAnchor>, ) { - rows.push(RailRow::Agent(group.row.clone())); let task_limit = group.visible_tasks; let mut visible_tasks = 0; let mut hidden_tasks = 0; @@ -59,21 +57,16 @@ pub(super) fn push_group( let show_overflow = group.overflow && (group.hidden == 0 || hidden_tasks > 0); let shown = shown_sessions.len(); for (index, session) in shown_sessions.iter_mut().enumerate() { - session.last = !offers_session && !show_overflow && index + 1 == shown; + session.last = !show_overflow && index + 1 == shown; let session = Box::new(session.clone()); let run_rows = run_rows_under(&session, runs); rows.push(RailRow::Session(session)); rows.extend(run_rows); } if show_overflow { - rows.push(RailRow::Lane(AgentRow::More { + rows.push(RailRow::Overflow { lane_index: group.row.lane_index.unwrap_or(0), hidden: hidden_tasks, - })); - } - if offers_session { - rows.push(RailRow::NewSession { - agent_id: group.row.agent_id.clone(), }); } } diff --git a/src/tui/src/ui/app/rail/paging/tests.rs b/src/tui/src/ui/app/rail/paging/tests.rs index 628e90c0f..cba357701 100644 --- a/src/tui/src/ui/app/rail/paging/tests.rs +++ b/src/tui/src/ui/app/rail/paging/tests.rs @@ -81,7 +81,7 @@ fn split_fold_derives_the_first_page_counts_from_the_lane_tasks() { }) .collect(); - let (_, groups) = app().split_fold(&[lane]); + let groups = app().split_fold(&[lane]); let group = groups.first().expect("the agent lane becomes one group"); assert_eq!(group.visible_tasks, 10); @@ -147,7 +147,6 @@ fn paging_keeps_the_anchored_task_after_recent_sorting() { push_group( &mut rows, &mut owner, - false, &medulla::control_socket::HarnessRunRegistry::new(), &lanes, Some(&RailAnchor::Task { @@ -186,7 +185,7 @@ fn paging_keeps_a_task_backed_session_with_an_active_workflow_run() { ); let mut rows = Vec::new(); - push_group(&mut rows, &mut owner, false, &runs, &lanes, None); + push_group(&mut rows, &mut owner, &runs, &lanes, None); assert!(rows.iter().any(|row| matches!( row, @@ -206,7 +205,6 @@ fn paging_hides_the_overflow_action_when_retention_shows_every_task() { push_group( &mut rows, &mut owner, - false, &medulla::control_socket::HarnessRunRegistry::new(), &lanes, Some(&RailAnchor::Task { @@ -218,7 +216,7 @@ fn paging_hides_the_overflow_action_when_retention_shows_every_task() { assert!( !rows .iter() - .any(|row| matches!(row, RailRow::Lane(AgentRow::More { .. }))), + .any(|row| matches!(row, RailRow::Overflow { .. })), "the overflow action is absent when retaining a task reveals every task" ); } diff --git a/src/tui/src/ui/app/rail/tests.rs b/src/tui/src/ui/app/rail/tests.rs index 0b836dac7..1b22e6b45 100644 --- a/src/tui/src/ui/app/rail/tests.rs +++ b/src/tui/src/ui/app/rail/tests.rs @@ -134,6 +134,7 @@ fn empty_grouped_sections_do_not_emit_a_header() { last_at: 0, lane_label: None, harness_label: None, + visible_tasks: 0, hidden: 0, overflow: false, }], diff --git a/src/tui/src/ui/app/rail/types.rs b/src/tui/src/ui/app/rail/types.rs index 969f3b451..737dd2594 100644 --- a/src/tui/src/ui/app/rail/types.rs +++ b/src/tui/src/ui/app/rail/types.rs @@ -110,6 +110,8 @@ pub(super) struct AgentGroup { pub(super) lane_label: Option, /// Harness label supplied by a lane-only peer agent. pub(super) harness_label: Option, + /// Number of task-backed sessions currently revealed by the lane pager. + pub(super) visible_tasks: usize, /// Sessions hidden by the fold's paging boundary. pub(super) hidden: usize, /// Whether the fold supplied an overflow control for this group. From 7e396c6e129384a669ea9e80e2cfe8b052d71057 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 01:40:35 +0300 Subject: [PATCH 19/29] refactor(tui): move rail row rendering into rows module The rail row rendering functions were moved from the main rail module into the dedicated rows module to keep related formatting logic together. The test call site was updated to pass the new empty lanes argument. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/tui/src/ui/app/rail/tests.rs | 1 + .../src/ui/app/render/sessions/rail/mod.rs | 115 ------------------ .../src/ui/app/render/sessions/rail/rows.rs | 98 +++++++++++++++ 3 files changed, 99 insertions(+), 115 deletions(-) diff --git a/src/tui/src/ui/app/rail/tests.rs b/src/tui/src/ui/app/rail/tests.rs index 1b22e6b45..1c6c59de4 100644 --- a/src/tui/src/ui/app/rail/tests.rs +++ b/src/tui/src/ui/app/rail/tests.rs @@ -140,6 +140,7 @@ fn empty_grouped_sections_do_not_emit_a_header() { }], }], Vec::new(), + &[], ); assert!( diff --git a/src/tui/src/ui/app/render/sessions/rail/mod.rs b/src/tui/src/ui/app/render/sessions/rail/mod.rs index 42bc46678..ac3918c7a 100644 --- a/src/tui/src/ui/app/render/sessions/rail/mod.rs +++ b/src/tui/src/ui/app/render/sessions/rail/mod.rs @@ -197,121 +197,6 @@ impl App { device_footer.append_to(&mut view, Style::default().fg(self.theme.accent)); f.render_widget(Paragraph::new(Text::from(view)), inner); } - - /// One row per open thread, with its running/attention badges. Built apart - /// Render one rail row as the lines it occupies, wrapped to `width`. - /// - /// A row is not always one line: an operator's own session carries a name - /// above its status line, and either can wrap. Continuations are indented so - /// a row that took two lines still reads as one row. - pub(super) fn rail_row_lines( - &self, - row: &RailRow, - lanes: &[AgentLane], - active: bool, - width: usize, - waiting_sessions: &std::collections::HashSet, - now: i64, - ) -> Vec> { - match row { - // A session this device runs and no task describes is the operator's - // own: it gets the multi-line status-line treatment, because its - // working directory is the only thing telling two of them apart. - // - // A name goes above that rather than into it. The status line is - // configurable and describes the *harness*; the name is what the - // person who opened the session called it, and it is the first thing - // they look for. - RailRow::Session(session) if session.task.is_none() => { - let Some(local) = &session.local else { - return Vec::new(); - }; - let mut lines = Vec::new(); - if let Some(name) = session.name() { - let style = if active { - self.theme.selection() - } else { - Style::default().fg(color("cyan")) - }; - lines.extend(wrap_line( - &TLine::from(Span::styled(format!(" {name}"), style)), - width, - CONT_INDENT, - )); - } - lines.extend(self.own_session_lines(local, active, width, now)); - lines - } - other => wrap_line( - &self.rail_row_line(other, lanes, active, waiting_sessions, now), - width, - CONT_INDENT, - ), - } - } - - /// Format one single-line rail row. - pub(super) fn rail_row_line( - &self, - row: &RailRow, - lanes: &[AgentLane], - active: bool, - waiting_sessions: &std::collections::HashSet, - now: i64, - ) -> TLine<'static> { - let _ = lanes; - match row { - RailRow::Host(host) => TLine::from(Span::styled( - format!("▸ {}", host.label), - Style::default() - .fg(color("blue")) - .add_modifier(Modifier::BOLD), - )), - RailRow::Group(group) => TLine::from(Span::styled( - format!("▸ {}", group.label), - Style::default() - .fg(color("blue")) - .add_modifier(Modifier::BOLD), - )), - RailRow::NewSession => self.new_session_line(active), - RailRow::Overflow { hidden, .. } => self.overflow_line(*hidden, active), - RailRow::WorkflowRun(run) => self.workflow_run_line(run, active, now), - RailRow::Session(session) => match (&session.task, &session.local) { - (Some(task), _) => { - self.task_session_line(task, session.last, active, waiting_sessions) - } - // Only reached through `rail_row_lines`, which draws a local - // session over several lines; kept total so measurement can call - // either. - (None, Some(local)) => self - .own_session_lines(local, active, RAIL_MAX_CONTENT, now) - .into_iter() - .next() - .unwrap_or_default(), - (None, None) => TLine::from(""), - }, - } - } - - /// Format the `+ New session` action row at the top of the rail. - /// - /// Drawn as a button rather than as another list entry — bold and coloured, - /// with its chord beside it — because it is the one row on the rail that - /// *does* something rather than selecting something. It is no longer a leaf - /// of an agent's group, so it takes the top level and loses the `└`. - fn new_session_line(&self, active: bool) -> TLine<'static> { - let style = if active { - self.theme.selection() - } else { - Style::default() - .fg(color("cyan")) - .add_modifier(Modifier::BOLD) - }; - TLine::from(vec![ - Span::styled(format!(" {NEW_SESSION_LABEL} "), style), - Span::styled(" ⏎ / ^T", Style::default().add_modifier(Modifier::DIM)), - ]) - } } /// Center the selected row while keeping its final line visible when the full diff --git a/src/tui/src/ui/app/render/sessions/rail/rows.rs b/src/tui/src/ui/app/render/sessions/rail/rows.rs index 272a4e56d..90864e8d6 100644 --- a/src/tui/src/ui/app/render/sessions/rail/rows.rs +++ b/src/tui/src/ui/app/render/sessions/rail/rows.rs @@ -6,9 +6,11 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line as TLine, Span}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; +use crate::ui::agents::AgentLane; use crate::ui::util::slug; use crate::worker::pty::{HarnessAttention, SessionControl, SessionRow, ATTENTION_GLYPH}; +use super::super::super::super::rail::{RailRow, NEW_SESSION_LABEL}; use super::super::super::super::types::App; use super::super::super::color; use super::harness_line; @@ -25,6 +27,102 @@ const SESSION_TITLE_MAX_CELLS: usize = 48; /// What a row says when its harness is blocked on the operator. const NEEDS_INPUT_LABEL: &str = "needs input"; +impl App { + /// Render a rail row as the lines it occupies, wrapped to `width`. + pub(super) fn rail_row_lines( + &self, + row: &RailRow, + lanes: &[AgentLane], + active: bool, + width: usize, + waiting_sessions: &HashSet, + now: i64, + ) -> Vec> { + match row { + RailRow::Session(session) if session.task.is_none() => { + let Some(local) = &session.local else { + return Vec::new(); + }; + let mut lines = Vec::new(); + if let Some(name) = session.name() { + let style = if active { + self.theme.selection() + } else { + Style::default().fg(color("cyan")) + }; + lines.extend(wrap_line( + &TLine::from(Span::styled(format!(" {name}"), style)), + width, + CONT_INDENT, + )); + } + lines.extend(self.own_session_lines(local, active, width, now)); + lines + } + other => wrap_line( + &self.rail_row_line(other, lanes, active, waiting_sessions, now), + width, + CONT_INDENT, + ), + } + } + + /// Format one single-line rail row. + pub(super) fn rail_row_line( + &self, + row: &RailRow, + lanes: &[AgentLane], + active: bool, + waiting_sessions: &HashSet, + now: i64, + ) -> TLine<'static> { + let _ = lanes; + match row { + RailRow::Host(host) => TLine::from(Span::styled( + format!("▸ {}", host.label), + Style::default() + .fg(color("blue")) + .add_modifier(Modifier::BOLD), + )), + RailRow::Group(group) => TLine::from(Span::styled( + format!("▸ {}", group.label), + Style::default() + .fg(color("blue")) + .add_modifier(Modifier::BOLD), + )), + RailRow::NewSession => self.new_session_line(active), + RailRow::Overflow { hidden, .. } => self.overflow_line(*hidden, active), + RailRow::WorkflowRun(run) => self.workflow_run_line(run, active, now), + RailRow::Session(session) => match (&session.task, &session.local) { + (Some(task), _) => { + self.task_session_line(task, session.last, active, waiting_sessions) + } + (None, Some(local)) => self + .own_session_lines(local, active, super::RAIL_MAX_CONTENT, now) + .into_iter() + .next() + .unwrap_or_default(), + (None, None) => TLine::from(""), + }, + } + } + + /// Format the `+ New session` action row at the top of the rail. + fn new_session_line(&self, active: bool) -> TLine<'static> { + let style = if active { + self.theme.selection() + } else { + Style::default() + .fg(color("cyan")) + .add_modifier(Modifier::BOLD) + }; + TLine::from(vec![ + Span::styled(format!(" {NEW_SESSION_LABEL} "), style), + Span::styled(" ⏎ / ^T", Style::default().add_modifier(Modifier::DIM)), + ]) + } +} + impl App { /// Format one operator-started harness using the configured status-line layout. /// From b3fb84b0f1d3822c606925180bde452d23c84db8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 01:40:51 +0300 Subject: [PATCH 20/29] test(paging): add test for name sort before visible task selection Adds a regression test verifying that paging applies name sorting before selecting which tasks are visible, ensuring the correct task appears when the group has hidden and overflow tasks. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/tui/src/ui/app/rail/paging/tests.rs | 35 ++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/tui/src/ui/app/rail/paging/tests.rs b/src/tui/src/ui/app/rail/paging/tests.rs index cba357701..527de40e7 100644 --- a/src/tui/src/ui/app/rail/paging/tests.rs +++ b/src/tui/src/ui/app/rail/paging/tests.rs @@ -115,7 +115,10 @@ fn paging_starts_with_the_fold_running_first_task_order() { }, ]; - let group = app().group_for_lane(&lane, 0); + let group = app() + .split_fold(&[lane]) + .pop() + .expect("the agent lane becomes one group"); assert_eq!( group @@ -128,6 +131,36 @@ fn paging_starts_with_the_fold_running_first_task_order() { ); } +#[test] +fn paging_applies_name_sort_before_selecting_the_visible_tasks() { + let lanes = vec![shell_lane()]; + let mut owner = group(vec![task_row("zulu"), task_row("alpha")]); + owner.visible_tasks = 1; + owner.hidden = 1; + owner.overflow = true; + sort_sessions(&mut owner.sessions, SidebarSort::Name); + let mut rows = Vec::new(); + + push_group( + &mut rows, + &mut owner, + &medulla::control_socket::HarnessRunRegistry::new(), + &lanes, + None, + ); + + assert!(rows.iter().any(|row| matches!( + row, + RailRow::Session(session) + if session.task.as_ref().is_some_and(|task| task.task_id == "alpha") + ))); + assert!(!rows.iter().any(|row| matches!( + row, + RailRow::Session(session) + if session.task.as_ref().is_some_and(|task| task.task_id == "zulu") + ))); +} + #[test] fn paging_keeps_the_anchored_task_after_recent_sorting() { let lanes = vec![shell_lane()]; From cfd468ce7a40b825808a6b07e94637fe6130b417 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 01:43:13 +0300 Subject: [PATCH 21/29] fix(rail): share pager visibility with session rail Co-authored-by: Medulla --- src/tui/src/ui/app/input/nav.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/src/ui/app/input/nav.rs b/src/tui/src/ui/app/input/nav.rs index d9a3045b6..832494139 100644 --- a/src/tui/src/ui/app/input/nav.rs +++ b/src/tui/src/ui/app/input/nav.rs @@ -85,7 +85,7 @@ impl App { } /// How many sublanes a lane reveals at its current expansion. - fn revealed_subtasks(&self, key: &str) -> usize { + pub(in crate::ui::app) fn revealed_subtasks(&self, key: &str) -> usize { SUBTASK_PAGE.saturating_mul( self.subtask_pages .get(key) From 9eaadb26a9ddd90ff174717297d83f592cd1e002 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 01:59:52 +0300 Subject: [PATCH 22/29] docs(appearance): clarify module purpose in types.rs Updated the module-level documentation to more accurately describe the types as covering appearance configuration for resource displays and the Agents-sidebar layout, rather than only resource display formats. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/sdk/src/config/appearance/types.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sdk/src/config/appearance/types.rs b/src/sdk/src/config/appearance/types.rs index 746c79b3f..a336d0b0a 100644 --- a/src/sdk/src/config/appearance/types.rs +++ b/src/sdk/src/config/appearance/types.rs @@ -1,4 +1,4 @@ -//! Data types for selecting process and whole-device resource display formats. +//! Appearance configuration types for resource displays and Agents-sidebar layout. use serde::{Deserialize, Serialize}; From bb9d30797a9b3fe910c2cee6ba8736f21ce972f2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 02:00:50 +0300 Subject: [PATCH 23/29] fix(tui): restore rail build Co-authored-by: Medulla --- src/tui/src/ui/app/input/mod.rs | 2 ++ src/tui/src/ui/app/rail/mod.rs | 4 ++-- src/tui/src/ui/app/render/sessions/rail/mod.rs | 8 +++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/tui/src/ui/app/input/mod.rs b/src/tui/src/ui/app/input/mod.rs index 38e645f32..3971eff3c 100644 --- a/src/tui/src/ui/app/input/mod.rs +++ b/src/tui/src/ui/app/input/mod.rs @@ -17,6 +17,8 @@ mod paste; #[cfg(test)] mod tests; +pub(in crate::ui::app) use nav::SUBTASK_PAGE; + impl App { /// Route a terminal event to the key, mouse, or paste handler, producing any /// command the event loop must run. diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index af814b335..90660f3f8 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -163,8 +163,8 @@ impl App { .collect(); group.visible_tasks = self.revealed_subtasks(&lane.key).min(group.sessions.len()); group.hidden = group.sessions.len().saturating_sub(group.visible_tasks); - group.overflow = group.hidden > 0 - || group.visible_tasks > crate::ui::app::input::nav::SUBTASK_PAGE; + group.overflow = + group.hidden > 0 || group.visible_tasks > crate::ui::app::input::SUBTASK_PAGE; group }) .collect() diff --git a/src/tui/src/ui/app/render/sessions/rail/mod.rs b/src/tui/src/ui/app/render/sessions/rail/mod.rs index ac3918c7a..76e2cf343 100644 --- a/src/tui/src/ui/app/render/sessions/rail/mod.rs +++ b/src/tui/src/ui/app/render/sessions/rail/mod.rs @@ -8,17 +8,16 @@ //! says rather than how it is laid out across columns. use ratatui::layout::Rect; -use ratatui::style::{Modifier, Style}; -use ratatui::text::{Line as TLine, Span, Text}; +use ratatui::style::Style; +use ratatui::text::{Line as TLine, Text}; use ratatui::widgets::Paragraph; use ratatui::Frame; use crate::ui::agents::{AgentLane, TaskStatus}; use crate::worker::pty::ATTENTION_GLYPH; -use super::super::super::rail::{RailRow, NEW_SESSION_LABEL}; +use super::super::super::rail::RailRow; use super::super::super::types::{App, RailHit}; -use super::super::color; use super::types::{Selection, SessionsPanes}; mod device_footer; @@ -35,7 +34,6 @@ mod wrap; use types::DeviceFooter; pub(super) use workflow_run::workflow_run_elapsed; -use wrap::wrap_line; /// The most content columns the Sessions rail ever takes. /// From ba6c84d63131c2de0293a273ab00a2698675249a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 02:23:22 +0300 Subject: [PATCH 24/29] fix(rail): relocate overflow cursor when its lane is fully revealed When retaining a task consumes the final hidden row, the overflow anchor can no longer be retained. Previously, resolving by the old offset would select the newly revealed task; now the cursor relocates to the lane's first remaining row, which is stable as sessions appear beneath it. This also removes the now-unused `agent_rows_in` helper and `push_sessions` function. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/tui/src/ui/app/input/nav.rs | 8 ------ src/tui/src/ui/app/rail/cursor.rs | 12 +++++++++ src/tui/src/ui/app/rail/cursor_tests.rs | 33 +++++++++++++++++++++++++ src/tui/src/ui/app/rail/mod.rs | 30 ---------------------- src/tui/src/ui/app/rail/paging/tests.rs | 2 +- 5 files changed, 46 insertions(+), 39 deletions(-) diff --git a/src/tui/src/ui/app/input/nav.rs b/src/tui/src/ui/app/input/nav.rs index 832494139..f0b8d7853 100644 --- a/src/tui/src/ui/app/input/nav.rs +++ b/src/tui/src/ui/app/input/nav.rs @@ -24,14 +24,6 @@ impl App { #[cfg(test)] pub(in crate::ui::app) fn agent_rows(&self) -> Vec { let lanes = self.lanes(); - self.agent_rows_in(&lanes) - } - - /// Build paged fold rows from one already-captured lane snapshot. - pub(in crate::ui::app) fn agent_rows_in( - &self, - lanes: &[crate::ui::agents::AgentLane], - ) -> Vec { agent_row_model_paged(lanes, SUBTASK_PAGE, |lane| { self.subtask_pages.get(&lane.key).copied().unwrap_or(0) }) diff --git a/src/tui/src/ui/app/rail/cursor.rs b/src/tui/src/ui/app/rail/cursor.rs index 74b98dc39..08dadb7ac 100644 --- a/src/tui/src/ui/app/rail/cursor.rs +++ b/src/tui/src/ui/app/rail/cursor.rs @@ -54,6 +54,18 @@ pub(in crate::ui::app) fn resolve_rail_cursor( rows.iter() .position(|row| rail_anchor(row, lanes).as_ref() == Some(anchor)) }) + // Retaining a task can consume the final hidden row. In that case the + // overflow anchor cannot be retained, but resolving by its old offset + // would select the newly revealed task. Relocate to the lane's first + // remaining row instead, which is stable as sessions appear beneath it. + .or_else(|| match anchor { + Some(RailAnchor::Overflow(lane)) => rows.iter().position(|row| { + row.lane_index() + .and_then(|index| lanes.get(index)) + .is_some_and(|candidate| candidate.key == *lane) + }), + _ => None, + }) .unwrap_or_else(|| fallback.min(rows.len() - 1)) } diff --git a/src/tui/src/ui/app/rail/cursor_tests.rs b/src/tui/src/ui/app/rail/cursor_tests.rs index 09d7ccf65..c8c9b98df 100644 --- a/src/tui/src/ui/app/rail/cursor_tests.rs +++ b/src/tui/src/ui/app/rail/cursor_tests.rs @@ -82,6 +82,39 @@ fn an_overflow_anchor_uses_its_lanes_stable_key() { ); } +#[test] +fn a_removed_overflow_anchor_relocates_to_its_lanes_first_session() { + let lanes = vec![lane("builder")]; + let task = |id| { + RailRow::Session(Box::new(SessionRailRow { + agent_id: Some("builder".to_string()), + lane_index: Some(0), + task: Some(TaskState { + task_id: id.to_string(), + status: TaskStatus::Running, + turns: 0, + last_at: 0, + turn_blocks: Vec::new(), + attention: None, + question_id: None, + work: None, + }), + local: None, + last: false, + })) + }; + + assert_eq!( + resolve_rail_cursor( + &[RailRow::NewSession, task("first"), task("retained")], + &lanes, + Some(&RailAnchor::Overflow("builder".to_string())), + 2, + ), + 1, + ); +} + #[test] fn a_task_anchor_survives_local_pty_enrichment() { let lanes = vec![lane("builder")]; diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index 90660f3f8..ca2a0c7bc 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -571,33 +571,3 @@ fn run_rows_under( }) .collect() } - -/// Push one agent's sessions, tree-marked. The agent itself gets no row. -/// -/// Paging is the fold's, not the rail's (#171): `agent_rows` reveals a page of -/// task sublanes at a time and marks the rest with an overflow row, so a second -/// cap here would clip the page the operator just asked to see. The overflow row -/// is re-emitted under the sessions and stays selectable, which is what makes -/// `Enter` on it page the lane open — and, once the lane is fully revealed, fold -/// it back. -fn push_sessions( - rows: &mut Vec, - group: &mut AgentGroup, - runs: &medulla::control_socket::HarnessRunRegistry, -) { - let shown = group.sessions.len(); - for (index, session) in group.sessions.iter_mut().enumerate() { - // Only the tree's last leaf when the overflow row does not follow it. - session.last = !group.overflow && index + 1 == shown; - let session = Box::new(session.clone()); - let run_rows = run_rows_under(&session, runs); - rows.push(RailRow::Session(session)); - rows.extend(run_rows); - } - if group.overflow { - rows.push(RailRow::Overflow { - lane_index: group.row.lane_index.unwrap_or(0), - hidden: group.hidden, - }); - } -} diff --git a/src/tui/src/ui/app/rail/paging/tests.rs b/src/tui/src/ui/app/rail/paging/tests.rs index 527de40e7..dd6279a27 100644 --- a/src/tui/src/ui/app/rail/paging/tests.rs +++ b/src/tui/src/ui/app/rail/paging/tests.rs @@ -6,7 +6,7 @@ use super::super::{AgentGroup, AgentRailRow, RailAnchor, RailRow, SessionRailRow use super::push_group; use medulla::config::SidebarSort; use medulla::control_socket::{HarnessRunStatus, RunReport}; -use medulla::ui::agents::{AgentLane, AgentRole, AgentRow, TaskState, TaskStatus}; +use medulla::ui::agents::{AgentLane, AgentRole, TaskState, TaskStatus}; fn task_row(task_id: &str) -> SessionRailRow { SessionRailRow { From 6a860d7866b7657b2954a9f064f8df3c9c6e59f1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 02:47:08 +0300 Subject: [PATCH 25/29] docs(tui): document the group row in the sessions rail taxonomy The module documentation now reflects the addition of a grouping heading between hosts and sessions in the row taxonomy, updating the description of the tree shape and the list of possible row types. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/tui/src/ui/app/rail/types.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/tui/src/ui/app/rail/types.rs b/src/tui/src/ui/app/rail/types.rs index 737dd2594..7b9bbd97b 100644 --- a/src/tui/src/ui/app/rail/types.rs +++ b/src/tui/src/ui/app/rail/types.rs @@ -1,9 +1,9 @@ -//! The Sessions rail's row taxonomy: `Host → Session`. +//! The Sessions rail's row taxonomy: `Host → Group → Session`. //! -//! One shape for the whole tree. A row is a host, one session running on it, a -//! workflow run that session started, or one of the two controls — the action -//! that opens a session, and the paging control for a lane whose sessions the -//! fold hid. +//! One shape for the whole tree. A row is a host, a grouping heading, one +//! session running on it, a workflow run that session started, or one of the +//! two controls — the action that opens a session, and the paging control for a +//! lane whose sessions the fold hid. //! //! Two levels have been removed since. `AgentRow::Sub` rendered a *task* and //! `RailRow::Harness` a *session*, in two groups separated by a From c314b2e8c5367426d009669b0d281120172334b1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 02:51:26 +0300 Subject: [PATCH 26/29] fix(tui): restore sidebar grouping test build Co-authored-by: Medulla --- src/tui/src/ui/app/input/nav.rs | 6 ++++-- src/tui/src/ui/app/rail/cursor_tests.rs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/tui/src/ui/app/input/nav.rs b/src/tui/src/ui/app/input/nav.rs index f0b8d7853..ef1746829 100644 --- a/src/tui/src/ui/app/input/nav.rs +++ b/src/tui/src/ui/app/input/nav.rs @@ -8,7 +8,9 @@ use super::super::rail::RailRow; use super::super::types::{App, Cmd}; -use crate::ui::agents::{agent_row_model_paged, AgentRole, AgentRow, TaskStatus}; +#[cfg(test)] +use crate::ui::agents::{agent_row_model_paged, AgentRow}; +use crate::ui::agents::{AgentRole, TaskStatus}; /// How many of a lane's task sublanes one page reveals. /// @@ -24,7 +26,7 @@ impl App { #[cfg(test)] pub(in crate::ui::app) fn agent_rows(&self) -> Vec { let lanes = self.lanes(); - agent_row_model_paged(lanes, SUBTASK_PAGE, |lane| { + agent_row_model_paged(&lanes, SUBTASK_PAGE, |lane| { self.subtask_pages.get(&lane.key).copied().unwrap_or(0) }) } diff --git a/src/tui/src/ui/app/rail/cursor_tests.rs b/src/tui/src/ui/app/rail/cursor_tests.rs index c8c9b98df..aac1d065e 100644 --- a/src/tui/src/ui/app/rail/cursor_tests.rs +++ b/src/tui/src/ui/app/rail/cursor_tests.rs @@ -85,7 +85,7 @@ fn an_overflow_anchor_uses_its_lanes_stable_key() { #[test] fn a_removed_overflow_anchor_relocates_to_its_lanes_first_session() { let lanes = vec![lane("builder")]; - let task = |id| { + let task = |id: &str| { RailRow::Session(Box::new(SessionRailRow { agent_id: Some("builder".to_string()), lane_index: Some(0), From 7ec197af4a1e64459e128a623c681f831f0919ca Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 03:15:39 +0300 Subject: [PATCH 27/29] fix(rail): keep second declared host in shared tree The rail previously dropped a second declared host from the shared host tree once it was no longer the only remote machine. The appearance preferences now control grouping and ordering of the Agents sidebar, and the host tree retains all declared hosts so the grouping and sort controls can operate on the complete set. Tests were updated to verify the second declared host remains present alongside the local machine. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/tui/src/ui/app/appearance.rs | 7 +++--- src/tui/src/ui/app/rail/mod.rs | 12 ++++++++--- src/tui/src/ui/app/rail/tests.rs | 37 +++++++++++++++++++++++--------- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/src/tui/src/ui/app/appearance.rs b/src/tui/src/ui/app/appearance.rs index d49473181..030724496 100644 --- a/src/tui/src/ui/app/appearance.rs +++ b/src/tui/src/ui/app/appearance.rs @@ -1,7 +1,8 @@ -//! Appearance-setting mutations and persistence. +//! Appearance-setting mutations and persistence, including the Agents-sidebar +//! grouping and ordering controls. //! -//! Colour roles, local-process indicators, and whole-device indicators live -//! here. The two +//! Colour roles, local-process indicators, whole-device indicators, and the +//! sidebar's grouping and sort preferences live here. The two //! harness-row toggles this page used to carry — branch and shortened path — //! became placements on the Status line page, which is where the rest of the row //! is configured and the only place the effect can be previewed. Leaving them diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index ca2a0c7bc..905753de6 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -1,14 +1,20 @@ -//! The Sessions rail: one cursor over the `Host → Session` tree. +//! The Sessions rail: one cursor over configured `Host → Group → Session` +//! sections. //! //! ```text //! + New session ← the one action, when this device hosts //! ▸ this device ← host row, only when a remote host exists -//! ├ t_41 · running ← a session the orchestrator dispatched -//! ├ debug login ← a session the operator started +//! ├ /workspace ← optional path or harness group heading +//! │ ├ t_41 · running ← a session the orchestrator dispatched +//! │ └ debug login ← a session the operator started //! │ └ wf run · deploy ← a workflow run that session started //! └ +3 more ← the fold's paging control //! ``` //! +//! Appearance preferences organize the sections by host, path, harness, or no +//! heading, and order their agents and sessions by creation time, recent +//! activity, or name. +//! //! The rail lists **what is running**. It used to render the whole //! `Host → Agent → Session` tree, with a row per declared agent, because //! declaring one was a thing an operator did from here. It is not any more — diff --git a/src/tui/src/ui/app/rail/tests.rs b/src/tui/src/ui/app/rail/tests.rs index 1c6c59de4..a499d5181 100644 --- a/src/tui/src/ui/app/rail/tests.rs +++ b/src/tui/src/ui/app/rail/tests.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use medulla::config::LoadedConfig; use medulla::protocol::HarnessProvider; use medulla::runtime::mock::MockRuntime; -use medulla::runtime::{AgentDeclaration, Runtime}; +use medulla::runtime::{AgentDeclaration, Runtime, WorkerInfo}; use super::{AgentGroup, AgentRailRow, GroupRailRow, RailRow, NEW_SESSION_LABEL}; use crate::ui::app::App; @@ -266,8 +266,28 @@ fn a_session_in_an_undeclared_directory_is_still_listed() { } #[test] -fn host_rows_appear_only_once_a_second_host_exists() { - let mut app = app(); +fn the_host_tree_keeps_a_second_declared_host() { + let runtime = MockRuntime::empty(); + runtime.set_workers(vec![WorkerInfo { + id: "studio-claude".into(), + address: "studio".into(), + handle: None, + label: None, + harness: Some("claude".into()), + workspace: Some("/work".into()), + peer_id: None, + cpu_cores: None, + memory_total_bytes: None, + memory_available_bytes: None, + ip_address: None, + selected: false, + roles: Vec::new(), + budgets: Vec::new(), + readiness: Vec::new(), + }]); + let mut loaded = LoadedConfig::defaults("medulla.tui.json".into()); + loaded.config.link = Some(medulla::config::LinkConfig::default()); + let mut app = App::new(Arc::new(runtime), loaded); app.loaded.config.fleet.agent_declarations = vec![AgentDeclaration::new("local-claude", "", "claude", "/work")]; assert!( @@ -288,20 +308,17 @@ fn host_rows_appear_only_once_a_second_host_exists() { "/work", )); let hosts: Vec<(String, bool)> = app - .rail_rows() + .host_tree() .into_iter() - .filter_map(|row| match row { - RailRow::Host(host) => Some((host.host_id, host.local)), - _ => None, - }) + .map(|host| (host.id, host.kind == medulla::ui::hosts::HostKind::Local)) .collect(); assert!( hosts.iter().any(|(host_id, _)| host_id == "studio"), - "the second machine gets a header: {hosts:?}" + "the second declared machine stays in the shared tree: {hosts:?}" ); assert!( hosts.len() >= 2, - "so does this one, once there is a second: {hosts:?}" + "the local and second declared machines both remain: {hosts:?}" ); let local = app.local_host_refs(); assert_eq!( From 047cfbe964312ff67644d05280422c975a3cd346 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 03:39:27 +0300 Subject: [PATCH 28/29] test(rail): strengthen host tree assertion for local machine The test now verifies that the second declared host is not local and that a local machine remains in the shared tree, improving coverage of the host filtering behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/tui/src/ui/app/rail/tests.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/tui/src/ui/app/rail/tests.rs b/src/tui/src/ui/app/rail/tests.rs index a499d5181..cecc9ee74 100644 --- a/src/tui/src/ui/app/rail/tests.rs +++ b/src/tui/src/ui/app/rail/tests.rs @@ -313,9 +313,15 @@ fn the_host_tree_keeps_a_second_declared_host() { .map(|host| (host.id, host.kind == medulla::ui::hosts::HostKind::Local)) .collect(); assert!( - hosts.iter().any(|(host_id, _)| host_id == "studio"), + hosts + .iter() + .any(|(host_id, is_local)| host_id == "studio" && !is_local), "the second declared machine stays in the shared tree: {hosts:?}" ); + assert!( + hosts.iter().any(|(_, is_local)| *is_local), + "the local machine remains in the shared tree: {hosts:?}" + ); assert!( hosts.len() >= 2, "the local and second declared machines both remain: {hosts:?}" From 3b3b8c347fcd82188f48c0538a21d2563a5c7bb8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 03:39:44 +0300 Subject: [PATCH 29/29] test(rail): simplify host-kind assertion in tree test The test previously checked that the second declared host was not local, but the assertion only needs to verify the host remains in the tree. The kind check was redundant and has been removed to focus the test on tree membership. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/tui/src/ui/app/rail/tests.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/tui/src/ui/app/rail/tests.rs b/src/tui/src/ui/app/rail/tests.rs index cecc9ee74..e09e2fd79 100644 --- a/src/tui/src/ui/app/rail/tests.rs +++ b/src/tui/src/ui/app/rail/tests.rs @@ -313,9 +313,7 @@ fn the_host_tree_keeps_a_second_declared_host() { .map(|host| (host.id, host.kind == medulla::ui::hosts::HostKind::Local)) .collect(); assert!( - hosts - .iter() - .any(|(host_id, is_local)| host_id == "studio" && !is_local), + hosts.iter().any(|(host_id, _)| host_id == "studio"), "the second declared machine stays in the shared tree: {hosts:?}" ); assert!(