chore: record panel followups

This commit is contained in:
2026-06-18 22:41:54 +09:00
parent b6685af3ae
commit 8cbade818f
14 changed files with 411 additions and 60 deletions
+58
View File
@@ -5406,6 +5406,9 @@ fn panel_ticket_detail(row: &PanelRow) -> String {
}
let mut parts = vec![panel_ticket_reference(row)];
if let Some(overlay_detail) = panel_ticket_overlay_detail(row) {
parts.push(overlay_detail);
}
if let Some(blocked_reason) = row
.ticket
.as_ref()
@@ -5441,6 +5444,24 @@ fn panel_ticket_action_label(row: &PanelRow, action: NextUserAction) -> &'static
}
}
fn panel_ticket_overlay_detail(row: &PanelRow) -> Option<String> {
let ticket = row.ticket.as_ref()?;
let overlay = ticket.orchestration_overlay.as_ref()?;
let mut detail = format!(
"Overlay: local {} · {} {}",
ticket.workflow_state.as_str(),
overlay.source,
overlay.workflow_state.as_str()
);
if matches!(
overlay.workflow_state,
TicketWorkflowState::Done | TicketWorkflowState::Closed
) {
detail.push_str(" · merge pending");
}
Some(detail)
}
fn panel_ticket_reason(row: &PanelRow) -> Option<&str> {
row.disabled_reason
.as_deref()
@@ -7596,6 +7617,43 @@ branch = "orchestration/custom-panel"
assert!(detail_line.ends_with('…'));
}
#[test]
fn panel_orchestration_overlay_uses_compact_status_column_and_detail_line() {
let mut row = panel_test_ticket_row(
"00001OVERLAY",
"Overlay column regression",
ActionPriority::Background,
NextUserAction::Wait,
"queued",
);
row.kind = PanelRowKind::Review;
row.status = "q→done".to_string();
row.disabled_reason = Some(
"orchestration worktree overlay shows Ticket state done; local state remains queued"
.to_string(),
);
row.ticket.as_mut().unwrap().orchestration_overlay =
Some(crate::workspace_panel::TicketStateOverlay {
source: "orchestration".to_string(),
workflow_state: TicketWorkflowState::Done,
});
let lines = panel_row_lines(&row, false, 160);
let title_line = plain_line(&lines[0]);
let detail_line = plain_line(&lines[1]);
let state_start = 2;
let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
assert!(row.status.width() <= TICKET_STATE_COLUMN_WIDTH);
assert_eq!(display_column(&title_line, "q→done"), state_start);
assert_eq!(
display_column(&title_line, "Overlay column regression"),
title_start
);
assert!(!title_line.contains("orchestration"));
assert!(detail_line.contains("Overlay: local queued · orchestration done · merge pending"));
}
#[test]
fn ready_ticket_with_waiting_gate_shows_queue_disabled_reason() {
let mut row = panel_test_ticket_row(
+136 -55
View File
@@ -1089,15 +1089,25 @@ fn ticket_state_display(
) -> String {
match overlay {
Some(overlay) => format!(
"local: {} · {}: {}",
local.as_str(),
overlay.source,
overlay.workflow_state.as_str()
"{}{}",
compact_ticket_state_label(local),
compact_ticket_state_label(overlay.workflow_state)
),
None => local.as_str().to_string(),
}
}
fn compact_ticket_state_label(state: TicketWorkflowState) -> &'static str {
match state {
TicketWorkflowState::Planning => "plan",
TicketWorkflowState::Ready => "ready",
TicketWorkflowState::Queued => "q",
TicketWorkflowState::InProgress => "prog",
TicketWorkflowState::Done => "done",
TicketWorkflowState::Closed => "cls",
}
}
fn apply_orchestration_overlay_to_derived(
derived: &mut DerivedTicketState,
local: TicketWorkflowState,
@@ -1138,51 +1148,90 @@ fn apply_orchestration_overlay_to_derived(
}
}
fn format_relation_blockers(blockers: &[&TicketRelationBlocker]) -> String {
let shown_blockers = blockers.iter().take(3).count();
let mut formatted = blockers
.iter()
.take(3)
.map(|blocker| {
format!(
"{} via {} (state: {})",
blocker.blocking_ticket,
blocker.reason_kind,
blocker.blocking_state.as_str()
)
})
.collect::<Vec<_>>()
.join(", ");
let remaining_blockers = blockers.len().saturating_sub(shown_blockers);
if remaining_blockers > 0 {
formatted.push_str(&format!(" (+{remaining_blockers} more)"));
}
formatted
}
fn relation_blocker_allows_ready_queue(blocker: &TicketRelationBlocker) -> bool {
matches!(
blocker.blocking_state,
TicketWorkflowState::Queued | TicketWorkflowState::InProgress
)
}
fn derive_ticket_state(
summary: &TicketSummary,
relation_blockers: &[TicketRelationBlocker],
) -> DerivedTicketState {
if !relation_blockers.is_empty() {
let shown_blockers = relation_blockers.iter().take(3).count();
let mut blockers = relation_blockers
let active_blockers = relation_blockers
.iter()
.take(3)
.map(|blocker| {
format!(
"{} via {} (state: {})",
blocker.blocking_ticket,
blocker.reason_kind,
blocker.blocking_state.as_str()
)
})
.collect::<Vec<_>>()
.join(", ");
let remaining_blockers = relation_blockers.len().saturating_sub(shown_blockers);
if remaining_blockers > 0 {
blockers.push_str(&format!(" (+{remaining_blockers} more)"));
.filter(|blocker| !relation_blocker_allows_ready_queue(blocker))
.collect::<Vec<_>>();
if !active_blockers.is_empty() || summary.workflow_state != TicketWorkflowState::Ready {
let blockers_to_report = if active_blockers.is_empty() {
relation_blockers.iter().collect::<Vec<_>>()
} else {
active_blockers
};
let blockers = format_relation_blockers(&blockers_to_report);
let waiting_reason = format!("waiting for {blockers}");
return DerivedTicketState {
kind: match summary.workflow_state {
TicketWorkflowState::Planning => PanelRowKind::Planning,
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
PanelRowKind::ActiveWork
}
TicketWorkflowState::Done | TicketWorkflowState::Closed => PanelRowKind::Review,
TicketWorkflowState::Ready => PanelRowKind::Ticket,
},
priority: match summary.workflow_state {
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
ActionPriority::ActiveWork
}
_ => ActionPriority::Background,
},
action: Some(NextUserAction::Wait),
disabled_reason: Some(format!(
"Queue disabled: {waiting_reason}. Resolve dependency/blocker before ready -> queued."
)),
key_hint: Some(format!("Gate: {waiting_reason}")),
blocked_reason: Some(blockers),
};
}
let waiting_reason = format!("waiting for {blockers}");
let blockers = format_relation_blockers(
&relation_blockers
.iter()
.collect::<Vec<&TicketRelationBlocker>>(),
);
return DerivedTicketState {
kind: match summary.workflow_state {
TicketWorkflowState::Planning => PanelRowKind::Planning,
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
PanelRowKind::ActiveWork
}
TicketWorkflowState::Done | TicketWorkflowState::Closed => PanelRowKind::Review,
TicketWorkflowState::Ready => PanelRowKind::Ticket,
},
priority: match summary.workflow_state {
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
ActionPriority::ActiveWork
}
_ => ActionPriority::Background,
},
action: Some(NextUserAction::Wait),
disabled_reason: Some(format!(
"Queue disabled: {waiting_reason}. Resolve dependency/blocker before ready -> queued."
kind: PanelRowKind::Ticket,
priority: ActionPriority::ReadyForQueue,
action: Some(NextUserAction::Queue),
disabled_reason: None,
key_hint: Some(format!(
"Queue allowed: prerequisites are already queued/in progress; Orchestrator will preserve order ({blockers})."
)),
key_hint: Some(format!("Gate: {waiting_reason}")),
blocked_reason: Some(blockers),
blocked_reason: None,
};
}
@@ -1635,15 +1684,6 @@ mod tests {
.unwrap_or_else(|| panic!("missing row for {title}"))
}
fn status_contains(row: &PanelRow, needle: &str) {
assert!(
row.status.contains(needle),
"status {:?} did not contain {:?}",
row.status,
needle
);
}
fn live_pods(names: &[&str]) -> PodList {
PodList::from_sources(
crate::pod_list::PodVisibilitySource::ResumePicker,
@@ -1731,8 +1771,7 @@ mod tests {
let model = build_workspace_panel(temp.path(), &empty_pods());
let matched = ticket_row_by_title(&model, "Overlay Match");
status_contains(matched, "local: queued");
status_contains(matched, "orchestration: inprogress");
assert_eq!(matched.status, "q→prog");
assert_eq!(
matched.ticket.as_ref().unwrap().workflow_state,
TicketWorkflowState::Queued
@@ -1772,8 +1811,7 @@ mod tests {
let model = build_workspace_panel(temp.path(), &empty_pods());
let row = ticket_row_by_title(&model, "Overlay In Progress");
status_contains(row, "local: queued");
status_contains(row, "orchestration: inprogress");
assert_eq!(row.status, "q→prog");
assert_eq!(row.next_action, Some(NextUserAction::Wait));
assert_eq!(row.kind, PanelRowKind::ActiveWork);
assert_eq!(fs::read_to_string(&local_item).unwrap(), before);
@@ -1801,8 +1839,7 @@ mod tests {
let model = build_workspace_panel(temp.path(), &empty_pods());
let row = ticket_row_by_title(&model, "Overlay Done");
status_contains(row, "local: queued");
status_contains(row, "orchestration: done");
assert_eq!(row.status, "q→done");
assert_eq!(row.kind, PanelRowKind::Review);
assert_eq!(row.next_action, Some(NextUserAction::Wait));
assert_ne!(row.next_action, Some(NextUserAction::Queue));
@@ -2123,6 +2160,50 @@ mod tests {
);
}
#[test]
fn workspace_panel_allows_ready_ticket_when_relation_prerequisite_is_queued() {
let temp = TempDir::new().unwrap();
write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
let mut ready_input = NewTicket::new("Ready After Queued Relation");
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
let ready = backend.create(ready_input).unwrap();
let mut dependency_input = NewTicket::new("Queued Relation Dependency");
dependency_input.workflow_state = Some(TicketWorkflowState::Queued);
let dependency = backend.create(dependency_input).unwrap();
backend
.add_ticket_relation(
TicketIdOrSlug::Id(ready.id.clone()),
NewTicketRelation {
kind: TicketRelationKind::DependsOn,
target: dependency.id.clone(),
note: None,
author: Some("test".to_string()),
},
)
.unwrap();
let model = build_workspace_panel(temp.path(), &empty_pods());
let row = model
.rows
.iter()
.find(|row| row.title == "Ready After Queued Relation")
.unwrap();
assert_eq!(row.kind, PanelRowKind::Ticket);
assert_eq!(row.next_action, Some(NextUserAction::Queue));
assert_eq!(row.priority, ActionPriority::ReadyForQueue);
assert!(row.disabled_reason.is_none());
assert!(row.ticket.as_ref().unwrap().blocked_reason.is_none());
assert!(
row.key_hint
.as_deref()
.unwrap()
.contains("Queue allowed: prerequisites are already queued/in progress")
);
assert!(row.key_hint.as_deref().unwrap().contains(&dependency.id));
}
#[test]
fn workspace_panel_defaults_missing_open_state_to_planning_and_displays_done_state() {
let temp = TempDir::new().unwrap();