From 3b634d66ca6e8284bdb720ead14868a1577ff2ad Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 19 Jun 2026 00:14:02 +0900 Subject: [PATCH] tui: filter panel pods by workspace --- crates/pod-store/src/lib.rs | 8 ++ crates/pod/src/discovery.rs | 12 +++ crates/pod/src/pod.rs | 10 +- crates/pod/src/ticket_event_notify.rs | 2 + crates/tui/src/multi_pod.rs | 4 +- crates/tui/src/pod_list.rs | 137 +++++++++++++++++++++++++- crates/tui/src/workspace_panel.rs | 133 +++++++++++++++++++++++-- 7 files changed, 292 insertions(+), 14 deletions(-) diff --git a/crates/pod-store/src/lib.rs b/crates/pod-store/src/lib.rs index bc5ee7fd..25422af7 100644 --- a/crates/pod-store/src/lib.rs +++ b/crates/pod-store/src/lib.rs @@ -100,6 +100,8 @@ pub struct PodMetadata { pub pod_name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub active: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_root: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub spawned_children: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -116,12 +118,18 @@ impl PodMetadata { Self { pod_name: pod_name.into(), active, + workspace_root: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: Vec::new(), resolved_manifest_snapshot: None, } } + + pub fn with_workspace_root(mut self, workspace_root: PathBuf) -> Self { + self.workspace_root = Some(workspace_root); + self + } } /// Sync persistence backend for Pod metadata. diff --git a/crates/pod/src/discovery.rs b/crates/pod/src/discovery.rs index 82fa07bf..935238ae 100644 --- a/crates/pod/src/discovery.rs +++ b/crates/pod/src/discovery.rs @@ -1108,6 +1108,7 @@ mod tests { let parent = PodMetadata { pod_name: "parent".into(), active: None, + workspace_root: None, spawned_children: vec![ child("child-live", &live_socket), child("child-stale", &stale_socket), @@ -1127,6 +1128,7 @@ mod tests { session_id, active_child_segment, )), + workspace_root: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: Vec::new(), @@ -1140,6 +1142,7 @@ mod tests { session_id, active_child_segment, )), + workspace_root: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: Vec::new(), @@ -1150,6 +1153,7 @@ mod tests { .write(&PodMetadata { pod_name: "child-pending".into(), active: Some(PodActiveSegmentRef::pending_segment(pending_session_id)), + workspace_root: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: Vec::new(), @@ -1163,6 +1167,7 @@ mod tests { session_id, new_segment_id(), )), + workspace_root: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: Vec::new(), @@ -1173,6 +1178,7 @@ mod tests { .write(&PodMetadata { pod_name: "peer".into(), active: None, + workspace_root: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: vec![pod_store::PodPeer { @@ -1366,6 +1372,7 @@ mod tests { .write(&PodMetadata { pod_name: "source".into(), active: None, + workspace_root: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: vec![pod_store::PodPeer { @@ -1405,6 +1412,7 @@ mod tests { .write(&PodMetadata { pod_name: "source".into(), active: None, + workspace_root: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: vec![pod_store::PodPeer { @@ -1417,6 +1425,7 @@ mod tests { .write(&PodMetadata { pod_name: "target".into(), active: None, + workspace_root: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: vec![pod_store::PodPeer { @@ -1519,6 +1528,7 @@ mod tests { .write(&PodMetadata { pod_name: "source".into(), active: None, + workspace_root: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: vec![pod_store::PodPeer { @@ -1531,6 +1541,7 @@ mod tests { .write(&PodMetadata { pod_name: "target".into(), active: None, + workspace_root: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: vec![pod_store::PodPeer { @@ -1631,6 +1642,7 @@ mod tests { .write(&PodMetadata { pod_name: "source".into(), active: None, + workspace_root: None, spawned_children: vec![child("target", &socket)], reclaimed_children: Vec::new(), peers: Vec::new(), diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index 9d1c232f..abeece58 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -924,7 +924,7 @@ impl Pod { } fn pod_metadata(&self, active: Option) -> PodMetadata { - pod_metadata_for_manifest(&self.manifest, active) + pod_metadata_for_manifest(&self.manifest, &self.workspace_root, active) } fn write_pod_metadata_pending(&self) -> Result<(), PodError> { @@ -4319,9 +4319,11 @@ fn request_config_from_worker_manifest(wm: &WorkerManifest) -> RequestConfig { fn pod_metadata_for_manifest( manifest: &PodManifest, + workspace_root: &Path, active: Option, ) -> PodMetadata { - let mut metadata = PodMetadata::new(manifest.pod.name.clone(), active); + let mut metadata = PodMetadata::new(manifest.pod.name.clone(), active) + .with_workspace_root(workspace_root.to_path_buf()); if should_persist_resolved_manifest_snapshot(manifest) { metadata.resolved_manifest_snapshot = serde_json::to_value(manifest).ok(); } @@ -5328,7 +5330,7 @@ permission = "read" .unwrap(); assert!(manifest.profile.is_none()); assert!( - pod_metadata_for_manifest(&manifest, None) + pod_metadata_for_manifest(&manifest, Path::new("/snapshot/workspace"), None) .resolved_manifest_snapshot .is_none() ); @@ -5361,7 +5363,7 @@ permission = "read" config: None, }]; - let metadata = pod_metadata_for_manifest(&manifest, None); + let metadata = pod_metadata_for_manifest(&manifest, Path::new("/snapshot/workspace"), None); let snapshot = metadata .resolved_manifest_snapshot .expect("plugin-resolved manifest should be snapshotted"); diff --git a/crates/pod/src/ticket_event_notify.rs b/crates/pod/src/ticket_event_notify.rs index 124ac5fa..0cbc9fe7 100644 --- a/crates/pod/src/ticket_event_notify.rs +++ b/crates/pod/src/ticket_event_notify.rs @@ -378,6 +378,7 @@ mod tests { .write(&PodMetadata { pod_name: "orchestrator".into(), active: None, + workspace_root: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: Vec::new(), @@ -388,6 +389,7 @@ mod tests { .write(&PodMetadata { pod_name: "companion".into(), active: None, + workspace_root: None, spawned_children: Vec::new(), reclaimed_children: Vec::new(), peers: Vec::new(), diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index 667291a2..d0ca36d5 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -3398,12 +3398,13 @@ async fn load_pod_list( let live = read_reachable_live_pod_infos(&store) .await .unwrap_or_default(); - Ok(PodList::from_sources( + Ok(PodList::from_workspace_sources( PodVisibilitySource::ResumePicker, stored, live, selected_name, max_entries, + ¤t_workspace_root(), )) } @@ -9390,6 +9391,7 @@ branch = "orchestration/custom-panel" active_session_id: None, active_segment_id: None, updated_at, + workspace_root: None, preview: None, } } diff --git a/crates/tui/src/pod_list.rs b/crates/tui/src/pod_list.rs index a4842571..d9907942 100644 --- a/crates/tui/src/pod_list.rs +++ b/crates/tui/src/pod_list.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::io; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -65,6 +65,56 @@ impl PodList { } } + pub(crate) fn from_workspace_sources( + source: PodVisibilitySource, + stored: Vec, + live: Vec, + selected_name: Option, + max_entries: usize, + workspace_root: &Path, + ) -> Self { + let current_workspace = workspace_root_key(workspace_root); + let mut current_names = BTreeSet::new(); + let stored: Vec<_> = stored + .into_iter() + .filter(|info| { + let matches = info + .workspace_root + .as_deref() + .is_some_and(|root| workspace_root_key(root) == current_workspace); + if matches { + current_names.insert(info.pod_name.clone()); + } + matches + }) + .collect(); + let live = live + .into_iter() + .filter(|info| current_names.contains(&info.pod_name)) + .collect(); + Self::from_sources(source, stored, live, selected_name, max_entries) + } + + pub(crate) fn filter_for_workspace(&self, workspace_root: &Path) -> Self { + let current_workspace = workspace_root_key(workspace_root); + let entries: Vec<_> = self + .entries + .iter() + .filter(|entry| entry_belongs_to_workspace(entry, ¤t_workspace)) + .cloned() + .collect(); + let selected_name = self + .selected_name + .as_ref() + .filter(|name| entries.iter().any(|entry| entry.name == **name)) + .cloned() + .or_else(|| entries.first().map(|entry| entry.name.clone())); + Self { + entries, + selected_name, + } + } + pub(crate) fn selected_index(&self) -> usize { self.selected_name .as_ref() @@ -82,6 +132,18 @@ impl PodList { } } +fn workspace_root_key(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +fn entry_belongs_to_workspace(entry: &PodListEntry, current_workspace: &Path) -> bool { + entry + .stored + .as_ref() + .and_then(|stored| stored.workspace_root.as_deref()) + .is_some_and(|root| workspace_root_key(root) == current_workspace) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PodVisibilitySource { ResumePicker, @@ -210,6 +272,7 @@ pub(crate) struct StoredPodInfo { pub active_session_id: Option, pub active_segment_id: Option, pub updated_at: u64, + pub workspace_root: Option, pub preview: Option, } @@ -348,6 +411,7 @@ fn stored_info_from_metadata( active_session_id, active_segment_id, updated_at: summary.updated_at, + workspace_root: metadata.workspace_root, preview: summary.preview, } } @@ -359,6 +423,7 @@ fn corrupt_stored_info(pod_name: String, message: String) -> StoredPodInfo { active_session_id: None, active_segment_id: None, updated_at: 0, + workspace_root: None, preview: Some(format!("metadata: {}", trim_one_line(&message, 48))), } } @@ -1061,6 +1126,7 @@ mod tests { active_session_id: Some(session_id), active_segment_id: None, updated_at: 0, + workspace_root: None, preview: Some("[pending segment]".to_string()), } } @@ -1072,6 +1138,7 @@ mod tests { active_session_id: None, active_segment_id: None, updated_at, + workspace_root: None, preview: None, } } @@ -1170,4 +1237,72 @@ mod tests { ) .unwrap(); } + + fn stopped_info_for_workspace(pod_name: &str, workspace_root: &Path) -> StoredPodInfo { + let mut info = stopped_info_with_updated_at(pod_name, 10); + info.workspace_root = Some(workspace_root.to_path_buf()); + info + } + + #[test] + fn workspace_sources_include_current_and_hide_external_or_unknown_pods() { + let current = tempdir().unwrap(); + let external = tempdir().unwrap(); + + let list = PodList::from_workspace_sources( + SOURCE, + vec![ + stopped_info_for_workspace("current", current.path()), + stopped_info_for_workspace("current-orchestrator", current.path()), + stopped_info_for_workspace("other-workspace", external.path()), + stopped_info_with_updated_at("legacy-unknown", 10), + corrupt_stored_info("corrupt".to_string(), "invalid metadata".to_string()), + ], + vec![ + live_info("current", PodStatus::Idle), + live_info("current-orchestrator", PodStatus::Running), + live_info("other-workspace", PodStatus::Idle), + live_info("legacy-unknown", PodStatus::Idle), + live_info("live-only", PodStatus::Idle), + ], + None, + 10, + current.path(), + ); + + let names = list + .entries + .iter() + .map(|entry| entry.name.as_str()) + .collect::>(); + assert_eq!(names, vec!["current", "current-orchestrator"]); + assert!(list.entries.iter().all(|entry| entry.actions.can_open)); + } + + #[test] + fn workspace_sources_use_workspace_metadata_not_cwd_or_live_presence() { + let current = tempdir().unwrap(); + let worktree_cwd = current.path().join(".worktree/impl"); + + let list = PodList::from_workspace_sources( + SOURCE, + vec![stopped_info_for_workspace("ticket-role", current.path())], + vec![live_info("ticket-role", PodStatus::Idle)], + None, + 10, + &worktree_cwd, + ); + assert!(list.entries.is_empty()); + + let list = PodList::from_workspace_sources( + SOURCE, + vec![stopped_info_for_workspace("ticket-role", current.path())], + vec![live_info("ticket-role", PodStatus::Idle)], + None, + 10, + current.path(), + ); + assert_eq!(list.entries[0].name, "ticket-role"); + assert!(list.entries[0].actions.can_open); + } } diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index a6441bc6..49eeb101 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -918,6 +918,8 @@ fn build_workspace_panel_with_registry_model( pods: &PodList, registry: &PanelRegistrySnapshot, ) -> WorkspacePanelViewModel { + let pods = pods.filter_for_workspace(workspace_root); + let pods = &pods; match ticket_config_availability(workspace_root) { TicketConfigAvailability::Absent => {} TicketConfigAvailability::Usable => { @@ -1685,7 +1687,7 @@ fn excerpt(markdown: &str, max_chars: usize) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::pod_list::{LivePodInfo, PodEntrySummary}; + use crate::pod_list::{LivePodInfo, PodEntrySummary, StoredPodInfo}; use crate::role_session_registry::{PanelRegistryStore, RelatedTicketRef, RoleSessionOrigin}; use std::fs; use std::path::{Path, PathBuf}; @@ -1829,10 +1831,22 @@ mod tests { .unwrap_or_else(|| panic!("missing row for {title}")) } - fn live_pods(names: &[&str]) -> PodList { + fn live_pods(workspace_root: &Path, names: &[&str]) -> PodList { + let stored = names + .iter() + .map(|name| StoredPodInfo { + pod_name: (*name).to_string(), + metadata_state: StoredMetadataState::Present, + active_session_id: None, + active_segment_id: None, + updated_at: 1, + workspace_root: Some(workspace_root.to_path_buf()), + preview: None, + }) + .collect(); PodList::from_sources( crate::pod_list::PodVisibilitySource::ResumePicker, - vec![], + stored, names .iter() .map(|name| LivePodInfo { @@ -1855,7 +1869,7 @@ mod tests { let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); create_ticket(&backend, "Hidden Without Config", |_| {}); - let model = build_workspace_panel(temp.path(), &live_pods(&["idle"])); + let model = build_workspace_panel(temp.path(), &live_pods(temp.path(), &["idle"])); assert!(model.header.diagnostics.is_empty()); assert_eq!( @@ -2069,7 +2083,7 @@ mod tests { .unwrap(); let model = build_workspace_panel_with_registry( temp.path(), - &live_pods(&["ready-intake"]), + &live_pods(temp.path(), &["ready-intake"]), ®istry.snapshot().unwrap(), ); @@ -2187,7 +2201,7 @@ mod tests { ) .unwrap(); - let model = build_workspace_panel(temp.path(), &live_pods(&["idle"])); + let model = build_workspace_panel(temp.path(), &live_pods(temp.path(), &["idle"])); let diagnostics = model.header.diagnostics.join("\n"); assert!(diagnostics.contains("Ticket config is unusable")); @@ -2420,7 +2434,10 @@ mod tests { ) .unwrap(); - let pods = live_pods(&["claimed-intake", "shared-intake", &preticket_pod]); + let pods = live_pods( + temp.path(), + &["claimed-intake", "shared-intake", &preticket_pod], + ); let model = build_workspace_panel_with_registry(temp.path(), &pods, ®istry.snapshot().unwrap()); @@ -2484,7 +2501,7 @@ mod tests { let model = build_workspace_panel_with_registry( temp.path(), - &live_pods(&["ticket-claimed-intake"]), + &live_pods(temp.path(), &["ticket-claimed-intake"]), ®istry, ); let row = model @@ -2692,4 +2709,104 @@ mod tests { OrchestratorLifecyclePlan::ReportLive ); } + + fn mixed_workspace_pods(current: &Path, external: &Path) -> PodList { + let stored = vec![ + StoredPodInfo { + pod_name: "current".to_string(), + metadata_state: StoredMetadataState::Present, + active_session_id: None, + active_segment_id: None, + updated_at: 10, + workspace_root: Some(current.to_path_buf()), + preview: None, + }, + StoredPodInfo { + pod_name: "current-coder".to_string(), + metadata_state: StoredMetadataState::Present, + active_session_id: None, + active_segment_id: None, + updated_at: 20, + workspace_root: Some(current.to_path_buf()), + preview: None, + }, + StoredPodInfo { + pod_name: "external".to_string(), + metadata_state: StoredMetadataState::Present, + active_session_id: None, + active_segment_id: None, + updated_at: 30, + workspace_root: Some(external.to_path_buf()), + preview: None, + }, + StoredPodInfo { + pod_name: "legacy".to_string(), + metadata_state: StoredMetadataState::Present, + active_session_id: None, + active_segment_id: None, + updated_at: 40, + workspace_root: None, + preview: None, + }, + StoredPodInfo { + pod_name: "corrupt".to_string(), + metadata_state: StoredMetadataState::Corrupt("bad metadata".to_string()), + active_session_id: None, + active_segment_id: None, + updated_at: 50, + workspace_root: None, + preview: Some("metadata: bad metadata".to_string()), + }, + ]; + let live = [ + "current", + "current-coder", + "external", + "legacy", + "live-only", + ] + .iter() + .map(|name| LivePodInfo { + pod_name: (*name).to_string(), + socket_path: PathBuf::from(format!("/tmp/{name}.sock")), + status: Some(PodStatus::Idle), + reachable: true, + segment_id: None, + summary: PodEntrySummary::default(), + }) + .collect(); + PodList::from_sources( + crate::pod_list::PodVisibilitySource::ResumePicker, + stored, + live, + None, + 10, + ) + } + + #[test] + fn workspace_panel_filters_pod_rows_to_current_workspace_metadata() { + let current = TempDir::new().unwrap(); + let external = TempDir::new().unwrap(); + let pods = mixed_workspace_pods(current.path(), external.path()); + + let model = build_workspace_panel(current.path(), &pods); + let pod_names = model + .rows + .iter() + .filter_map(|row| match &row.key { + PanelRowKey::Pod(name) => Some(name.as_str()), + _ => None, + }) + .collect::>(); + + assert_eq!(pod_names, vec!["current-coder", "current"]); + assert!( + model + .rows + .iter() + .filter(|row| matches!(row.key, PanelRowKey::Pod(_))) + .all(|row| row.next_action == Some(NextUserAction::OpenPod)) + ); + } }