merge: panel ticket two-line rows
This commit is contained in:
commit
98357b8aa2
|
|
@ -2,7 +2,7 @@
|
|||
title: 'Panel Ticket rows を2行表示にして gate 情報を分離する'
|
||||
state: 'inprogress'
|
||||
created_at: '2026-06-13T18:10:57Z'
|
||||
updated_at: '2026-06-14T06:10:45Z'
|
||||
updated_at: '2026-06-14T06:37:08Z'
|
||||
assignee: null
|
||||
readiness: 'implementation_ready'
|
||||
risk_flags: ['tui', 'workspace-panel', 'ticket-relations', 'mouse-input', 'layout']
|
||||
|
|
|
|||
|
|
@ -45,4 +45,65 @@ Validation:
|
|||
|
||||
Ticket evidence, related records, orchestration plan, and clean workspace state were checked. No blockers remain; accept for implementation before worktree/spawn side effects.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: hare at: 2026-06-14T06:26:55Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
Implemented Workspace Panel Ticket row rendering as two visual lines.
|
||||
|
||||
Changes:
|
||||
- Ticket action rows now render a state/title line and a separate detail line containing Ticket id, gate, action, and reason.
|
||||
- Selection stays logical per Ticket row: selected Ticket visual lines use the same `|` marker, and contiguous hit boxes for the same Ticket are grouped so mouse clicks on either line select the same Ticket without dispatching actions.
|
||||
- Relation blockers are shown as gate/wait information with queue disabled wording and `Wait` action rather than as blocked/error/human-reply styling; no persisted `waiting` lifecycle state was added.
|
||||
- Updated focused row layout, waiting-gate, and mouse hit-testing tests.
|
||||
|
||||
Validation:
|
||||
- PASS: `cargo test -p tui panel_ticket --lib`
|
||||
- PASS: `cargo test -p tui row_hit_testing --lib`
|
||||
- PASS: `cargo test -p tui mouse_click --lib`
|
||||
- PASS: `cargo test -p tui ready_ticket_with_waiting_gate --lib`
|
||||
- PASS: `cargo test -p tui workspace_panel_marks_ready_ticket_with_unresolved_relation_waiting_gate --lib`
|
||||
- PASS: `cargo test -p yoi-e2e --features e2e --test panel -- --nocapture`
|
||||
- PASS: `cargo build -p yoi`
|
||||
- PASS: `cargo fmt --check`
|
||||
- PASS: `git diff --check`
|
||||
- NOTE: `cargo test -p tui --lib` was also attempted and failed in pre-existing/unrelated tests: `multi_pod::tests::orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace`, `spawn::tests::profile_choices_include_builtin_and_project_default_marker`, and `spawn::tests::profile_choices_use_project_registry_default`. The failures assert unrelated orchestration/profile defaults and are outside this Ticket's row/layout/mouse changes.
|
||||
- NOT RUN: `nix build .#yoi` (skipped as nonessential here; it may create root-level build output symlinks and is heavier than the scoped TUI/panel validation).
|
||||
|
||||
Risks:
|
||||
- Ticket rows now consume two terminal lines, so very short panel areas may display fewer logical Ticket rows before clipping.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: review author: hare at: 2026-06-14T06:37:08Z status: approve -->
|
||||
|
||||
## Review: approve
|
||||
|
||||
Approve.
|
||||
|
||||
Evidence:
|
||||
- Reviewed `git diff f709fc10..HEAD` for implementation commit `645d048d`.
|
||||
- Ticket action rows now expand each Ticket into two selectable visual rows: the first renders canonical state plus title, the second renders Ticket id, derived gate, action, and reason. The selected marker is the `| ` grouping marker on both visual lines, and the title line no longer embeds the Ticket id.
|
||||
- Hit testing merges adjacent visual rows with the same `PanelRowKey`, so both Ticket visual lines select the same logical row/hitbox; mouse selection does not dispatch by itself, with dispatch still requiring the existing blank-Enter path.
|
||||
- Relation blockers are derived from relation data into `Wait`/queue-disabled gate text and preserve the canonical workflow state rather than introducing a persisted `waiting` lifecycle state or the old blocked/edit/human-reply row class.
|
||||
- Short-area/list-row slicing remains bounded by available height and hitboxes ignore out-of-area rows, so very small panel areas degrade without panics.
|
||||
|
||||
Validation:
|
||||
- `cargo test -p tui panel_ticket --lib` — passed (3 tests).
|
||||
- `cargo test -p tui row_hit_testing --lib` — passed (1 test).
|
||||
- `cargo test -p tui mouse_click --lib` — passed (2 tests).
|
||||
- `cargo test -p tui ready_ticket_with_waiting_gate --lib` — passed (1 test).
|
||||
- `cargo test -p tui workspace_panel_marks_ready_ticket_with_unresolved_relation_waiting_gate --lib` — passed (1 test).
|
||||
- `cargo test -p yoi-e2e --features e2e --test panel -- --nocapture` — passed (3 tests).
|
||||
- `cargo build -p yoi` — passed.
|
||||
- `cargo fmt --check` — passed.
|
||||
- `git diff --check f709fc10..HEAD` — passed.
|
||||
|
||||
Risks / unresolved:
|
||||
- None found within the requested review focus.
|
||||
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -3753,16 +3753,15 @@ async fn dispatch_ticket_action(
|
|||
.await
|
||||
}
|
||||
NextUserAction::Close => unreachable!("Close action is handled before row dispatch"),
|
||||
NextUserAction::Clarify
|
||||
| NextUserAction::Edit
|
||||
| NextUserAction::OpenPod
|
||||
| NextUserAction::Wait => Ok(TicketActionOutcome {
|
||||
notice: format!(
|
||||
"{} for Ticket {} has no safe inline workspace-panel dispatch; use the Ticket workflow.",
|
||||
request.action.label(),
|
||||
current_ticket.id
|
||||
),
|
||||
}),
|
||||
NextUserAction::Clarify | NextUserAction::OpenPod | NextUserAction::Wait => {
|
||||
Ok(TicketActionOutcome {
|
||||
notice: format!(
|
||||
"{} for Ticket {} has no safe inline workspace-panel dispatch; use the Ticket workflow.",
|
||||
request.action.label(),
|
||||
current_ticket.id
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5094,19 +5093,34 @@ fn row_hit_boxes(rows: &[PanelListRow], area: Rect) -> Vec<PanelRowHitBox> {
|
|||
if area.width == 0 || area.height == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
rows.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(offset, row)| {
|
||||
let y = area.y.checked_add(offset as u16)?;
|
||||
if y >= area.y.saturating_add(area.height) {
|
||||
return None;
|
||||
|
||||
let mut hit_boxes: Vec<PanelRowHitBox> = Vec::new();
|
||||
for (offset, row) in rows.iter().enumerate() {
|
||||
let Some(key) = row.key.clone() else {
|
||||
continue;
|
||||
};
|
||||
let Some(y) = area.y.checked_add(offset as u16) else {
|
||||
continue;
|
||||
};
|
||||
if y >= area.y.saturating_add(area.height) {
|
||||
continue;
|
||||
}
|
||||
if let Some(last) = hit_boxes.last_mut() {
|
||||
if last.key == key
|
||||
&& last.rect.x == area.x
|
||||
&& last.rect.width == area.width
|
||||
&& last.rect.y.saturating_add(last.rect.height) == y
|
||||
{
|
||||
last.rect.height = last.rect.height.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
Some(PanelRowHitBox {
|
||||
rect: Rect::new(area.x, y, area.width, 1),
|
||||
key: row.key.clone()?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
hit_boxes.push(PanelRowHitBox {
|
||||
rect: Rect::new(area.x, y, area.width, 1),
|
||||
key,
|
||||
});
|
||||
}
|
||||
hit_boxes
|
||||
}
|
||||
|
||||
fn panel_diagnostic_lines(panel: &WorkspacePanelViewModel, width: u16) -> Vec<Line<'static>> {
|
||||
|
|
@ -5139,16 +5153,15 @@ fn panel_action_rows(
|
|||
if rows.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut lines = Vec::with_capacity(rows.len() + 1);
|
||||
let mut lines = Vec::with_capacity((rows.len() * 2) + 1);
|
||||
lines.push(PanelListRow::inert(panel_action_header_line(
|
||||
rows.len(),
|
||||
width,
|
||||
)));
|
||||
for row in rows {
|
||||
lines.push(PanelListRow::selectable(
|
||||
panel_row_line(row, selected == Some(&row.key), width),
|
||||
row.key.clone(),
|
||||
));
|
||||
for line in panel_row_lines(row, selected == Some(&row.key), width) {
|
||||
lines.push(PanelListRow::selectable(line, row.key.clone()));
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
|
@ -5169,11 +5182,16 @@ fn panel_action_header_line(total: usize, width: u16) -> Line<'static> {
|
|||
}
|
||||
|
||||
const TICKET_STATE_COLUMN_WIDTH: usize = 10;
|
||||
const TICKET_ID_COLUMN_WIDTH: usize = 13;
|
||||
const POD_STATUS_COLUMN_WIDTH: usize = 18;
|
||||
|
||||
fn panel_row_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
|
||||
let marker = if selected { "▶ " } else { " " };
|
||||
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_title_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
|
||||
let title_style = if selected {
|
||||
Style::default()
|
||||
.fg(Color::Magenta)
|
||||
|
|
@ -5181,22 +5199,10 @@ fn panel_row_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
|
|||
} else {
|
||||
Style::default().fg(Color::Magenta)
|
||||
};
|
||||
let ticket_ref = panel_ticket_reference(row);
|
||||
let mut spans = Vec::new();
|
||||
let mut remaining = width as usize;
|
||||
|
||||
push_bounded_span(
|
||||
&mut spans,
|
||||
marker,
|
||||
if selected {
|
||||
Style::default()
|
||||
.fg(Color::Magenta)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
},
|
||||
&mut remaining,
|
||||
);
|
||||
push_ticket_marker_span(&mut spans, selected, &mut remaining);
|
||||
push_column_span(
|
||||
&mut spans,
|
||||
&row.status,
|
||||
|
|
@ -5204,18 +5210,96 @@ fn panel_row_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
|
|||
panel_priority_style(row.priority),
|
||||
&mut remaining,
|
||||
);
|
||||
push_column_span(
|
||||
&mut spans,
|
||||
&ticket_ref,
|
||||
TICKET_ID_COLUMN_WIDTH,
|
||||
Style::default().fg(Color::DarkGray),
|
||||
&mut remaining,
|
||||
);
|
||||
push_bounded_span(&mut spans, row.title.as_str(), title_style, &mut remaining);
|
||||
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn panel_row_detail_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
|
||||
let mut spans = Vec::new();
|
||||
let mut remaining = width as usize;
|
||||
|
||||
push_ticket_marker_span(&mut spans, selected, &mut remaining);
|
||||
push_bounded_span(
|
||||
&mut spans,
|
||||
&panel_ticket_detail(row),
|
||||
ticket_detail_style(row),
|
||||
&mut remaining,
|
||||
);
|
||||
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn push_ticket_marker_span(spans: &mut Vec<Span<'static>>, selected: bool, remaining: &mut usize) {
|
||||
let (marker, style) = if selected {
|
||||
(
|
||||
"| ",
|
||||
Style::default()
|
||||
.fg(Color::Magenta)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
} else {
|
||||
(" ", Style::default().fg(Color::DarkGray))
|
||||
};
|
||||
push_bounded_span(spans, marker, style, remaining);
|
||||
}
|
||||
|
||||
fn panel_ticket_detail(row: &PanelRow) -> String {
|
||||
let mut parts = vec![panel_ticket_reference(row)];
|
||||
if let Some(blocked_reason) = row
|
||||
.ticket
|
||||
.as_ref()
|
||||
.and_then(|ticket| ticket.blocked_reason.as_deref())
|
||||
{
|
||||
parts.push(format!("Gate: waiting for {blocked_reason}"));
|
||||
} else {
|
||||
parts.push("Gate: clear".to_string());
|
||||
}
|
||||
if let Some(action) = row.next_action {
|
||||
parts.push(format!(
|
||||
"Action: {}",
|
||||
panel_ticket_action_label(row, action)
|
||||
));
|
||||
}
|
||||
if let Some(reason) = panel_ticket_reason(row) {
|
||||
parts.push(format!("Reason: {reason}"));
|
||||
}
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
fn panel_ticket_action_label(row: &PanelRow, action: NextUserAction) -> &'static str {
|
||||
if action == NextUserAction::Wait
|
||||
&& row
|
||||
.ticket
|
||||
.as_ref()
|
||||
.and_then(|ticket| ticket.blocked_reason.as_ref())
|
||||
.is_some()
|
||||
{
|
||||
"queue disabled"
|
||||
} else {
|
||||
action.label()
|
||||
}
|
||||
}
|
||||
|
||||
fn panel_ticket_reason(row: &PanelRow) -> Option<&str> {
|
||||
row.disabled_reason
|
||||
.as_deref()
|
||||
.or_else(|| row.key_hint.as_deref())
|
||||
}
|
||||
|
||||
fn ticket_detail_style(row: &PanelRow) -> Style {
|
||||
if row
|
||||
.ticket
|
||||
.as_ref()
|
||||
.and_then(|ticket| ticket.blocked_reason.as_ref())
|
||||
.is_some()
|
||||
{
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
}
|
||||
}
|
||||
|
||||
fn panel_ticket_reference(row: &PanelRow) -> String {
|
||||
row.ticket
|
||||
.as_ref()
|
||||
|
|
@ -5264,7 +5348,6 @@ fn padded_cell(value: &str, width: usize) -> String {
|
|||
|
||||
fn panel_priority_style(priority: ActionPriority) -> Style {
|
||||
match priority {
|
||||
ActionPriority::UserReply => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||
ActionPriority::ReadyForQueue => Style::default().fg(Color::Green),
|
||||
ActionPriority::ActiveWork => Style::default().fg(Color::Cyan),
|
||||
ActionPriority::Background => Style::default().fg(Color::DarkGray),
|
||||
|
|
@ -5379,8 +5462,11 @@ fn target_status_line(app: &MultiPodApp) -> Line<'static> {
|
|||
.selected_panel_row()
|
||||
.filter(|row| row.is_ticket_action())
|
||||
{
|
||||
let action = row.next_action.map(NextUserAction::label).unwrap_or("View");
|
||||
Line::from(vec![
|
||||
let action = row
|
||||
.next_action
|
||||
.map(|action| panel_ticket_action_label(row, action))
|
||||
.unwrap_or("View");
|
||||
let mut spans = vec![
|
||||
Span::styled("composer target ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
app.composer_target().label(),
|
||||
|
|
@ -5392,7 +5478,15 @@ fn target_status_line(app: &MultiPodApp) -> Line<'static> {
|
|||
Span::styled(row.status.clone(), panel_priority_style(row.priority)),
|
||||
Span::styled(" · blank Enter ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(action, Style::default().fg(Color::Magenta)),
|
||||
])
|
||||
];
|
||||
if let Some(reason) = panel_ticket_reason(row) {
|
||||
spans.push(Span::styled(" · ", Style::default().fg(Color::DarkGray)));
|
||||
spans.push(Span::styled(
|
||||
truncate_with_ellipsis(reason, 100),
|
||||
ticket_detail_style(row),
|
||||
));
|
||||
}
|
||||
Line::from(spans)
|
||||
} else if let Some(entry) = app.selected_pod_entry() {
|
||||
let (status, status_style) = row_status_label(entry);
|
||||
Line::from(vec![
|
||||
|
|
@ -6694,10 +6788,11 @@ branch = "orchestration/custom-panel"
|
|||
|
||||
assert_eq!(boxes.len(), 3);
|
||||
assert_eq!(boxes[0].key, PanelRowKey::Ticket("TICKET-1".into()));
|
||||
assert_eq!(boxes[0].rect, Rect::new(3, 6, 80, 1));
|
||||
assert_eq!(boxes[0].rect, Rect::new(3, 6, 80, 2));
|
||||
assert_eq!(boxes[1].key, PanelRowKey::Ticket("TICKET-2".into()));
|
||||
assert_eq!(boxes[1].rect, Rect::new(3, 7, 80, 1));
|
||||
assert_eq!(boxes[1].rect, Rect::new(3, 8, 80, 2));
|
||||
assert_eq!(boxes[2].key, PanelRowKey::Pod("alpha".into()));
|
||||
assert_eq!(boxes[2].rect, Rect::new(3, 11, 80, 1));
|
||||
assert!(boxes.iter().all(|hit| !hit.contains(2, hit.rect.y)));
|
||||
}
|
||||
|
||||
|
|
@ -6725,7 +6820,13 @@ branch = "orchestration/custom-panel"
|
|||
let rows = list_rows(&app, 80, 6);
|
||||
app.set_row_hit_boxes(&rows, Rect::new(0, 0, 80, 6));
|
||||
|
||||
assert!(app.handle_mouse_event(left_click(2, 2)));
|
||||
assert!(app.handle_mouse_event(left_click(2, 3)));
|
||||
assert_eq!(
|
||||
app.selected_row,
|
||||
Some(PanelRowKey::Ticket("TICKET-2".into()))
|
||||
);
|
||||
app.selected_row = None;
|
||||
assert!(app.handle_mouse_event(left_click(2, 4)));
|
||||
assert_eq!(
|
||||
app.selected_row,
|
||||
Some(PanelRowKey::Ticket("TICKET-2".into()))
|
||||
|
|
@ -6787,7 +6888,7 @@ branch = "orchestration/custom-panel"
|
|||
let rows = list_rows(&app, 80, 6);
|
||||
app.set_row_hit_boxes(&rows, Rect::new(0, 0, 80, 6));
|
||||
|
||||
assert!(app.handle_mouse_event(left_click(2, 2)));
|
||||
assert!(app.handle_mouse_event(left_click(2, 4)));
|
||||
assert_eq!(
|
||||
app.selected_row,
|
||||
Some(PanelRowKey::Ticket("TICKET-2".into()))
|
||||
|
|
@ -7221,15 +7322,39 @@ branch = "orchestration/custom-panel"
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn panel_ticket_rows_use_aligned_columns_before_title() {
|
||||
let review_row = panel_test_ticket_row(
|
||||
fn panel_ticket_rows_render_state_title_then_detail_line() {
|
||||
let row = panel_test_ticket_row(
|
||||
"00001KTX1QMG9",
|
||||
"Workspace panel composer targets",
|
||||
ActionPriority::ActiveWork,
|
||||
NextUserAction::Wait,
|
||||
"inprogress",
|
||||
);
|
||||
let ready_row = panel_test_ticket_row(
|
||||
|
||||
let [title, detail] = panel_row_lines(&row, true, 160);
|
||||
let title_line = plain_line(&title);
|
||||
let detail_line = plain_line(&detail);
|
||||
let state_start = 2;
|
||||
let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
|
||||
let row_id = row.ticket.as_ref().unwrap().id.as_str();
|
||||
|
||||
assert!(title_line.starts_with("| "));
|
||||
assert!(detail_line.starts_with("| "));
|
||||
assert!(!title_line.starts_with("▶"));
|
||||
assert!(!title_line.contains(row_id));
|
||||
assert_eq!(display_column(&title_line, "inprogress"), state_start);
|
||||
assert_eq!(
|
||||
display_column(&title_line, "Workspace panel composer targets"),
|
||||
title_start
|
||||
);
|
||||
assert!(detail_line.contains(row_id));
|
||||
assert!(detail_line.contains("Gate: clear"));
|
||||
assert!(detail_line.contains("Action: Wait"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_ticket_non_selected_rows_align_with_selected_marker_space() {
|
||||
let row = panel_test_ticket_row(
|
||||
"00001KTTB479X",
|
||||
"Long Ticket title that should be rendered after short columns",
|
||||
ActionPriority::ReadyForQueue,
|
||||
|
|
@ -7237,53 +7362,62 @@ branch = "orchestration/custom-panel"
|
|||
"ready",
|
||||
);
|
||||
|
||||
let review_line = plain_line(&panel_row_line(&review_row, true, 160));
|
||||
let ready_line = plain_line(&panel_row_line(&ready_row, false, 160));
|
||||
let [title, detail] = panel_row_lines(&row, false, 160);
|
||||
let title_line = plain_line(&title);
|
||||
let detail_line = plain_line(&detail);
|
||||
let state_start = 2;
|
||||
let id_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
|
||||
let title_start = id_start + TICKET_ID_COLUMN_WIDTH + 1;
|
||||
let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
|
||||
|
||||
assert!(!review_line.starts_with("▶ Workspace panel composer targets"));
|
||||
assert_eq!(display_column(&review_line, "inprogress"), state_start);
|
||||
assert_eq!(display_column(&ready_line, "ready"), state_start);
|
||||
let review_id = review_row.ticket.as_ref().unwrap().id.as_str();
|
||||
let ready_id = ready_row.ticket.as_ref().unwrap().id.as_str();
|
||||
assert_eq!(review_id.width(), TICKET_ID_COLUMN_WIDTH);
|
||||
assert_eq!(ready_id.width(), TICKET_ID_COLUMN_WIDTH);
|
||||
assert_eq!(display_column(&review_line, review_id), id_start);
|
||||
assert_eq!(display_column(&ready_line, ready_id), id_start);
|
||||
assert!(title_line.starts_with(" ready"));
|
||||
assert!(detail_line.starts_with(" 00001KTTB479X"));
|
||||
assert_eq!(display_column(&title_line, "ready"), state_start);
|
||||
assert_eq!(
|
||||
display_column(&review_line, "Workspace panel composer targets"),
|
||||
title_start
|
||||
);
|
||||
assert_eq!(
|
||||
display_column(&ready_line, "Long Ticket title"),
|
||||
display_column(&title_line, "Long Ticket title"),
|
||||
title_start
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_ticket_title_truncates_after_stable_columns() {
|
||||
fn panel_ticket_title_truncates_after_state_column() {
|
||||
let row = panel_test_ticket_row(
|
||||
"00001KTTB479X",
|
||||
"Very long Ticket title that should truncate only after the aligned short columns",
|
||||
"Very long Ticket title that should truncate only after the state column",
|
||||
ActionPriority::ReadyForQueue,
|
||||
NextUserAction::Queue,
|
||||
"ready",
|
||||
);
|
||||
|
||||
let line = plain_line(&panel_row_line(&row, false, 58));
|
||||
let title_start = 2 + TICKET_STATE_COLUMN_WIDTH + 1 + TICKET_ID_COLUMN_WIDTH + 1;
|
||||
let [title, detail] = panel_row_lines(&row, false, 42);
|
||||
let title_line = plain_line(&title);
|
||||
let detail_line = plain_line(&detail);
|
||||
let title_start = 2 + TICKET_STATE_COLUMN_WIDTH + 1;
|
||||
|
||||
assert_eq!(line.width(), 58);
|
||||
let row_id = row.ticket.as_ref().unwrap().id.as_str();
|
||||
assert_eq!(row_id.width(), TICKET_ID_COLUMN_WIDTH);
|
||||
assert_eq!(
|
||||
display_column(&line, row_id),
|
||||
title_start - TICKET_ID_COLUMN_WIDTH - 1
|
||||
assert_eq!(title_line.width(), 42);
|
||||
assert_eq!(display_column(&title_line, "Very long Ticket"), title_start);
|
||||
assert!(title_line.ends_with('…'));
|
||||
assert_eq!(detail_line.width(), 42);
|
||||
assert!(detail_line.starts_with(" 00001KTTB479X · Gate: clear"));
|
||||
assert!(detail_line.ends_with('…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_ticket_with_waiting_gate_shows_queue_disabled_reason() {
|
||||
let mut row = panel_test_ticket_row(
|
||||
"00001WAITING",
|
||||
"Ready but gated",
|
||||
ActionPriority::Background,
|
||||
NextUserAction::Wait,
|
||||
"ready",
|
||||
);
|
||||
assert_eq!(display_column(&line, "Very long Ticket"), title_start);
|
||||
assert!(line.ends_with('…'));
|
||||
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 detail_line = plain_line(&detail);
|
||||
|
||||
assert!(detail_line.contains("Gate: waiting for BLOCKER-1 via depends_on"));
|
||||
assert!(detail_line.contains("Action: queue disabled"));
|
||||
assert!(detail_line.contains("Reason: Queue disabled: waiting for BLOCKER-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -190,14 +190,12 @@ pub(crate) enum PanelRowKind {
|
|||
Planning,
|
||||
Ticket,
|
||||
Review,
|
||||
Blocked,
|
||||
ActiveWork,
|
||||
Pod,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(crate) enum ActionPriority {
|
||||
UserReply,
|
||||
ReadyForQueue,
|
||||
ActiveWork,
|
||||
Background,
|
||||
|
|
@ -208,7 +206,6 @@ pub(crate) enum NextUserAction {
|
|||
Clarify,
|
||||
Queue,
|
||||
Close,
|
||||
Edit,
|
||||
Wait,
|
||||
OpenPod,
|
||||
}
|
||||
|
|
@ -219,7 +216,6 @@ impl NextUserAction {
|
|||
Self::Clarify => "Clarify",
|
||||
Self::Queue => "Queue",
|
||||
Self::Close => "Close",
|
||||
Self::Edit => "Edit",
|
||||
Self::Wait => "Wait",
|
||||
Self::OpenPod => "Open",
|
||||
}
|
||||
|
|
@ -705,7 +701,8 @@ fn derive_ticket_state(
|
|||
relation_blockers: &[TicketRelationBlocker],
|
||||
) -> DerivedTicketState {
|
||||
if !relation_blockers.is_empty() {
|
||||
let blockers = relation_blockers
|
||||
let shown_blockers = relation_blockers.iter().take(3).count();
|
||||
let mut blockers = relation_blockers
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|blocker| {
|
||||
|
|
@ -718,15 +715,31 @@ fn derive_ticket_state(
|
|||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let remaining_blockers = relation_blockers.len().saturating_sub(shown_blockers);
|
||||
if remaining_blockers > 0 {
|
||||
blockers.push_str(&format!(" (+{remaining_blockers} more)"));
|
||||
}
|
||||
let waiting_reason = format!("waiting for {blockers}");
|
||||
return DerivedTicketState {
|
||||
kind: PanelRowKind::Blocked,
|
||||
priority: ActionPriority::UserReply,
|
||||
action: Some(NextUserAction::Edit),
|
||||
disabled_reason: Some(
|
||||
"Unresolved Ticket relation blocks queueing; resolve dependency/blocker before ready -> queued."
|
||||
.to_string(),
|
||||
),
|
||||
key_hint: Some("Open the Ticket relation diagnostics before queueing".to_string()),
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
|
@ -1105,7 +1118,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_panel_marks_ready_ticket_with_unresolved_relation_blocked() {
|
||||
fn workspace_panel_marks_ready_ticket_with_unresolved_relation_waiting_gate() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
write_ticket_config(temp.path());
|
||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||
|
|
@ -1134,14 +1147,14 @@ mod tests {
|
|||
.find(|row| row.title == "Ready Blocked By Relation")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(row.kind, PanelRowKind::Blocked);
|
||||
assert_eq!(row.next_action, Some(NextUserAction::Edit));
|
||||
assert_eq!(row.priority, ActionPriority::UserReply);
|
||||
assert_eq!(row.kind, PanelRowKind::Ticket);
|
||||
assert_eq!(row.next_action, Some(NextUserAction::Wait));
|
||||
assert_eq!(row.priority, ActionPriority::Background);
|
||||
assert!(
|
||||
row.disabled_reason
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("Unresolved Ticket relation")
|
||||
.contains("Queue disabled: waiting for")
|
||||
);
|
||||
assert!(
|
||||
row.ticket
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user