tui: filter panel pods by workspace

This commit is contained in:
Keisuke Hirata 2026-06-19 00:14:02 +09:00
parent e2e76d3beb
commit 3b634d66ca
No known key found for this signature in database
7 changed files with 292 additions and 14 deletions

View File

@ -100,6 +100,8 @@ pub struct PodMetadata {
pub pod_name: String, pub pod_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub active: Option<PodActiveSegmentRef>, pub active: Option<PodActiveSegmentRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_root: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub spawned_children: Vec<PodSpawnedChild>, pub spawned_children: Vec<PodSpawnedChild>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
@ -116,12 +118,18 @@ impl PodMetadata {
Self { Self {
pod_name: pod_name.into(), pod_name: pod_name.into(),
active, active,
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: Vec::new(), peers: Vec::new(),
resolved_manifest_snapshot: None, 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. /// Sync persistence backend for Pod metadata.

View File

@ -1108,6 +1108,7 @@ mod tests {
let parent = PodMetadata { let parent = PodMetadata {
pod_name: "parent".into(), pod_name: "parent".into(),
active: None, active: None,
workspace_root: None,
spawned_children: vec![ spawned_children: vec![
child("child-live", &live_socket), child("child-live", &live_socket),
child("child-stale", &stale_socket), child("child-stale", &stale_socket),
@ -1127,6 +1128,7 @@ mod tests {
session_id, session_id,
active_child_segment, active_child_segment,
)), )),
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: Vec::new(), peers: Vec::new(),
@ -1140,6 +1142,7 @@ mod tests {
session_id, session_id,
active_child_segment, active_child_segment,
)), )),
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: Vec::new(), peers: Vec::new(),
@ -1150,6 +1153,7 @@ mod tests {
.write(&PodMetadata { .write(&PodMetadata {
pod_name: "child-pending".into(), pod_name: "child-pending".into(),
active: Some(PodActiveSegmentRef::pending_segment(pending_session_id)), active: Some(PodActiveSegmentRef::pending_segment(pending_session_id)),
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: Vec::new(), peers: Vec::new(),
@ -1163,6 +1167,7 @@ mod tests {
session_id, session_id,
new_segment_id(), new_segment_id(),
)), )),
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: Vec::new(), peers: Vec::new(),
@ -1173,6 +1178,7 @@ mod tests {
.write(&PodMetadata { .write(&PodMetadata {
pod_name: "peer".into(), pod_name: "peer".into(),
active: None, active: None,
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: vec![pod_store::PodPeer { peers: vec![pod_store::PodPeer {
@ -1366,6 +1372,7 @@ mod tests {
.write(&PodMetadata { .write(&PodMetadata {
pod_name: "source".into(), pod_name: "source".into(),
active: None, active: None,
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: vec![pod_store::PodPeer { peers: vec![pod_store::PodPeer {
@ -1405,6 +1412,7 @@ mod tests {
.write(&PodMetadata { .write(&PodMetadata {
pod_name: "source".into(), pod_name: "source".into(),
active: None, active: None,
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: vec![pod_store::PodPeer { peers: vec![pod_store::PodPeer {
@ -1417,6 +1425,7 @@ mod tests {
.write(&PodMetadata { .write(&PodMetadata {
pod_name: "target".into(), pod_name: "target".into(),
active: None, active: None,
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: vec![pod_store::PodPeer { peers: vec![pod_store::PodPeer {
@ -1519,6 +1528,7 @@ mod tests {
.write(&PodMetadata { .write(&PodMetadata {
pod_name: "source".into(), pod_name: "source".into(),
active: None, active: None,
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: vec![pod_store::PodPeer { peers: vec![pod_store::PodPeer {
@ -1531,6 +1541,7 @@ mod tests {
.write(&PodMetadata { .write(&PodMetadata {
pod_name: "target".into(), pod_name: "target".into(),
active: None, active: None,
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: vec![pod_store::PodPeer { peers: vec![pod_store::PodPeer {
@ -1631,6 +1642,7 @@ mod tests {
.write(&PodMetadata { .write(&PodMetadata {
pod_name: "source".into(), pod_name: "source".into(),
active: None, active: None,
workspace_root: None,
spawned_children: vec![child("target", &socket)], spawned_children: vec![child("target", &socket)],
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: Vec::new(), peers: Vec::new(),

View File

@ -924,7 +924,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
} }
fn pod_metadata(&self, active: Option<PodActiveSegmentRef>) -> PodMetadata { fn pod_metadata(&self, active: Option<PodActiveSegmentRef>) -> 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> { 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( fn pod_metadata_for_manifest(
manifest: &PodManifest, manifest: &PodManifest,
workspace_root: &Path,
active: Option<PodActiveSegmentRef>, active: Option<PodActiveSegmentRef>,
) -> PodMetadata { ) -> 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) { if should_persist_resolved_manifest_snapshot(manifest) {
metadata.resolved_manifest_snapshot = serde_json::to_value(manifest).ok(); metadata.resolved_manifest_snapshot = serde_json::to_value(manifest).ok();
} }
@ -5328,7 +5330,7 @@ permission = "read"
.unwrap(); .unwrap();
assert!(manifest.profile.is_none()); assert!(manifest.profile.is_none());
assert!( assert!(
pod_metadata_for_manifest(&manifest, None) pod_metadata_for_manifest(&manifest, Path::new("/snapshot/workspace"), None)
.resolved_manifest_snapshot .resolved_manifest_snapshot
.is_none() .is_none()
); );
@ -5361,7 +5363,7 @@ permission = "read"
config: None, 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 let snapshot = metadata
.resolved_manifest_snapshot .resolved_manifest_snapshot
.expect("plugin-resolved manifest should be snapshotted"); .expect("plugin-resolved manifest should be snapshotted");

View File

@ -378,6 +378,7 @@ mod tests {
.write(&PodMetadata { .write(&PodMetadata {
pod_name: "orchestrator".into(), pod_name: "orchestrator".into(),
active: None, active: None,
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: Vec::new(), peers: Vec::new(),
@ -388,6 +389,7 @@ mod tests {
.write(&PodMetadata { .write(&PodMetadata {
pod_name: "companion".into(), pod_name: "companion".into(),
active: None, active: None,
workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: Vec::new(), peers: Vec::new(),

View File

@ -3398,12 +3398,13 @@ async fn load_pod_list(
let live = read_reachable_live_pod_infos(&store) let live = read_reachable_live_pod_infos(&store)
.await .await
.unwrap_or_default(); .unwrap_or_default();
Ok(PodList::from_sources( Ok(PodList::from_workspace_sources(
PodVisibilitySource::ResumePicker, PodVisibilitySource::ResumePicker,
stored, stored,
live, live,
selected_name, selected_name,
max_entries, max_entries,
&current_workspace_root(),
)) ))
} }
@ -9390,6 +9391,7 @@ branch = "orchestration/custom-panel"
active_session_id: None, active_session_id: None,
active_segment_id: None, active_segment_id: None,
updated_at, updated_at,
workspace_root: None,
preview: None, preview: None,
} }
} }

View File

@ -1,4 +1,4 @@
use std::collections::BTreeMap; use std::collections::{BTreeMap, BTreeSet};
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration; use std::time::Duration;
@ -65,6 +65,56 @@ impl PodList {
} }
} }
pub(crate) fn from_workspace_sources(
source: PodVisibilitySource,
stored: Vec<StoredPodInfo>,
live: Vec<LivePodInfo>,
selected_name: Option<String>,
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, &current_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 { pub(crate) fn selected_index(&self) -> usize {
self.selected_name self.selected_name
.as_ref() .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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PodVisibilitySource { pub(crate) enum PodVisibilitySource {
ResumePicker, ResumePicker,
@ -210,6 +272,7 @@ pub(crate) struct StoredPodInfo {
pub active_session_id: Option<SessionId>, pub active_session_id: Option<SessionId>,
pub active_segment_id: Option<SegmentId>, pub active_segment_id: Option<SegmentId>,
pub updated_at: u64, pub updated_at: u64,
pub workspace_root: Option<PathBuf>,
pub preview: Option<String>, pub preview: Option<String>,
} }
@ -348,6 +411,7 @@ fn stored_info_from_metadata(
active_session_id, active_session_id,
active_segment_id, active_segment_id,
updated_at: summary.updated_at, updated_at: summary.updated_at,
workspace_root: metadata.workspace_root,
preview: summary.preview, preview: summary.preview,
} }
} }
@ -359,6 +423,7 @@ fn corrupt_stored_info(pod_name: String, message: String) -> StoredPodInfo {
active_session_id: None, active_session_id: None,
active_segment_id: None, active_segment_id: None,
updated_at: 0, updated_at: 0,
workspace_root: None,
preview: Some(format!("metadata: {}", trim_one_line(&message, 48))), preview: Some(format!("metadata: {}", trim_one_line(&message, 48))),
} }
} }
@ -1061,6 +1126,7 @@ mod tests {
active_session_id: Some(session_id), active_session_id: Some(session_id),
active_segment_id: None, active_segment_id: None,
updated_at: 0, updated_at: 0,
workspace_root: None,
preview: Some("[pending segment]".to_string()), preview: Some("[pending segment]".to_string()),
} }
} }
@ -1072,6 +1138,7 @@ mod tests {
active_session_id: None, active_session_id: None,
active_segment_id: None, active_segment_id: None,
updated_at, updated_at,
workspace_root: None,
preview: None, preview: None,
} }
} }
@ -1170,4 +1237,72 @@ mod tests {
) )
.unwrap(); .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::<Vec<_>>();
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);
}
} }

View File

@ -918,6 +918,8 @@ fn build_workspace_panel_with_registry_model(
pods: &PodList, pods: &PodList,
registry: &PanelRegistrySnapshot, registry: &PanelRegistrySnapshot,
) -> WorkspacePanelViewModel { ) -> WorkspacePanelViewModel {
let pods = pods.filter_for_workspace(workspace_root);
let pods = &pods;
match ticket_config_availability(workspace_root) { match ticket_config_availability(workspace_root) {
TicketConfigAvailability::Absent => {} TicketConfigAvailability::Absent => {}
TicketConfigAvailability::Usable => { TicketConfigAvailability::Usable => {
@ -1685,7 +1687,7 @@ fn excerpt(markdown: &str, max_chars: usize) -> Option<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::pod_list::{LivePodInfo, PodEntrySummary}; use crate::pod_list::{LivePodInfo, PodEntrySummary, StoredPodInfo};
use crate::role_session_registry::{PanelRegistryStore, RelatedTicketRef, RoleSessionOrigin}; use crate::role_session_registry::{PanelRegistryStore, RelatedTicketRef, RoleSessionOrigin};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -1829,10 +1831,22 @@ mod tests {
.unwrap_or_else(|| panic!("missing row for {title}")) .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( PodList::from_sources(
crate::pod_list::PodVisibilitySource::ResumePicker, crate::pod_list::PodVisibilitySource::ResumePicker,
vec![], stored,
names names
.iter() .iter()
.map(|name| LivePodInfo { .map(|name| LivePodInfo {
@ -1855,7 +1869,7 @@ mod tests {
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
create_ticket(&backend, "Hidden Without Config", |_| {}); 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!(model.header.diagnostics.is_empty());
assert_eq!( assert_eq!(
@ -2069,7 +2083,7 @@ mod tests {
.unwrap(); .unwrap();
let model = build_workspace_panel_with_registry( let model = build_workspace_panel_with_registry(
temp.path(), temp.path(),
&live_pods(&["ready-intake"]), &live_pods(temp.path(), &["ready-intake"]),
&registry.snapshot().unwrap(), &registry.snapshot().unwrap(),
); );
@ -2187,7 +2201,7 @@ mod tests {
) )
.unwrap(); .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"); let diagnostics = model.header.diagnostics.join("\n");
assert!(diagnostics.contains("Ticket config is unusable")); assert!(diagnostics.contains("Ticket config is unusable"));
@ -2420,7 +2434,10 @@ mod tests {
) )
.unwrap(); .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 = let model =
build_workspace_panel_with_registry(temp.path(), &pods, &registry.snapshot().unwrap()); build_workspace_panel_with_registry(temp.path(), &pods, &registry.snapshot().unwrap());
@ -2484,7 +2501,7 @@ mod tests {
let model = build_workspace_panel_with_registry( let model = build_workspace_panel_with_registry(
temp.path(), temp.path(),
&live_pods(&["ticket-claimed-intake"]), &live_pods(temp.path(), &["ticket-claimed-intake"]),
&registry, &registry,
); );
let row = model let row = model
@ -2692,4 +2709,104 @@ mod tests {
OrchestratorLifecyclePlan::ReportLive 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::<Vec<_>>();
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))
);
}
} }