From 2664cdd9928c000f777df060aeb6a31996b1df6a Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 00:47:08 +0900 Subject: [PATCH] feat: show ticket intake pods in panel --- crates/tui/src/multi_pod.rs | 95 +++++++--- crates/tui/src/workspace_panel.rs | 295 ++++++++++++++++++++++++++---- 2 files changed, 331 insertions(+), 59 deletions(-) diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index f4e6b44c..bdce4d9e 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -48,11 +48,11 @@ use crate::workspace_panel::{ ActionPriority, CompanionLifecyclePlan, CompanionPanelState, CompanionPanelStatus, CompanionPodPresence, ComposerTarget, NextUserAction, OrchestratorLifecyclePlan, OrchestratorPanelState, OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow, - PanelRowKey, TicketConfigAvailability, TicketLocalClaimStatus, WorkspacePanelViewModel, - bounded_panel_diagnostic, build_current_ticket_row, build_workspace_panel, - companion_pod_presence, decide_companion_lifecycle, decide_orchestrator_lifecycle, - local_claim_status_for_pod, orchestrator_pod_presence, ticket_config_availability, - workspace_companion_pod_name, workspace_orchestrator_pod_name, + PanelRowKey, PanelRowKind, TicketConfigAvailability, TicketLocalClaimStatus, + WorkspacePanelViewModel, bounded_panel_diagnostic, build_current_ticket_row, + build_workspace_panel, companion_pod_presence, decide_companion_lifecycle, + decide_orchestrator_lifecycle, local_claim_status_for_pod, orchestrator_pod_presence, + ticket_config_availability, workspace_companion_pod_name, workspace_orchestrator_pod_name, }; const MAX_ENTRIES: usize = 50; @@ -958,6 +958,13 @@ fn panel_e2e_row_key(key: &PanelRowKey) -> PanelE2eRowKey { kind: "ticket", id: id.clone(), }, + PanelRowKey::TicketIntakePod { + ticket_id, + pod_name, + } => PanelE2eRowKey { + kind: "ticket_intake_pod", + id: format!("{ticket_id}:{pod_name}"), + }, PanelRowKey::Pod(name) => PanelE2eRowKey { kind: "pod", id: name.clone(), @@ -1207,12 +1214,8 @@ impl MultiPodApp { } fn selected_pod_entry(&self) -> Option<&PodListEntry> { - match self.selected_row.as_ref() { - Some(PanelRowKey::Pod(name)) => { - self.list.entries.iter().find(|entry| &entry.name == name) - } - _ => None, - } + let name = self.selected_row.as_ref().and_then(PanelRowKey::pod_name)?; + self.list.entries.iter().find(|entry| entry.name == name) } #[cfg(test)] @@ -1238,11 +1241,14 @@ impl MultiPodApp { }), ); } - let entry = self.selected_pod_entry()?; - if entry.actions.can_open { - return None; + if let Some(entry) = self.selected_pod_entry() { + if entry.actions.can_open { + return None; + } + return Some(open_disabled_reason(entry)); } - Some(open_disabled_reason(entry)) + self.selected_panel_row() + .and_then(|row| row.disabled_reason.clone().or_else(|| row.key_hint.clone())) } pub(crate) fn select_next(&mut self) { @@ -1354,6 +1360,9 @@ impl MultiPodApp { None => match &hit.key { PanelRowKey::Pod(name) => (name.clone(), None, None), PanelRowKey::Ticket(id) => (id.clone(), None, None), + PanelRowKey::TicketIntakePod { pod_name, .. } => { + (pod_name.clone(), None, None) + } }, }; PanelE2eRenderedRow { @@ -1406,7 +1415,7 @@ impl MultiPodApp { } if let Some(key) = visible.iter().find(|key| match key { PanelRowKey::Pod(name) => Some(name.as_str()) != orchestrator_pod_name, - PanelRowKey::Ticket(_) => true, + PanelRowKey::Ticket(_) | PanelRowKey::TicketIntakePod { .. } => true, }) { self.select_panel_key(key.clone()); return; @@ -4677,6 +4686,13 @@ fn selected_ticket_notice(row: Option<&PanelRow>) -> String { row.title ) } + Some(row) if row.kind == PanelRowKind::TicketIntakePod => row + .disabled_reason + .clone() + .or_else(|| row.key_hint.clone()) + .unwrap_or_else(|| { + "Open/attach this Ticket's Intake Pod from the associated row.".to_string() + }), _ => "No Pod is selected.".to_string(), } } @@ -4793,7 +4809,7 @@ fn visible_panel_keys(panel: &WorkspacePanelViewModel, list: &PodList) -> Vec>(); keys.extend( @@ -5145,7 +5161,7 @@ fn panel_action_rows( let rows = panel .rows .iter() - .filter(|row| row.is_ticket_action()) + .filter(|row| row.is_ticket_section_row()) .collect::>(); if rows.is_empty() { return Vec::new(); @@ -5181,11 +5197,15 @@ fn panel_action_header_line(total: usize, width: u16) -> Line<'static> { const TICKET_STATE_COLUMN_WIDTH: usize = 10; const POD_STATUS_COLUMN_WIDTH: usize = 18; -fn panel_row_lines(row: &PanelRow, selected: bool, width: u16) -> [Line<'static>; 2] { - [ - panel_row_title_line(row, selected, width), - panel_row_detail_line(row, selected, width), - ] +fn panel_row_lines(row: &PanelRow, selected: bool, width: u16) -> Vec> { + if row.kind == PanelRowKind::TicketIntakePod { + vec![panel_row_title_line(row, selected, width)] + } else { + vec![ + panel_row_title_line(row, selected, width), + panel_row_detail_line(row, selected, width), + ] + } } fn panel_row_title_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> { @@ -5242,6 +5262,21 @@ fn push_ticket_marker_span(spans: &mut Vec>, selected: bool, remai } fn panel_ticket_detail(row: &PanelRow) -> String { + if row.kind == PanelRowKind::TicketIntakePod { + let mut parts = row + .subtitle + .as_ref() + .map(|subtitle| vec![subtitle.clone()]) + .unwrap_or_else(|| vec![panel_ticket_reference(row)]); + if let Some(action) = row.next_action { + parts.push(format!("Action: {}", action.label())); + } + if let Some(reason) = panel_ticket_reason(row) { + parts.push(format!("Reason: {reason}")); + } + return parts.join(" · "); + } + let mut parts = vec![panel_ticket_reference(row)]; if let Some(blocked_reason) = row .ticket @@ -5303,6 +5338,7 @@ fn panel_ticket_reference(row: &PanelRow) -> String { .map(|ticket| ticket.id.clone()) .unwrap_or_else(|| match &row.key { PanelRowKey::Ticket(id) => id.clone(), + PanelRowKey::TicketIntakePod { ticket_id, .. } => ticket_id.clone(), PanelRowKey::Pod(name) => name.clone(), }) } @@ -7321,7 +7357,8 @@ branch = "orchestration/custom-panel" "inprogress", ); - let [title, detail] = panel_row_lines(&row, true, 160); + let lines = panel_row_lines(&row, true, 160); + let (title, detail) = (&lines[0], &lines[1]); let title_line = plain_line(&title); let detail_line = plain_line(&detail); let state_start = 2; @@ -7352,7 +7389,8 @@ branch = "orchestration/custom-panel" "ready", ); - let [title, detail] = panel_row_lines(&row, false, 160); + let lines = panel_row_lines(&row, false, 160); + let (title, detail) = (&lines[0], &lines[1]); let title_line = plain_line(&title); let detail_line = plain_line(&detail); let state_start = 2; @@ -7377,7 +7415,8 @@ branch = "orchestration/custom-panel" "ready", ); - let [title, detail] = panel_row_lines(&row, false, 42); + let lines = panel_row_lines(&row, false, 42); + let (title, detail) = (&lines[0], &lines[1]); let title_line = plain_line(&title); let detail_line = plain_line(&detail); let title_start = 2 + TICKET_STATE_COLUMN_WIDTH + 1; @@ -7402,7 +7441,8 @@ branch = "orchestration/custom-panel" row.disabled_reason = Some("Queue disabled: waiting for BLOCKER-1".to_string()); row.ticket.as_mut().unwrap().blocked_reason = Some("BLOCKER-1 via depends_on".to_string()); - let [_title, detail] = panel_row_lines(&row, true, 160); + let lines = panel_row_lines(&row, true, 160); + let detail = &lines[1]; let detail_line = plain_line(&detail); assert!(detail_line.contains("Gate: waiting for BLOCKER-1 via depends_on")); @@ -8602,6 +8642,7 @@ branch = "orchestration/custom-panel" blocked_reason: None, related_pods: Vec::new(), local_claim: None, + intake_pods: Vec::new(), }; PanelRow { key: PanelRowKey::Ticket(ticket.id.clone()), diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index fdeb1db4..58e11b0a 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -182,15 +182,26 @@ impl OrchestratorPanelStatus { #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub(crate) enum PanelRowKey { Ticket(String), + TicketIntakePod { ticket_id: String, pod_name: String }, Pod(String), } +impl PanelRowKey { + pub(crate) fn pod_name(&self) -> Option<&str> { + match self { + Self::Pod(name) | Self::TicketIntakePod { pod_name: name, .. } => Some(name), + Self::Ticket(_) => None, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PanelRowKind { Planning, Ticket, Review, ActiveWork, + TicketIntakePod, Pod, } @@ -236,6 +247,30 @@ pub(crate) struct TicketPanelEntry { pub(crate) blocked_reason: Option, pub(crate) related_pods: Vec, pub(crate) local_claim: Option, + pub(crate) intake_pods: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TicketAssociatedIntakeEntry { + pub(crate) ticket_id: String, + pub(crate) pod_name: String, + pub(crate) status: TicketLocalClaimStatus, + pub(crate) source: TicketAssociatedIntakeSource, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TicketAssociatedIntakeSource { + LocalClaim, + RelatedSession, +} + +impl TicketAssociatedIntakeSource { + pub(crate) fn label(self) -> &'static str { + match self { + Self::LocalClaim => "local claim", + Self::RelatedSession => "related session", + } + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -279,11 +314,22 @@ pub(crate) struct PanelRow { impl PanelRow { pub(crate) fn is_ticket_action(&self) -> bool { - !matches!(self.kind, PanelRowKind::Pod) + matches!( + self.kind, + PanelRowKind::Planning + | PanelRowKind::Ticket + | PanelRowKind::Review + | PanelRowKind::ActiveWork + ) + } + + pub(crate) fn is_ticket_section_row(&self) -> bool { + self.is_ticket_action() || matches!(self.kind, PanelRowKind::TicketIntakePod) } } const MAX_POD_NAME_CHARS: usize = 80; +const MAX_ASSOCIATED_INTAKE_ROWS_PER_TICKET: usize = 3; const ORCHESTRATOR_SUFFIX: &str = "-orchestrator"; #[derive(Debug, Clone, PartialEq, Eq)] @@ -574,12 +620,6 @@ fn build_workspace_panel_with_registry_model( } model.rows.extend(pod_rows(pods)); - model.rows.sort_by(|a, b| { - a.priority - .cmp(&b.priority) - .then_with(|| row_updated_at(b).cmp(row_updated_at(a))) - .then_with(|| a.title.cmp(&b.title)) - }); model } @@ -628,13 +668,13 @@ fn build_ticket_rows( pods: &PodList, registry: &PanelRegistrySnapshot, ) -> ticket::Result> { - let mut rows = Vec::new(); + let mut ticket_rows = Vec::new(); for summary in backend.list(TicketFilter::all())? { if summary.workflow_state == TicketWorkflowState::Closed { continue; } let ticket = backend.show(TicketIdOrSlug::Query(summary.id.clone()))?; - rows.push(ticket_row( + ticket_rows.push(ticket_row( summary, &ticket.events, &ticket.relations.blockers, @@ -642,6 +682,19 @@ fn build_ticket_rows( registry, )); } + ticket_rows.sort_by(|a, b| { + a.priority + .cmp(&b.priority) + .then_with(|| row_updated_at(b).cmp(row_updated_at(a))) + .then_with(|| a.title.cmp(&b.title)) + }); + + let mut rows = Vec::new(); + for row in ticket_rows { + let intake_rows = ticket_intake_pod_rows(&row); + rows.push(row); + rows.extend(intake_rows); + } Ok(rows) } @@ -653,7 +706,17 @@ fn ticket_row( registry: &PanelRegistrySnapshot, ) -> PanelRow { let local_claim = local_claim_for_ticket(&summary, pods, registry); - let related_pods = related_pods_for_ticket(&summary, pods, registry); + let intake_pods = + associated_intake_entries_for_ticket(&summary, pods, registry, local_claim.as_ref()); + let mut related_pods = Vec::new(); + if let Some(claim) = local_claim.as_ref() { + related_pods.push(claim.pod_name.clone()); + } + for pod_name in intake_pods.iter().map(|intake| intake.pod_name.clone()) { + if !related_pods.iter().any(|existing| existing == &pod_name) { + related_pods.push(pod_name); + } + } let derived = derive_ticket_state(&summary, relation_blockers); let latest_event = events.last(); let entry = TicketPanelEntry { @@ -669,6 +732,7 @@ fn ticket_row( blocked_reason: derived.blocked_reason.clone(), related_pods: related_pods.clone(), local_claim, + intake_pods, }; let subtitle = ticket_subtitle(&entry); PanelRow { @@ -802,32 +866,111 @@ fn derive_ticket_state( } } -fn related_pods_for_ticket( +fn associated_intake_entries_for_ticket( summary: &TicketSummary, pods: &PodList, registry: &PanelRegistrySnapshot, -) -> Vec { - let id = lowercase(&summary.id); - let mut names = Vec::new(); - if let Some(claim) = registry.claim_for_ticket(&summary.id) { - names.push(claim.pod_name.clone()); + local_claim: Option<&TicketLocalClaimEntry>, +) -> Vec { + let mut entries = Vec::new(); + if let Some(claim) = local_claim.filter(|claim| is_intake_role(&claim.role)) { + entries.push(TicketAssociatedIntakeEntry { + ticket_id: summary.id.clone(), + pod_name: claim.pod_name.clone(), + status: claim.status, + source: TicketAssociatedIntakeSource::LocalClaim, + }); } - for pod in pods.entries.iter().filter_map(|pod| { - let name = lowercase(&pod.name); - if !id.is_empty() && name.contains(&id) { - Some(pod.name.clone()) - } else { - None + + let mut related_sessions = registry + .sessions + .iter() + .filter(|session| { + is_intake_role(&session.role) + && session + .related_tickets + .iter() + .any(|related| related.id == summary.id.as_str()) + }) + .map(|session| session.pod_name.clone()) + .collect::>(); + related_sessions.sort(); + related_sessions.dedup(); + + for pod_name in related_sessions { + if entries.iter().any(|entry| entry.pod_name == pod_name) { + continue; } - }) { - if !names.iter().any(|existing| existing == &pod) { - names.push(pod); - } - if names.len() >= 5 { + entries.push(TicketAssociatedIntakeEntry { + ticket_id: summary.id.clone(), + status: local_claim_status_for_pod(&pod_name, pods), + pod_name, + source: TicketAssociatedIntakeSource::RelatedSession, + }); + if entries.len() >= MAX_ASSOCIATED_INTAKE_ROWS_PER_TICKET { break; } } - names + + entries.truncate(MAX_ASSOCIATED_INTAKE_ROWS_PER_TICKET); + entries +} + +fn is_intake_role(role: &str) -> bool { + role.eq_ignore_ascii_case("intake") +} + +fn ticket_intake_pod_rows(row: &PanelRow) -> Vec { + row.ticket + .as_ref() + .map(|ticket| { + ticket + .intake_pods + .iter() + .map(ticket_intake_pod_row) + .collect() + }) + .unwrap_or_default() +} + +fn ticket_intake_pod_row(intake: &TicketAssociatedIntakeEntry) -> PanelRow { + let stale = intake.status == TicketLocalClaimStatus::Stale; + PanelRow { + key: PanelRowKey::TicketIntakePod { + ticket_id: intake.ticket_id.clone(), + pod_name: intake.pod_name.clone(), + }, + kind: PanelRowKind::TicketIntakePod, + title: format!("↳ Intake Pod: {}", intake.pod_name), + subtitle: Some(format!( + "Ticket {} · {} · {}", + intake.ticket_id, + intake.source.label(), + intake.status.label() + )), + status: intake.status.label().to_string(), + priority: ActionPriority::ActiveWork, + next_action: if stale { + None + } else { + Some(NextUserAction::OpenPod) + }, + ticket: None, + related_pods: vec![intake.pod_name.clone()], + disabled_reason: if stale { + Some( + "Associated Intake Pod is stale; no live or restorable Pod entry is available." + .to_string(), + ) + } else { + None + }, + key_hint: Some(if stale { + "Stale Intake claim/session; restore is unavailable".to_string() + } else { + "Open/attach this Ticket's Intake Pod".to_string() + }), + } } fn local_claim_for_ticket( @@ -962,15 +1105,12 @@ fn excerpt(markdown: &str, max_chars: usize) -> Option { } } -fn lowercase(value: &str) -> String { - value.to_ascii_lowercase() -} - #[allow(dead_code)] #[cfg(test)] mod tests { use super::*; use crate::pod_list::{LivePodInfo, PodEntrySummary}; + use crate::role_session_registry::{PanelRegistryStore, RelatedTicketRef, RoleSessionOrigin}; use std::fs; use std::path::{Path, PathBuf}; use tempfile::TempDir; @@ -1196,6 +1336,97 @@ mod tests { assert_eq!(done.next_action, Some(NextUserAction::Close)); } + #[test] + fn workspace_panel_shows_ticket_associated_intake_pods_adjacent_to_ticket() { + let temp = TempDir::new().unwrap(); + write_ticket_config(temp.path()); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + create_ticket(&backend, "Ticket With Intake", |input| { + input.workflow_state = Some(TicketWorkflowState::Ready); + }); + let ticket_id = backend + .list(TicketFilter::all()) + .unwrap() + .into_iter() + .find(|ticket| ticket.title == "Ticket With Intake") + .unwrap() + .id; + let preticket_pod = format!("pre-{ticket_id}-intake"); + let registry = PanelRegistryStore::from_root(temp.path().join("registry")); + registry + .claim_ticket(&ticket_id, None, "claimed-intake", "intake") + .unwrap(); + registry + .record_session( + "shared-intake", + "intake", + RoleSessionOrigin::RoleLaunch, + None, + [RelatedTicketRef { + id: ticket_id.clone(), + slug: None, + }], + ) + .unwrap(); + registry + .record_session( + &preticket_pod, + "intake", + RoleSessionOrigin::PreTicketIntake, + None, + [], + ) + .unwrap(); + + let pods = live_pods(&["claimed-intake", "shared-intake", &preticket_pod]); + let model = + build_workspace_panel_with_registry(temp.path(), &pods, ®istry.snapshot().unwrap()); + + let ticket_index = model + .rows + .iter() + .position(|row| row.key == PanelRowKey::Ticket(ticket_id.clone())) + .unwrap(); + let ticket_row = &model.rows[ticket_index]; + let ticket = ticket_row.ticket.as_ref().unwrap(); + assert_eq!( + ticket + .intake_pods + .iter() + .map(|entry| entry.pod_name.as_str()) + .collect::>(), + vec!["claimed-intake", "shared-intake"] + ); + assert_eq!(ticket.related_pods, vec!["claimed-intake", "shared-intake"]); + assert_eq!( + model.rows[ticket_index + 1].key, + PanelRowKey::TicketIntakePod { + ticket_id: ticket_id.clone(), + pod_name: "claimed-intake".to_string(), + } + ); + assert_eq!( + model.rows[ticket_index + 1].kind, + PanelRowKind::TicketIntakePod + ); + assert_eq!(model.rows[ticket_index + 1].status, "live"); + assert_eq!( + model.rows[ticket_index + 1].next_action, + Some(NextUserAction::OpenPod) + ); + assert_eq!( + model.rows[ticket_index + 2].key, + PanelRowKey::TicketIntakePod { + ticket_id: ticket_id.clone(), + pod_name: "shared-intake".to_string(), + } + ); + assert!(model.rows.iter().all(|row| { + row.kind != PanelRowKind::TicketIntakePod + || row.key.pod_name() != Some(preticket_pod.as_str()) + })); + } + #[test] fn workspace_panel_displays_local_ticket_claim_status() { let temp = TempDir::new().unwrap();