merge: panel intake pod rows

This commit is contained in:
Keisuke Hirata 2026-06-15 00:54:44 +09:00
commit 2fcbd6aefb
No known key found for this signature in database
2 changed files with 331 additions and 59 deletions

View File

@ -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<Pa
let mut keys = panel
.rows
.iter()
.filter(|row| row.is_ticket_action())
.filter(|row| row.is_ticket_section_row())
.map(|row| row.key.clone())
.collect::<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::<Vec<_>>();
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<Line<'static>> {
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<Span<'static>>, 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()),

View File

@ -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<String>,
pub(crate) related_pods: Vec<String>,
pub(crate) local_claim: Option<TicketLocalClaimEntry>,
pub(crate) intake_pods: Vec<TicketAssociatedIntakeEntry>,
}
#[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<Vec<PanelRow>> {
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<String> {
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<TicketAssociatedIntakeEntry> {
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::<Vec<_>>();
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<PanelRow> {
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<String> {
}
}
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, &registry.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<_>>(),
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();