merge: panel ticket two-line rows
This commit is contained in:
commit
98357b8aa2
|
|
@ -2,7 +2,7 @@
|
||||||
title: 'Panel Ticket rows を2行表示にして gate 情報を分離する'
|
title: 'Panel Ticket rows を2行表示にして gate 情報を分離する'
|
||||||
state: 'inprogress'
|
state: 'inprogress'
|
||||||
created_at: '2026-06-13T18:10:57Z'
|
created_at: '2026-06-13T18:10:57Z'
|
||||||
updated_at: '2026-06-14T06:10:45Z'
|
updated_at: '2026-06-14T06:37:08Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
readiness: 'implementation_ready'
|
readiness: 'implementation_ready'
|
||||||
risk_flags: ['tui', 'workspace-panel', 'ticket-relations', 'mouse-input', 'layout']
|
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.
|
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
|
.await
|
||||||
}
|
}
|
||||||
NextUserAction::Close => unreachable!("Close action is handled before row dispatch"),
|
NextUserAction::Close => unreachable!("Close action is handled before row dispatch"),
|
||||||
NextUserAction::Clarify
|
NextUserAction::Clarify | NextUserAction::OpenPod | NextUserAction::Wait => {
|
||||||
| NextUserAction::Edit
|
Ok(TicketActionOutcome {
|
||||||
| NextUserAction::OpenPod
|
notice: format!(
|
||||||
| NextUserAction::Wait => Ok(TicketActionOutcome {
|
"{} for Ticket {} has no safe inline workspace-panel dispatch; use the Ticket workflow.",
|
||||||
notice: format!(
|
request.action.label(),
|
||||||
"{} for Ticket {} has no safe inline workspace-panel dispatch; use the Ticket workflow.",
|
current_ticket.id
|
||||||
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 {
|
if area.width == 0 || area.height == 0 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
rows.iter()
|
|
||||||
.enumerate()
|
let mut hit_boxes: Vec<PanelRowHitBox> = Vec::new();
|
||||||
.filter_map(|(offset, row)| {
|
for (offset, row) in rows.iter().enumerate() {
|
||||||
let y = area.y.checked_add(offset as u16)?;
|
let Some(key) = row.key.clone() else {
|
||||||
if y >= area.y.saturating_add(area.height) {
|
continue;
|
||||||
return None;
|
};
|
||||||
|
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),
|
hit_boxes.push(PanelRowHitBox {
|
||||||
key: row.key.clone()?,
|
rect: Rect::new(area.x, y, area.width, 1),
|
||||||
})
|
key,
|
||||||
})
|
});
|
||||||
.collect()
|
}
|
||||||
|
hit_boxes
|
||||||
}
|
}
|
||||||
|
|
||||||
fn panel_diagnostic_lines(panel: &WorkspacePanelViewModel, width: u16) -> Vec<Line<'static>> {
|
fn panel_diagnostic_lines(panel: &WorkspacePanelViewModel, width: u16) -> Vec<Line<'static>> {
|
||||||
|
|
@ -5139,16 +5153,15 @@ fn panel_action_rows(
|
||||||
if rows.is_empty() {
|
if rows.is_empty() {
|
||||||
return Vec::new();
|
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(
|
lines.push(PanelListRow::inert(panel_action_header_line(
|
||||||
rows.len(),
|
rows.len(),
|
||||||
width,
|
width,
|
||||||
)));
|
)));
|
||||||
for row in rows {
|
for row in rows {
|
||||||
lines.push(PanelListRow::selectable(
|
for line in panel_row_lines(row, selected == Some(&row.key), width) {
|
||||||
panel_row_line(row, selected == Some(&row.key), width),
|
lines.push(PanelListRow::selectable(line, row.key.clone()));
|
||||||
row.key.clone(),
|
}
|
||||||
));
|
|
||||||
}
|
}
|
||||||
lines
|
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_STATE_COLUMN_WIDTH: usize = 10;
|
||||||
const TICKET_ID_COLUMN_WIDTH: usize = 13;
|
|
||||||
const POD_STATUS_COLUMN_WIDTH: usize = 18;
|
const POD_STATUS_COLUMN_WIDTH: usize = 18;
|
||||||
|
|
||||||
fn panel_row_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
|
fn panel_row_lines(row: &PanelRow, selected: bool, width: u16) -> [Line<'static>; 2] {
|
||||||
let marker = if selected { "▶ " } else { " " };
|
[
|
||||||
|
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 {
|
let title_style = if selected {
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::Magenta)
|
.fg(Color::Magenta)
|
||||||
|
|
@ -5181,22 +5199,10 @@ fn panel_row_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::Magenta)
|
Style::default().fg(Color::Magenta)
|
||||||
};
|
};
|
||||||
let ticket_ref = panel_ticket_reference(row);
|
|
||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
let mut remaining = width as usize;
|
let mut remaining = width as usize;
|
||||||
|
|
||||||
push_bounded_span(
|
push_ticket_marker_span(&mut spans, selected, &mut remaining);
|
||||||
&mut spans,
|
|
||||||
marker,
|
|
||||||
if selected {
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Magenta)
|
|
||||||
.add_modifier(Modifier::BOLD)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(Color::DarkGray)
|
|
||||||
},
|
|
||||||
&mut remaining,
|
|
||||||
);
|
|
||||||
push_column_span(
|
push_column_span(
|
||||||
&mut spans,
|
&mut spans,
|
||||||
&row.status,
|
&row.status,
|
||||||
|
|
@ -5204,18 +5210,96 @@ fn panel_row_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
|
||||||
panel_priority_style(row.priority),
|
panel_priority_style(row.priority),
|
||||||
&mut remaining,
|
&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);
|
push_bounded_span(&mut spans, row.title.as_str(), title_style, &mut remaining);
|
||||||
|
|
||||||
Line::from(spans)
|
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 {
|
fn panel_ticket_reference(row: &PanelRow) -> String {
|
||||||
row.ticket
|
row.ticket
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -5264,7 +5348,6 @@ fn padded_cell(value: &str, width: usize) -> String {
|
||||||
|
|
||||||
fn panel_priority_style(priority: ActionPriority) -> Style {
|
fn panel_priority_style(priority: ActionPriority) -> Style {
|
||||||
match priority {
|
match priority {
|
||||||
ActionPriority::UserReply => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
|
||||||
ActionPriority::ReadyForQueue => Style::default().fg(Color::Green),
|
ActionPriority::ReadyForQueue => Style::default().fg(Color::Green),
|
||||||
ActionPriority::ActiveWork => Style::default().fg(Color::Cyan),
|
ActionPriority::ActiveWork => Style::default().fg(Color::Cyan),
|
||||||
ActionPriority::Background => Style::default().fg(Color::DarkGray),
|
ActionPriority::Background => Style::default().fg(Color::DarkGray),
|
||||||
|
|
@ -5379,8 +5462,11 @@ fn target_status_line(app: &MultiPodApp) -> Line<'static> {
|
||||||
.selected_panel_row()
|
.selected_panel_row()
|
||||||
.filter(|row| row.is_ticket_action())
|
.filter(|row| row.is_ticket_action())
|
||||||
{
|
{
|
||||||
let action = row.next_action.map(NextUserAction::label).unwrap_or("View");
|
let action = row
|
||||||
Line::from(vec![
|
.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("composer target ", Style::default().fg(Color::DarkGray)),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
app.composer_target().label(),
|
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(row.status.clone(), panel_priority_style(row.priority)),
|
||||||
Span::styled(" · blank Enter ", Style::default().fg(Color::DarkGray)),
|
Span::styled(" · blank Enter ", Style::default().fg(Color::DarkGray)),
|
||||||
Span::styled(action, Style::default().fg(Color::Magenta)),
|
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() {
|
} else if let Some(entry) = app.selected_pod_entry() {
|
||||||
let (status, status_style) = row_status_label(entry);
|
let (status, status_style) = row_status_label(entry);
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
|
|
@ -6694,10 +6788,11 @@ branch = "orchestration/custom-panel"
|
||||||
|
|
||||||
assert_eq!(boxes.len(), 3);
|
assert_eq!(boxes.len(), 3);
|
||||||
assert_eq!(boxes[0].key, PanelRowKey::Ticket("TICKET-1".into()));
|
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].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].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)));
|
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);
|
let rows = list_rows(&app, 80, 6);
|
||||||
app.set_row_hit_boxes(&rows, Rect::new(0, 0, 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!(
|
assert_eq!(
|
||||||
app.selected_row,
|
app.selected_row,
|
||||||
Some(PanelRowKey::Ticket("TICKET-2".into()))
|
Some(PanelRowKey::Ticket("TICKET-2".into()))
|
||||||
|
|
@ -6787,7 +6888,7 @@ branch = "orchestration/custom-panel"
|
||||||
let rows = list_rows(&app, 80, 6);
|
let rows = list_rows(&app, 80, 6);
|
||||||
app.set_row_hit_boxes(&rows, Rect::new(0, 0, 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!(
|
assert_eq!(
|
||||||
app.selected_row,
|
app.selected_row,
|
||||||
Some(PanelRowKey::Ticket("TICKET-2".into()))
|
Some(PanelRowKey::Ticket("TICKET-2".into()))
|
||||||
|
|
@ -7221,15 +7322,39 @@ branch = "orchestration/custom-panel"
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn panel_ticket_rows_use_aligned_columns_before_title() {
|
fn panel_ticket_rows_render_state_title_then_detail_line() {
|
||||||
let review_row = panel_test_ticket_row(
|
let row = panel_test_ticket_row(
|
||||||
"00001KTX1QMG9",
|
"00001KTX1QMG9",
|
||||||
"Workspace panel composer targets",
|
"Workspace panel composer targets",
|
||||||
ActionPriority::ActiveWork,
|
ActionPriority::ActiveWork,
|
||||||
NextUserAction::Wait,
|
NextUserAction::Wait,
|
||||||
"inprogress",
|
"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",
|
"00001KTTB479X",
|
||||||
"Long Ticket title that should be rendered after short columns",
|
"Long Ticket title that should be rendered after short columns",
|
||||||
ActionPriority::ReadyForQueue,
|
ActionPriority::ReadyForQueue,
|
||||||
|
|
@ -7237,53 +7362,62 @@ branch = "orchestration/custom-panel"
|
||||||
"ready",
|
"ready",
|
||||||
);
|
);
|
||||||
|
|
||||||
let review_line = plain_line(&panel_row_line(&review_row, true, 160));
|
let [title, detail] = panel_row_lines(&row, false, 160);
|
||||||
let ready_line = plain_line(&panel_row_line(&ready_row, false, 160));
|
let title_line = plain_line(&title);
|
||||||
|
let detail_line = plain_line(&detail);
|
||||||
let state_start = 2;
|
let state_start = 2;
|
||||||
let id_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
|
let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
|
||||||
let title_start = id_start + TICKET_ID_COLUMN_WIDTH + 1;
|
|
||||||
|
|
||||||
assert!(!review_line.starts_with("▶ Workspace panel composer targets"));
|
assert!(title_line.starts_with(" ready"));
|
||||||
assert_eq!(display_column(&review_line, "inprogress"), state_start);
|
assert!(detail_line.starts_with(" 00001KTTB479X"));
|
||||||
assert_eq!(display_column(&ready_line, "ready"), state_start);
|
assert_eq!(display_column(&title_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_eq!(
|
assert_eq!(
|
||||||
display_column(&review_line, "Workspace panel composer targets"),
|
display_column(&title_line, "Long Ticket title"),
|
||||||
title_start
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
display_column(&ready_line, "Long Ticket title"),
|
|
||||||
title_start
|
title_start
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn panel_ticket_title_truncates_after_stable_columns() {
|
fn panel_ticket_title_truncates_after_state_column() {
|
||||||
let row = panel_test_ticket_row(
|
let row = panel_test_ticket_row(
|
||||||
"00001KTTB479X",
|
"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,
|
ActionPriority::ReadyForQueue,
|
||||||
NextUserAction::Queue,
|
NextUserAction::Queue,
|
||||||
"ready",
|
"ready",
|
||||||
);
|
);
|
||||||
|
|
||||||
let line = plain_line(&panel_row_line(&row, false, 58));
|
let [title, detail] = panel_row_lines(&row, false, 42);
|
||||||
let title_start = 2 + TICKET_STATE_COLUMN_WIDTH + 1 + TICKET_ID_COLUMN_WIDTH + 1;
|
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);
|
assert_eq!(title_line.width(), 42);
|
||||||
let row_id = row.ticket.as_ref().unwrap().id.as_str();
|
assert_eq!(display_column(&title_line, "Very long Ticket"), title_start);
|
||||||
assert_eq!(row_id.width(), TICKET_ID_COLUMN_WIDTH);
|
assert!(title_line.ends_with('…'));
|
||||||
assert_eq!(
|
assert_eq!(detail_line.width(), 42);
|
||||||
display_column(&line, row_id),
|
assert!(detail_line.starts_with(" 00001KTTB479X · Gate: clear"));
|
||||||
title_start - TICKET_ID_COLUMN_WIDTH - 1
|
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);
|
row.disabled_reason = Some("Queue disabled: waiting for BLOCKER-1".to_string());
|
||||||
assert!(line.ends_with('…'));
|
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]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -190,14 +190,12 @@ pub(crate) enum PanelRowKind {
|
||||||
Planning,
|
Planning,
|
||||||
Ticket,
|
Ticket,
|
||||||
Review,
|
Review,
|
||||||
Blocked,
|
|
||||||
ActiveWork,
|
ActiveWork,
|
||||||
Pod,
|
Pod,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub(crate) enum ActionPriority {
|
pub(crate) enum ActionPriority {
|
||||||
UserReply,
|
|
||||||
ReadyForQueue,
|
ReadyForQueue,
|
||||||
ActiveWork,
|
ActiveWork,
|
||||||
Background,
|
Background,
|
||||||
|
|
@ -208,7 +206,6 @@ pub(crate) enum NextUserAction {
|
||||||
Clarify,
|
Clarify,
|
||||||
Queue,
|
Queue,
|
||||||
Close,
|
Close,
|
||||||
Edit,
|
|
||||||
Wait,
|
Wait,
|
||||||
OpenPod,
|
OpenPod,
|
||||||
}
|
}
|
||||||
|
|
@ -219,7 +216,6 @@ impl NextUserAction {
|
||||||
Self::Clarify => "Clarify",
|
Self::Clarify => "Clarify",
|
||||||
Self::Queue => "Queue",
|
Self::Queue => "Queue",
|
||||||
Self::Close => "Close",
|
Self::Close => "Close",
|
||||||
Self::Edit => "Edit",
|
|
||||||
Self::Wait => "Wait",
|
Self::Wait => "Wait",
|
||||||
Self::OpenPod => "Open",
|
Self::OpenPod => "Open",
|
||||||
}
|
}
|
||||||
|
|
@ -705,7 +701,8 @@ fn derive_ticket_state(
|
||||||
relation_blockers: &[TicketRelationBlocker],
|
relation_blockers: &[TicketRelationBlocker],
|
||||||
) -> DerivedTicketState {
|
) -> DerivedTicketState {
|
||||||
if !relation_blockers.is_empty() {
|
if !relation_blockers.is_empty() {
|
||||||
let blockers = relation_blockers
|
let shown_blockers = relation_blockers.iter().take(3).count();
|
||||||
|
let mut blockers = relation_blockers
|
||||||
.iter()
|
.iter()
|
||||||
.take(3)
|
.take(3)
|
||||||
.map(|blocker| {
|
.map(|blocker| {
|
||||||
|
|
@ -718,15 +715,31 @@ fn derive_ticket_state(
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.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 {
|
return DerivedTicketState {
|
||||||
kind: PanelRowKind::Blocked,
|
kind: match summary.workflow_state {
|
||||||
priority: ActionPriority::UserReply,
|
TicketWorkflowState::Planning => PanelRowKind::Planning,
|
||||||
action: Some(NextUserAction::Edit),
|
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
|
||||||
disabled_reason: Some(
|
PanelRowKind::ActiveWork
|
||||||
"Unresolved Ticket relation blocks queueing; resolve dependency/blocker before ready -> queued."
|
}
|
||||||
.to_string(),
|
TicketWorkflowState::Done | TicketWorkflowState::Closed => PanelRowKind::Review,
|
||||||
),
|
TicketWorkflowState::Ready => PanelRowKind::Ticket,
|
||||||
key_hint: Some("Open the Ticket relation diagnostics before queueing".to_string()),
|
},
|
||||||
|
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),
|
blocked_reason: Some(blockers),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1105,7 +1118,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let temp = TempDir::new().unwrap();
|
||||||
write_ticket_config(temp.path());
|
write_ticket_config(temp.path());
|
||||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||||
|
|
@ -1134,14 +1147,14 @@ mod tests {
|
||||||
.find(|row| row.title == "Ready Blocked By Relation")
|
.find(|row| row.title == "Ready Blocked By Relation")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(row.kind, PanelRowKind::Blocked);
|
assert_eq!(row.kind, PanelRowKind::Ticket);
|
||||||
assert_eq!(row.next_action, Some(NextUserAction::Edit));
|
assert_eq!(row.next_action, Some(NextUserAction::Wait));
|
||||||
assert_eq!(row.priority, ActionPriority::UserReply);
|
assert_eq!(row.priority, ActionPriority::Background);
|
||||||
assert!(
|
assert!(
|
||||||
row.disabled_reason
|
row.disabled_reason
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.contains("Unresolved Ticket relation")
|
.contains("Queue disabled: waiting for")
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
row.ticket
|
row.ticket
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user