From a40d9c2027db1c9a59aef92b4bcd38ff6a4d2e78 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 20 Jul 2026 17:42:20 +0900 Subject: [PATCH] ticket: share workspace actionable projection --- crates/ticket/src/lib.rs | 511 ++++++++++++++++++++++++++++++ crates/tui/src/workspace_panel.rs | 310 ++++-------------- 2 files changed, 570 insertions(+), 251 deletions(-) diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 49ffcab5..4b89e7e1 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -739,6 +739,415 @@ pub struct TicketRelationView { pub notices: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TicketWorkspaceActionPriority { + ReadyForQueue, + ActiveWork, + Background, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TicketWorkspaceNextAction { + Clarify, + QueueForOrchestrator, + Close, + WaitForOrchestrator, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TicketWorkspaceRowKind { + Planning, + Ticket, + Review, + ActiveWork, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TicketWorkspaceStateOverlay { + pub source: String, + pub workflow_state: TicketWorkflowState, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TicketQueueGuard { + pub can_queue_for_orchestrator: bool, + pub reason: Option, + pub blocked_reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TicketWorkspaceProjection { + pub kind: TicketWorkspaceRowKind, + pub priority: TicketWorkspaceActionPriority, + pub next_action: Option, + pub visible_state: String, + pub visible_overlay: Option, + pub disabled_reason: Option, + pub key_hint: Option, + pub blocked_reason: Option, + pub queue_guard: TicketQueueGuard, +} + +pub fn project_ticket_workspace_item( + summary: &TicketSummary, + relation_blockers: &[TicketRelationBlocker], + orchestration_overlay: Option<&TicketWorkspaceStateOverlay>, +) -> TicketWorkspaceProjection { + let visible_overlay = orchestration_overlay + .filter(|overlay| { + ticket_overlay_state_has_progressed(summary.workflow_state, overlay.workflow_state) + }) + .cloned(); + let mut projection = derive_ticket_workspace_projection(summary, relation_blockers); + if let Some(overlay) = visible_overlay.as_ref() { + apply_workspace_overlay_to_projection(&mut projection, summary.workflow_state, overlay); + } + projection.visible_state = + ticket_workspace_state_display(summary.workflow_state, visible_overlay.as_ref()); + projection.visible_overlay = visible_overlay; + projection.queue_guard = ticket_queue_guard( + summary, + relation_blockers, + projection.visible_overlay.as_ref(), + ); + projection +} + +pub fn ticket_queue_guard( + summary: &TicketSummary, + relation_blockers: &[TicketRelationBlocker], + orchestration_overlay: Option<&TicketWorkspaceStateOverlay>, +) -> TicketQueueGuard { + if orchestration_overlay.is_some() { + return TicketQueueGuard { + can_queue_for_orchestrator: false, + reason: Some( + "orchestration overlay already shows progress; duplicate queue is suppressed" + .to_string(), + ), + blocked_reason: None, + }; + } + if summary.workflow_state != TicketWorkflowState::Ready { + return TicketQueueGuard { + can_queue_for_orchestrator: false, + reason: Some(format!( + "Ticket state is {}; only ready Tickets can be queued for Orchestrator", + summary.workflow_state.as_str() + )), + blocked_reason: None, + }; + } + let active_blockers = relation_blockers + .iter() + .filter(|blocker| !relation_blocker_allows_ready_queue(blocker)) + .collect::>(); + if !active_blockers.is_empty() { + let blockers = format_workspace_relation_blockers(&active_blockers); + return TicketQueueGuard { + can_queue_for_orchestrator: false, + reason: Some(format!("waiting for {blockers}")), + blocked_reason: Some(blockers), + }; + } + TicketQueueGuard { + can_queue_for_orchestrator: true, + reason: None, + blocked_reason: None, + } +} + +fn derive_ticket_workspace_projection( + summary: &TicketSummary, + relation_blockers: &[TicketRelationBlocker], +) -> TicketWorkspaceProjection { + if !relation_blockers.is_empty() { + let active_blockers = relation_blockers + .iter() + .filter(|blocker| !relation_blocker_allows_ready_queue(blocker)) + .collect::>(); + if !active_blockers.is_empty() || summary.workflow_state != TicketWorkflowState::Ready { + let blockers_to_report = if active_blockers.is_empty() { + relation_blockers.iter().collect::>() + } else { + active_blockers + }; + let blockers = format_workspace_relation_blockers(&blockers_to_report); + let waiting_reason = format!("waiting for {blockers}"); + return TicketWorkspaceProjection { + kind: workspace_row_kind_for_state(summary.workflow_state), + priority: match summary.workflow_state { + TicketWorkflowState::Queued | TicketWorkflowState::InProgress => { + TicketWorkspaceActionPriority::ActiveWork + } + _ => TicketWorkspaceActionPriority::Background, + }, + next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator), + visible_state: summary.workflow_state.as_str().to_string(), + visible_overlay: None, + 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), + queue_guard: TicketQueueGuard { + can_queue_for_orchestrator: false, + reason: Some(waiting_reason), + blocked_reason: None, + }, + }; + } + + let blockers = format_workspace_relation_blockers( + &relation_blockers + .iter() + .collect::>(), + ); + return TicketWorkspaceProjection { + kind: TicketWorkspaceRowKind::Ticket, + priority: TicketWorkspaceActionPriority::ReadyForQueue, + next_action: Some(TicketWorkspaceNextAction::QueueForOrchestrator), + visible_state: summary.workflow_state.as_str().to_string(), + visible_overlay: None, + disabled_reason: None, + key_hint: Some(format!( + "Queue allowed: prerequisites are already queued/in progress; Orchestrator will preserve order ({blockers})." + )), + blocked_reason: None, + queue_guard: TicketQueueGuard { + can_queue_for_orchestrator: true, + reason: None, + blocked_reason: None, + }, + }; + } + + match summary.workflow_state { + TicketWorkflowState::Ready => TicketWorkspaceProjection { + kind: TicketWorkspaceRowKind::Ticket, + priority: TicketWorkspaceActionPriority::ReadyForQueue, + next_action: Some(TicketWorkspaceNextAction::QueueForOrchestrator), + visible_state: summary.workflow_state.as_str().to_string(), + visible_overlay: None, + disabled_reason: None, + key_hint: Some( + "Queue transitions ready -> queued and may notify Orchestrator".to_string(), + ), + blocked_reason: None, + queue_guard: TicketQueueGuard { + can_queue_for_orchestrator: true, + reason: None, + blocked_reason: None, + }, + }, + TicketWorkflowState::Queued => TicketWorkspaceProjection { + kind: TicketWorkspaceRowKind::ActiveWork, + priority: TicketWorkspaceActionPriority::ActiveWork, + next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator), + visible_state: summary.workflow_state.as_str().to_string(), + visible_overlay: None, + disabled_reason: Some("Ticket is queued for Orchestrator routing.".to_string()), + key_hint: None, + blocked_reason: None, + queue_guard: TicketQueueGuard { + can_queue_for_orchestrator: false, + reason: Some("Ticket is already queued for Orchestrator routing".to_string()), + blocked_reason: None, + }, + }, + TicketWorkflowState::InProgress => TicketWorkspaceProjection { + kind: TicketWorkspaceRowKind::ActiveWork, + priority: TicketWorkspaceActionPriority::ActiveWork, + next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator), + visible_state: summary.workflow_state.as_str().to_string(), + visible_overlay: None, + disabled_reason: Some("Ticket is already in progress.".to_string()), + key_hint: None, + blocked_reason: None, + queue_guard: TicketQueueGuard { + can_queue_for_orchestrator: false, + reason: Some("Ticket is already in progress".to_string()), + blocked_reason: None, + }, + }, + TicketWorkflowState::Done => TicketWorkspaceProjection { + kind: TicketWorkspaceRowKind::Review, + priority: TicketWorkspaceActionPriority::Background, + next_action: Some(TicketWorkspaceNextAction::Close), + visible_state: summary.workflow_state.as_str().to_string(), + visible_overlay: None, + disabled_reason: Some( + "state is done; close if a resolution is still missing.".to_string(), + ), + key_hint: None, + blocked_reason: None, + queue_guard: TicketQueueGuard { + can_queue_for_orchestrator: false, + reason: Some("Ticket is done; close or review instead of queueing".to_string()), + blocked_reason: None, + }, + }, + TicketWorkflowState::Planning => TicketWorkspaceProjection { + kind: TicketWorkspaceRowKind::Planning, + priority: TicketWorkspaceActionPriority::Background, + next_action: Some(TicketWorkspaceNextAction::Clarify), + visible_state: summary.workflow_state.as_str().to_string(), + visible_overlay: None, + disabled_reason: Some( + "Ticket is still in planning; mark it ready before queueing.".to_string(), + ), + key_hint: Some("Planning/Intake helpers can set state = ready".to_string()), + blocked_reason: None, + queue_guard: TicketQueueGuard { + can_queue_for_orchestrator: false, + reason: Some("Ticket is still in planning".to_string()), + blocked_reason: None, + }, + }, + TicketWorkflowState::Closed => TicketWorkspaceProjection { + kind: TicketWorkspaceRowKind::Review, + priority: TicketWorkspaceActionPriority::Background, + next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator), + visible_state: summary.workflow_state.as_str().to_string(), + visible_overlay: None, + disabled_reason: Some("Ticket is closed.".to_string()), + key_hint: None, + blocked_reason: None, + queue_guard: TicketQueueGuard { + can_queue_for_orchestrator: false, + reason: Some("Ticket is closed".to_string()), + blocked_reason: None, + }, + }, + } +} + +fn workspace_row_kind_for_state(state: TicketWorkflowState) -> TicketWorkspaceRowKind { + match state { + TicketWorkflowState::Planning => TicketWorkspaceRowKind::Planning, + TicketWorkflowState::Queued | TicketWorkflowState::InProgress => { + TicketWorkspaceRowKind::ActiveWork + } + TicketWorkflowState::Done | TicketWorkflowState::Closed => TicketWorkspaceRowKind::Review, + TicketWorkflowState::Ready => TicketWorkspaceRowKind::Ticket, + } +} + +fn apply_workspace_overlay_to_projection( + projection: &mut TicketWorkspaceProjection, + local: TicketWorkflowState, + overlay: &TicketWorkspaceStateOverlay, +) { + projection.next_action = Some(TicketWorkspaceNextAction::WaitForOrchestrator); + let overlay_state = overlay.workflow_state.as_str(); + match overlay.workflow_state { + TicketWorkflowState::Done | TicketWorkflowState::Closed => { + projection.kind = TicketWorkspaceRowKind::Review; + projection.priority = TicketWorkspaceActionPriority::Background; + projection.disabled_reason = Some(format!( + "{} worktree overlay shows Ticket state {overlay_state}; local state remains {} until merge/review/close authority updates the current branch.", + overlay.source, + local.as_str() + )); + projection.key_hint = Some(format!( + "Merge pending: local: {} · {}: {overlay_state}", + local.as_str(), + overlay.source + )); + } + TicketWorkflowState::InProgress | TicketWorkflowState::Queued => { + projection.kind = TicketWorkspaceRowKind::ActiveWork; + projection.priority = TicketWorkspaceActionPriority::ActiveWork; + projection.disabled_reason = Some(format!( + "{} worktree overlay shows Ticket state {overlay_state}; local state remains {} and duplicate queue/start actions are suppressed.", + overlay.source, + local.as_str() + )); + projection.key_hint = Some(format!( + "Progress overlay: local: {} · {}: {overlay_state}", + local.as_str(), + overlay.source + )); + } + TicketWorkflowState::Planning | TicketWorkflowState::Ready => {} + } +} + +fn ticket_workspace_state_display( + local: TicketWorkflowState, + overlay: Option<&TicketWorkspaceStateOverlay>, +) -> String { + match overlay { + Some(overlay) => format!( + "{}→{}", + compact_ticket_state_label(local), + compact_ticket_state_label(overlay.workflow_state) + ), + None => local.as_str().to_string(), + } +} + +fn ticket_overlay_state_has_progressed( + local: TicketWorkflowState, + overlay: TicketWorkflowState, +) -> bool { + workflow_state_progress_rank(overlay) > workflow_state_progress_rank(local) +} + +fn workflow_state_progress_rank(state: TicketWorkflowState) -> u8 { + match state { + TicketWorkflowState::Planning => 0, + TicketWorkflowState::Ready => 1, + TicketWorkflowState::Queued => 2, + TicketWorkflowState::InProgress => 3, + TicketWorkflowState::Done => 4, + TicketWorkflowState::Closed => 5, + } +} + +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 relation_blocker_allows_ready_queue(blocker: &TicketRelationBlocker) -> bool { + matches!( + blocker.blocking_state, + TicketWorkflowState::Queued | TicketWorkflowState::InProgress + ) +} + +fn format_workspace_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::>() + .join(", "); + let remaining_blockers = blockers.len().saturating_sub(shown_blockers); + if remaining_blockers > 0 { + formatted.push_str(&format!(" (+{remaining_blockers} more)")); + } + formatted +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OrchestrationPlanKind { @@ -3962,6 +4371,108 @@ mod tests { LocalTicketBackend::new(dir.path().join("tickets")) } + fn summary_with_state(state: TicketWorkflowState) -> TicketSummary { + TicketSummary { + id: "000TEST".to_string(), + slug: "000TEST".to_string(), + title: "Test Ticket".to_string(), + status: ExtensibleTicketStatus::Open, + kind: "ticket".to_string(), + priority: "P2".to_string(), + labels: Vec::new(), + readiness: None, + workflow_state: state, + workflow_state_explicit: true, + queued_by: None, + queued_at: None, + updated_at: Some("2026-07-20T00:00:00Z".to_string()), + } + } + + fn blocker_with_state(state: TicketWorkflowState) -> TicketRelationBlocker { + TicketRelationBlocker { + blocking_ticket: "000BLOCK".to_string(), + reason_kind: "depends_on".to_string(), + relation_kind: TicketRelationKind::DependsOn, + note: None, + blocking_state: state, + } + } + + #[test] + fn workspace_projection_queues_ready_ticket_for_orchestrator() { + let summary = summary_with_state(TicketWorkflowState::Ready); + let projection = project_ticket_workspace_item(&summary, &[], None); + + assert_eq!(projection.kind, TicketWorkspaceRowKind::Ticket); + assert_eq!( + projection.priority, + TicketWorkspaceActionPriority::ReadyForQueue + ); + assert_eq!( + projection.next_action, + Some(TicketWorkspaceNextAction::QueueForOrchestrator) + ); + assert!(projection.queue_guard.can_queue_for_orchestrator); + assert!(projection.disabled_reason.is_none()); + } + + #[test] + fn workspace_projection_blocks_ready_queue_on_unstarted_dependency() { + let summary = summary_with_state(TicketWorkflowState::Ready); + let blockers = [blocker_with_state(TicketWorkflowState::Planning)]; + let projection = project_ticket_workspace_item(&summary, &blockers, None); + + assert_eq!(projection.kind, TicketWorkspaceRowKind::Ticket); + assert_eq!( + projection.next_action, + Some(TicketWorkspaceNextAction::WaitForOrchestrator) + ); + assert!(!projection.queue_guard.can_queue_for_orchestrator); + assert!(projection.blocked_reason.is_some()); + assert!(projection.disabled_reason.is_some()); + } + + #[test] + fn workspace_projection_allows_ready_queue_when_dependency_is_already_queued() { + let summary = summary_with_state(TicketWorkflowState::Ready); + let blockers = [blocker_with_state(TicketWorkflowState::Queued)]; + let projection = project_ticket_workspace_item(&summary, &blockers, None); + + assert_eq!( + projection.next_action, + Some(TicketWorkspaceNextAction::QueueForOrchestrator) + ); + assert!(projection.queue_guard.can_queue_for_orchestrator); + assert!(projection.blocked_reason.is_none()); + assert!( + projection + .key_hint + .as_deref() + .unwrap_or_default() + .contains("Orchestrator will preserve order") + ); + } + + #[test] + fn workspace_projection_overlay_suppresses_duplicate_queue() { + let summary = summary_with_state(TicketWorkflowState::Ready); + let overlay = TicketWorkspaceStateOverlay { + source: "orchestration".to_string(), + workflow_state: TicketWorkflowState::InProgress, + }; + let projection = project_ticket_workspace_item(&summary, &[], Some(&overlay)); + + assert_eq!(projection.kind, TicketWorkspaceRowKind::ActiveWork); + assert_eq!( + projection.next_action, + Some(TicketWorkspaceNextAction::WaitForOrchestrator) + ); + assert_eq!(projection.visible_state, "ready→prog"); + assert!(!projection.queue_guard.can_queue_for_orchestrator); + assert!(projection.visible_overlay.is_some()); + } + #[test] fn workflow_state_rejects_legacy_intake_alias() { assert_eq!( diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index 00eb6c5d..a43a15fd 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -12,7 +12,8 @@ use ticket::config::{ use ticket::{ LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketIdOrSlug, TicketInvalidRecord, TicketListQuery, TicketMeta, TicketRelationBlocker, TicketSummary, - TicketWorkflowState, + TicketWorkflowState, TicketWorkspaceActionPriority, TicketWorkspaceNextAction, + TicketWorkspaceRowKind, TicketWorkspaceStateOverlay, project_ticket_workspace_item, }; use crate::role_session_registry::{PanelRegistrySnapshot, PanelRegistryStore}; @@ -267,11 +268,7 @@ pub(crate) struct TicketPanelEntry { pub(crate) intake_workers: Vec, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct TicketStateOverlay { - pub(crate) source: String, - pub(crate) workflow_state: TicketWorkflowState, -} +pub(crate) type TicketStateOverlay = TicketWorkspaceStateOverlay; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TicketAssociatedIntakeEntry { @@ -1233,29 +1230,24 @@ fn ticket_row( related_workers.push(worker_name); } } - let visible_overlay = orchestration_overlay - .filter(|overlay| { - overlay_state_has_progressed(summary.workflow_state, overlay.workflow_state) - }) - .cloned(); - let mut derived = derive_ticket_state(&summary, relation_blockers); - if let Some(overlay) = visible_overlay.as_ref() { - apply_orchestration_overlay_to_derived(&mut derived, summary.workflow_state, overlay); - } + let projection = + project_ticket_workspace_item(&summary, relation_blockers, orchestration_overlay); let latest_event = events.last(); - let state_display = ticket_state_display(summary.workflow_state, visible_overlay.as_ref()); + let kind = panel_row_kind_from_workspace(projection.kind); + let priority = action_priority_from_workspace(projection.priority); + let next_action = projection.next_action.map(next_user_action_from_workspace); let entry = TicketPanelEntry { id: summary.id.clone(), title: summary.title.clone(), priority: summary.priority.clone(), workflow_state: summary.workflow_state, workflow_state_explicit: summary.workflow_state_explicit, - orchestration_overlay: visible_overlay, - next_action: derived.action, + orchestration_overlay: projection.visible_overlay.clone(), + next_action, updated_at: summary.updated_at.clone(), latest_event_kind: latest_event.map(|event| event.kind.as_str().to_string()), latest_event_excerpt: latest_event.and_then(|event| excerpt(event.body.as_str(), 72)), - blocked_reason: derived.blocked_reason.clone(), + blocked_reason: projection.blocked_reason.clone(), related_workers: related_workers.clone(), local_claim, intake_workers, @@ -1263,251 +1255,42 @@ fn ticket_row( let subtitle = ticket_subtitle(&entry); PanelRow { key: PanelRowKey::Ticket(summary.id), - kind: derived.kind, + kind, title: summary.title, subtitle, - status: state_display, - priority: derived.priority, - next_action: derived.action, + status: projection.visible_state, + priority, + next_action, ticket: Some(entry), related_workers, - disabled_reason: derived.disabled_reason, - key_hint: derived.key_hint, + disabled_reason: projection.disabled_reason, + key_hint: projection.key_hint, } } -#[derive(Debug, Clone, PartialEq, Eq)] -struct DerivedTicketState { - kind: PanelRowKind, - priority: ActionPriority, - action: Option, - disabled_reason: Option, - key_hint: Option, - blocked_reason: Option, -} - -fn workflow_state_progress_rank(state: TicketWorkflowState) -> u8 { - match state { - TicketWorkflowState::Planning => 0, - TicketWorkflowState::Ready => 1, - TicketWorkflowState::Queued => 2, - TicketWorkflowState::InProgress => 3, - TicketWorkflowState::Done => 4, - TicketWorkflowState::Closed => 5, +fn panel_row_kind_from_workspace(kind: TicketWorkspaceRowKind) -> PanelRowKind { + match kind { + TicketWorkspaceRowKind::Planning => PanelRowKind::Planning, + TicketWorkspaceRowKind::Ticket => PanelRowKind::Ticket, + TicketWorkspaceRowKind::Review => PanelRowKind::Review, + TicketWorkspaceRowKind::ActiveWork => PanelRowKind::ActiveWork, } } -fn overlay_state_has_progressed(local: TicketWorkflowState, overlay: TicketWorkflowState) -> bool { - workflow_state_progress_rank(overlay) > workflow_state_progress_rank(local) -} - -fn ticket_state_display( - local: TicketWorkflowState, - overlay: Option<&TicketStateOverlay>, -) -> String { - match overlay { - Some(overlay) => format!( - "{}→{}", - compact_ticket_state_label(local), - compact_ticket_state_label(overlay.workflow_state) - ), - None => local.as_str().to_string(), +fn action_priority_from_workspace(priority: TicketWorkspaceActionPriority) -> ActionPriority { + match priority { + TicketWorkspaceActionPriority::ReadyForQueue => ActionPriority::ReadyForQueue, + TicketWorkspaceActionPriority::ActiveWork => ActionPriority::ActiveWork, + TicketWorkspaceActionPriority::Background => ActionPriority::Background, } } -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, - overlay: &TicketStateOverlay, -) { - derived.action = Some(NextUserAction::Wait); - let overlay_state = overlay.workflow_state.as_str(); - match overlay.workflow_state { - TicketWorkflowState::Done | TicketWorkflowState::Closed => { - derived.kind = PanelRowKind::Review; - derived.priority = ActionPriority::Background; - derived.disabled_reason = Some(format!( - "{} worktree overlay shows Ticket state {overlay_state}; local state remains {} until merge/review/close authority updates the current branch.", - overlay.source, - local.as_str() - )); - derived.key_hint = Some(format!( - "Merge pending: local: {} · {}: {overlay_state}", - local.as_str(), - overlay.source - )); - } - TicketWorkflowState::InProgress | TicketWorkflowState::Queued => { - derived.kind = PanelRowKind::ActiveWork; - derived.priority = ActionPriority::ActiveWork; - derived.disabled_reason = Some(format!( - "{} worktree overlay shows Ticket state {overlay_state}; local state remains {} and duplicate queue/start actions are suppressed.", - overlay.source, - local.as_str() - )); - derived.key_hint = Some(format!( - "Progress overlay: local: {} · {}: {overlay_state}", - local.as_str(), - overlay.source - )); - } - TicketWorkflowState::Planning | TicketWorkflowState::Ready => {} - } -} - -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::>() - .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 active_blockers = relation_blockers - .iter() - .filter(|blocker| !relation_blocker_allows_ready_queue(blocker)) - .collect::>(); - if !active_blockers.is_empty() || summary.workflow_state != TicketWorkflowState::Ready { - let blockers_to_report = if active_blockers.is_empty() { - relation_blockers.iter().collect::>() - } 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 blockers = format_relation_blockers( - &relation_blockers - .iter() - .collect::>(), - ); - return DerivedTicketState { - 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})." - )), - blocked_reason: None, - }; - } - - match summary.workflow_state { - TicketWorkflowState::Ready => DerivedTicketState { - kind: PanelRowKind::Ticket, - priority: ActionPriority::ReadyForQueue, - action: Some(NextUserAction::Queue), - disabled_reason: None, - key_hint: Some( - "Queue transitions ready -> queued and may notify Orchestrator".to_string(), - ), - blocked_reason: None, - }, - TicketWorkflowState::Queued => DerivedTicketState { - kind: PanelRowKind::ActiveWork, - priority: ActionPriority::ActiveWork, - action: Some(NextUserAction::Wait), - disabled_reason: Some("Ticket is queued for Orchestrator routing.".to_string()), - key_hint: None, - blocked_reason: None, - }, - TicketWorkflowState::InProgress => DerivedTicketState { - kind: PanelRowKind::ActiveWork, - priority: ActionPriority::ActiveWork, - action: Some(NextUserAction::Wait), - disabled_reason: Some("Ticket is already in progress.".to_string()), - key_hint: None, - blocked_reason: None, - }, - TicketWorkflowState::Done => DerivedTicketState { - kind: PanelRowKind::Review, - priority: ActionPriority::Background, - action: Some(NextUserAction::Close), - disabled_reason: Some( - "state is done; close if a resolution is still missing.".to_string(), - ), - key_hint: None, - blocked_reason: None, - }, - TicketWorkflowState::Planning => DerivedTicketState { - kind: PanelRowKind::Planning, - priority: ActionPriority::Background, - action: Some(NextUserAction::Clarify), - disabled_reason: Some( - "Ticket is still in planning; mark it ready before queueing.".to_string(), - ), - key_hint: Some("Planning/Intake helpers can set state = ready".to_string()), - blocked_reason: None, - }, - TicketWorkflowState::Closed => DerivedTicketState { - kind: PanelRowKind::Review, - priority: ActionPriority::Background, - action: Some(NextUserAction::Wait), - disabled_reason: Some("Ticket is closed.".to_string()), - key_hint: None, - blocked_reason: None, - }, +fn next_user_action_from_workspace(action: TicketWorkspaceNextAction) -> NextUserAction { + match action { + TicketWorkspaceNextAction::Clarify => NextUserAction::Clarify, + TicketWorkspaceNextAction::QueueForOrchestrator => NextUserAction::Queue, + TicketWorkspaceNextAction::Close => NextUserAction::Close, + TicketWorkspaceNextAction::WaitForOrchestrator => NextUserAction::Wait, } } @@ -1652,6 +1435,31 @@ pub(crate) fn local_claim_status_for_pod( TicketLocalClaimStatus::Stale } +fn ticket_state_display( + local: TicketWorkflowState, + overlay: Option<&TicketStateOverlay>, +) -> String { + match overlay { + Some(overlay) => format!( + "{}→{}", + 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 ticket_subtitle(entry: &TicketPanelEntry) -> Option { let mut parts = vec![format!( "{} · {}",