feat: add workspace panel composer targets
This commit is contained in:
parent
f23a4a29c6
commit
a7c155e5c3
|
|
@ -2,8 +2,11 @@ use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use client::ticket_role::{TicketRole, TicketRoleLaunchContext};
|
use client::ticket_role::{
|
||||||
use client::{PodRuntimeCommand, SpawnConfig, launch_ticket_role_pod, spawn_pod};
|
TicketRole, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchResult,
|
||||||
|
launch_ticket_role_pod,
|
||||||
|
};
|
||||||
|
use client::{PodRuntimeCommand, SpawnConfig, spawn_pod};
|
||||||
use crossterm::event::{Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, poll, read};
|
use crossterm::event::{Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, poll, read};
|
||||||
use pod_store::FsPodStore;
|
use pod_store::FsPodStore;
|
||||||
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
||||||
|
|
@ -25,9 +28,9 @@ use crate::pod_list::{
|
||||||
read_stored_pod_infos,
|
read_stored_pod_infos,
|
||||||
};
|
};
|
||||||
use crate::workspace_panel::{
|
use crate::workspace_panel::{
|
||||||
ActionPriority, NextUserAction, OrchestratorLifecyclePlan, OrchestratorPanelState,
|
ActionPriority, ComposerTarget, NextUserAction, OrchestratorLifecyclePlan,
|
||||||
OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow, PanelRowKey,
|
OrchestratorPanelState, OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow,
|
||||||
TicketConfigAvailability, WorkspacePanelViewModel, bounded_panel_diagnostic,
|
PanelRowKey, TicketConfigAvailability, WorkspacePanelViewModel, bounded_panel_diagnostic,
|
||||||
build_workspace_panel, decide_orchestrator_lifecycle, orchestrator_pod_presence,
|
build_workspace_panel, decide_orchestrator_lifecycle, orchestrator_pod_presence,
|
||||||
ticket_config_availability, workspace_orchestrator_pod_name,
|
ticket_config_availability, workspace_orchestrator_pod_name,
|
||||||
};
|
};
|
||||||
|
|
@ -141,6 +144,16 @@ pub(crate) async fn run(
|
||||||
app.reload_or_notice().await;
|
app.reload_or_notice().await;
|
||||||
next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL;
|
next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL;
|
||||||
}
|
}
|
||||||
|
MultiPodAction::LaunchIntake(request) => {
|
||||||
|
pending_reload.abort();
|
||||||
|
terminal.draw(|f| draw(f, app))?;
|
||||||
|
let result =
|
||||||
|
launch_ticket_role_pod(request.context, request.runtime_command, |_| {})
|
||||||
|
.await;
|
||||||
|
app.finish_intake_launch(result);
|
||||||
|
app.reload_or_notice().await;
|
||||||
|
next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
TermEvent::Paste(text) => app.input.insert_paste(text),
|
TermEvent::Paste(text) => app.input.insert_paste(text),
|
||||||
TermEvent::Resize(_, _) => {}
|
TermEvent::Resize(_, _) => {}
|
||||||
|
|
@ -242,13 +255,21 @@ pub(crate) struct DirectSendRequest {
|
||||||
segments: Vec<Segment>,
|
segments: Vec<Segment>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct IntakeLaunchRequest {
|
||||||
|
context: TicketRoleLaunchContext,
|
||||||
|
runtime_command: PodRuntimeCommand,
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct MultiPodApp {
|
pub(crate) struct MultiPodApp {
|
||||||
pub(crate) list: PodList,
|
pub(crate) list: PodList,
|
||||||
pub(crate) panel: WorkspacePanelViewModel,
|
pub(crate) panel: WorkspacePanelViewModel,
|
||||||
pub(crate) input: InputBuffer,
|
pub(crate) input: InputBuffer,
|
||||||
selected_row: Option<PanelRowKey>,
|
selected_row: Option<PanelRowKey>,
|
||||||
|
composer_target: ComposerTarget,
|
||||||
notice: Option<String>,
|
notice: Option<String>,
|
||||||
sending: bool,
|
sending: bool,
|
||||||
|
runtime_command: PodRuntimeCommand,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MultiPodApp {
|
impl MultiPodApp {
|
||||||
|
|
@ -258,7 +279,9 @@ impl MultiPodApp {
|
||||||
) -> Result<Self, MultiPodError> {
|
) -> Result<Self, MultiPodError> {
|
||||||
let snapshot = load_multi_pod_snapshot(
|
let snapshot = load_multi_pod_snapshot(
|
||||||
selected_name,
|
selected_name,
|
||||||
OrchestratorLifecycleMode::Ensure { runtime_command },
|
OrchestratorLifecycleMode::Ensure {
|
||||||
|
runtime_command: runtime_command.clone(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let mut app = Self {
|
let mut app = Self {
|
||||||
|
|
@ -266,10 +289,13 @@ impl MultiPodApp {
|
||||||
panel: snapshot.panel,
|
panel: snapshot.panel,
|
||||||
input: InputBuffer::new(),
|
input: InputBuffer::new(),
|
||||||
selected_row: None,
|
selected_row: None,
|
||||||
|
composer_target: ComposerTarget::Companion,
|
||||||
notice: None,
|
notice: None,
|
||||||
sending: false,
|
sending: false,
|
||||||
|
runtime_command,
|
||||||
};
|
};
|
||||||
app.ensure_selection_visible();
|
app.ensure_selection_visible();
|
||||||
|
app.ensure_composer_target_available();
|
||||||
Ok(app)
|
Ok(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -321,6 +347,7 @@ impl MultiPodApp {
|
||||||
self.panel = snapshot.panel;
|
self.panel = snapshot.panel;
|
||||||
self.selected_row = previous_row.filter(|key| self.panel.row(key).is_some());
|
self.selected_row = previous_row.filter(|key| self.panel.row(key).is_some());
|
||||||
self.ensure_selection_visible();
|
self.ensure_selection_visible();
|
||||||
|
self.ensure_composer_target_available();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn selected_panel_row(&self) -> Option<&PanelRow> {
|
fn selected_panel_row(&self) -> Option<&PanelRow> {
|
||||||
|
|
@ -451,6 +478,34 @@ impl MultiPodApp {
|
||||||
self.selected_row = Some(key);
|
self.selected_row = Some(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ensure_composer_target_available(&mut self) {
|
||||||
|
if !self.panel.composer.is_available(self.composer_target) {
|
||||||
|
self.composer_target = ComposerTarget::Companion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn cycle_composer_target(&mut self) {
|
||||||
|
let targets = &self.panel.composer.available_targets;
|
||||||
|
if targets.len() <= 1 {
|
||||||
|
self.composer_target = ComposerTarget::Companion;
|
||||||
|
self.notice = Some(
|
||||||
|
"Ticket Intake target is unavailable without usable Ticket config.".to_string(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let current = targets
|
||||||
|
.iter()
|
||||||
|
.position(|target| *target == self.composer_target)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let next = targets[(current + 1) % targets.len()];
|
||||||
|
self.composer_target = next;
|
||||||
|
self.notice = Some(format!("Composer target: {}", next.label()));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn composer_target(&self) -> ComposerTarget {
|
||||||
|
self.composer_target
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn prepare_open(&mut self) -> Option<OpenPodRequest> {
|
pub(crate) fn prepare_open(&mut self) -> Option<OpenPodRequest> {
|
||||||
let (pod_name, socket_override) = {
|
let (pod_name, socket_override) = {
|
||||||
let entry = match self.selected_pod_entry() {
|
let entry = match self.selected_pod_entry() {
|
||||||
|
|
@ -544,6 +599,56 @@ impl MultiPodApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prepare_intake_launch(&mut self) -> Option<IntakeLaunchRequest> {
|
||||||
|
if !self
|
||||||
|
.panel
|
||||||
|
.composer
|
||||||
|
.is_available(ComposerTarget::TicketIntake)
|
||||||
|
{
|
||||||
|
self.composer_target = ComposerTarget::Companion;
|
||||||
|
self.notice = Some(
|
||||||
|
"Ticket Intake target is unavailable without usable Ticket config.".to_string(),
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let body = Segment::flatten_to_text(&self.input.submit_segments());
|
||||||
|
if body.trim().is_empty() {
|
||||||
|
self.notice = Some("Ticket Intake input is empty; type a request first.".to_string());
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut context =
|
||||||
|
TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake);
|
||||||
|
context.user_instruction = Some(body);
|
||||||
|
self.sending = true;
|
||||||
|
self.notice = Some("Launching Ticket Intake…".to_string());
|
||||||
|
Some(IntakeLaunchRequest {
|
||||||
|
context,
|
||||||
|
runtime_command: self.runtime_command.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn finish_intake_launch(
|
||||||
|
&mut self,
|
||||||
|
result: Result<TicketRoleLaunchResult, TicketRoleLaunchError>,
|
||||||
|
) {
|
||||||
|
self.sending = false;
|
||||||
|
match result {
|
||||||
|
Ok(result) => {
|
||||||
|
let pod_name = result.plan.pod_name;
|
||||||
|
self.input.clear();
|
||||||
|
self.notice = Some(bounded_panel_diagnostic(format!(
|
||||||
|
"Launched Ticket Intake Pod {pod_name}."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.notice = Some(format!(
|
||||||
|
"Intake launch failed; composer kept: {}",
|
||||||
|
bounded_panel_diagnostic(error.to_string())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_key(&mut self, key: KeyEvent) -> MultiPodAction {
|
fn handle_key(&mut self, key: KeyEvent) -> MultiPodAction {
|
||||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
let alt = key.modifiers.contains(KeyModifiers::ALT);
|
let alt = key.modifiers.contains(KeyModifiers::ALT);
|
||||||
|
|
@ -567,12 +672,20 @@ impl MultiPodApp {
|
||||||
self.select_next();
|
self.select_next();
|
||||||
MultiPodAction::None
|
MultiPodAction::None
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('t') if ctrl => {
|
||||||
|
self.cycle_composer_target();
|
||||||
|
MultiPodAction::None
|
||||||
|
}
|
||||||
KeyCode::Char('o') if !ctrl && !alt => MultiPodAction::Open,
|
KeyCode::Char('o') if !ctrl && !alt => MultiPodAction::Open,
|
||||||
KeyCode::Char('r') if !ctrl && !alt => MultiPodAction::Refresh,
|
KeyCode::Char('r') if !ctrl && !alt => MultiPodAction::Refresh,
|
||||||
KeyCode::Enter if alt => {
|
KeyCode::Enter if alt => {
|
||||||
self.input.insert_newline();
|
self.input.insert_newline();
|
||||||
MultiPodAction::None
|
MultiPodAction::None
|
||||||
}
|
}
|
||||||
|
KeyCode::Enter if self.composer_target == ComposerTarget::TicketIntake => self
|
||||||
|
.prepare_intake_launch()
|
||||||
|
.map(MultiPodAction::LaunchIntake)
|
||||||
|
.unwrap_or(MultiPodAction::None),
|
||||||
KeyCode::Enter if self.composer_is_blank() => MultiPodAction::Open,
|
KeyCode::Enter if self.composer_is_blank() => MultiPodAction::Open,
|
||||||
KeyCode::Enter => self
|
KeyCode::Enter => self
|
||||||
.prepare_send()
|
.prepare_send()
|
||||||
|
|
@ -617,6 +730,7 @@ enum MultiPodAction {
|
||||||
Open,
|
Open,
|
||||||
Refresh,
|
Refresh,
|
||||||
Send(DirectSendRequest),
|
Send(DirectSendRequest),
|
||||||
|
LaunchIntake(IntakeLaunchRequest),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -1222,7 +1336,13 @@ fn input_area_height(render: &crate::input::InputRender, terminal_height: u16) -
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_title(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) {
|
fn draw_title(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) {
|
||||||
let guidance = if app.panel.header.ticket_configured {
|
let guidance = if app
|
||||||
|
.panel
|
||||||
|
.composer
|
||||||
|
.is_available(ComposerTarget::TicketIntake)
|
||||||
|
{
|
||||||
|
" Ticket actions are display-only · Enter uses composer target · Ctrl+T target · o open/attach · r refresh"
|
||||||
|
} else if app.panel.header.ticket_configured {
|
||||||
" Ticket actions are display-only · Enter sends to selected idle Pod · o open/attach · r refresh"
|
" Ticket actions are display-only · Enter sends to selected idle Pod · o open/attach · r refresh"
|
||||||
} else {
|
} else {
|
||||||
" Pod-centric view · Enter sends to selected idle Pod · o open/attach · r refresh"
|
" Pod-centric view · Enter sends to selected idle Pod · o open/attach · r refresh"
|
||||||
|
|
@ -1485,7 +1605,7 @@ fn draw_separator(frame: &mut Frame<'_>, area: Rect) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_target_status(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) {
|
fn draw_target_status(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) {
|
||||||
let line = if let Some(row) = app
|
let mut line = if let Some(row) = app
|
||||||
.selected_panel_row()
|
.selected_panel_row()
|
||||||
.filter(|row| row.is_ticket_action())
|
.filter(|row| row.is_ticket_action())
|
||||||
{
|
{
|
||||||
|
|
@ -1544,7 +1664,21 @@ fn draw_target_status(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) {
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
frame.render_widget(Paragraph::new(line), area);
|
let mut prefix = vec![
|
||||||
|
Span::styled("composer ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::styled(
|
||||||
|
app.composer_target().label(),
|
||||||
|
Style::default()
|
||||||
|
.fg(match app.composer_target() {
|
||||||
|
ComposerTarget::Companion => Color::Green,
|
||||||
|
ComposerTarget::TicketIntake => Color::Magenta,
|
||||||
|
})
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(" · ", Style::default().fg(Color::DarkGray)),
|
||||||
|
];
|
||||||
|
prefix.append(&mut line.spans);
|
||||||
|
frame.render_widget(Paragraph::new(Line::from(prefix)), area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRender, area: Rect) {
|
fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRender, area: Rect) {
|
||||||
|
|
@ -1566,19 +1700,40 @@ fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRender, area: R
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_actionbar(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) {
|
fn draw_actionbar(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) {
|
||||||
let left = if app.sending {
|
let left = if app.sending && app.composer_target() == ComposerTarget::TicketIntake {
|
||||||
|
"launching Ticket Intake…".to_string()
|
||||||
|
} else if app.sending {
|
||||||
"sending…".to_string()
|
"sending…".to_string()
|
||||||
} else if let Some(notice) = app.notice.as_deref() {
|
} else if let Some(notice) = app.notice.as_deref() {
|
||||||
notice.to_string()
|
notice.to_string()
|
||||||
} else if let Some(reason) = app.selected_send_disabled_reason() {
|
} else if let Some(reason) = app.selected_send_disabled_reason() {
|
||||||
reason
|
reason
|
||||||
} else {
|
} else {
|
||||||
"idle live target: Enter sends directly without opening conversation".to_string()
|
match app.composer_target() {
|
||||||
|
ComposerTarget::Companion => {
|
||||||
|
"idle live target: Enter sends directly without opening conversation".to_string()
|
||||||
|
}
|
||||||
|
ComposerTarget::TicketIntake => {
|
||||||
|
"Ticket Intake target: Enter launches Intake with composer text".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let right = "↑/↓ select Enter send o open r refresh Esc quit";
|
let right = if app
|
||||||
|
.panel
|
||||||
|
.composer
|
||||||
|
.is_available(ComposerTarget::TicketIntake)
|
||||||
|
{
|
||||||
|
"↑/↓ select Enter use target Ctrl+T target o open r refresh Esc quit"
|
||||||
|
} else {
|
||||||
|
"↑/↓ select Enter send o open r refresh Esc quit"
|
||||||
|
};
|
||||||
|
let left_width = area
|
||||||
|
.width
|
||||||
|
.saturating_sub(right.width() as u16)
|
||||||
|
.saturating_sub(2) as usize;
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(Line::from(Span::styled(
|
Paragraph::new(Line::from(Span::styled(
|
||||||
truncate_with_ellipsis(&left, area.width.saturating_sub(42) as usize),
|
truncate_with_ellipsis(&left, left_width),
|
||||||
Style::default().fg(Color::DarkGray),
|
Style::default().fg(Color::DarkGray),
|
||||||
))),
|
))),
|
||||||
area,
|
area,
|
||||||
|
|
@ -2193,6 +2348,126 @@ mod tests {
|
||||||
assert!(app.notice.as_deref().unwrap().contains("Sending to idle"));
|
assert!(app.notice.as_deref().unwrap().contains("Sending to idle"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_composer_target_switch_preserves_typed_text() {
|
||||||
|
let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]);
|
||||||
|
app.input.insert_str("draft intake request");
|
||||||
|
|
||||||
|
assert!(matches!(app.composer_target(), ComposerTarget::Companion));
|
||||||
|
assert!(matches!(
|
||||||
|
app.handle_key(modified_key(KeyCode::Char('t'), KeyModifiers::CONTROL)),
|
||||||
|
MultiPodAction::None
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
app.composer_target(),
|
||||||
|
ComposerTarget::TicketIntake
|
||||||
|
));
|
||||||
|
assert_eq!(input_text(&app), "draft intake request");
|
||||||
|
assert!(app.notice.as_deref().unwrap().contains("Ticket Intake"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_no_ticket_workspace_exposes_only_companion_target() {
|
||||||
|
let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]);
|
||||||
|
app.input.insert_str("draft message");
|
||||||
|
|
||||||
|
app.cycle_composer_target();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.panel.composer.available_targets,
|
||||||
|
vec![ComposerTarget::Companion]
|
||||||
|
);
|
||||||
|
assert!(matches!(app.composer_target(), ComposerTarget::Companion));
|
||||||
|
assert_eq!(input_text(&app), "draft message");
|
||||||
|
assert!(app.notice.as_deref().unwrap().contains("unavailable"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_ticket_intake_rejects_empty_input() {
|
||||||
|
let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]);
|
||||||
|
app.cycle_composer_target();
|
||||||
|
app.input.insert_str(" \n\t");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
app.handle_key(key(KeyCode::Enter)),
|
||||||
|
MultiPodAction::None
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
app.composer_target(),
|
||||||
|
ComposerTarget::TicketIntake
|
||||||
|
));
|
||||||
|
assert!(!app.sending);
|
||||||
|
assert_eq!(input_text(&app), " \n\t");
|
||||||
|
assert!(app.notice.as_deref().unwrap().contains("input is empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_ticket_intake_enter_builds_launch_request_not_direct_send() {
|
||||||
|
let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]);
|
||||||
|
app.cycle_composer_target();
|
||||||
|
app.input.insert_str("please intake this work");
|
||||||
|
|
||||||
|
let request = match app.handle_key(key(KeyCode::Enter)) {
|
||||||
|
MultiPodAction::LaunchIntake(request) => request,
|
||||||
|
MultiPodAction::Send(_) => panic!("Ticket Intake target must not direct-send"),
|
||||||
|
_ => panic!("Ticket Intake target should launch Intake"),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(request.context.role, TicketRole::Intake);
|
||||||
|
assert_eq!(
|
||||||
|
request.context.user_instruction.as_deref(),
|
||||||
|
Some("please intake this work")
|
||||||
|
);
|
||||||
|
assert_eq!(request.runtime_command.program(), Path::new("/tmp/yoi"));
|
||||||
|
assert!(app.sending);
|
||||||
|
assert!(app.notice.as_deref().unwrap().contains("Launching"));
|
||||||
|
assert_eq!(input_text(&app), "please intake this work");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_ticket_intake_finish_success_clears_composer_and_reports_pod() {
|
||||||
|
let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]);
|
||||||
|
app.cycle_composer_target();
|
||||||
|
app.input.insert_str("please intake this work");
|
||||||
|
app.sending = true;
|
||||||
|
|
||||||
|
app.finish_intake_launch(Ok(TicketRoleLaunchResult {
|
||||||
|
plan: client::ticket_role::TicketRoleLaunchPlan {
|
||||||
|
workspace_root: PathBuf::from("/tmp/workspace"),
|
||||||
|
role: TicketRole::Intake,
|
||||||
|
pod_name: "intake-pod".to_string(),
|
||||||
|
profile: "builtin:default".to_string(),
|
||||||
|
workflow: "ticket-intake-workflow".to_string(),
|
||||||
|
launch_prompt_ref: None,
|
||||||
|
run_segments: vec![],
|
||||||
|
},
|
||||||
|
ready: client::SpawnReady {
|
||||||
|
pod_name: "intake-pod".to_string(),
|
||||||
|
socket_path: PathBuf::from("/tmp/intake.sock"),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert!(!app.sending);
|
||||||
|
assert_eq!(input_text(&app), "");
|
||||||
|
assert!(app.notice.as_deref().unwrap().contains("intake-pod"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_ticket_intake_finish_failure_keeps_composer() {
|
||||||
|
let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]);
|
||||||
|
app.cycle_composer_target();
|
||||||
|
app.input.insert_str("please keep this");
|
||||||
|
app.sending = true;
|
||||||
|
|
||||||
|
app.finish_intake_launch(Err(TicketRoleLaunchError::EmptyPodName));
|
||||||
|
|
||||||
|
assert!(!app.sending);
|
||||||
|
assert_eq!(input_text(&app), "please keep this");
|
||||||
|
assert!(app.notice.as_deref().unwrap().contains("composer kept"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multi_empty_enter_on_non_openable_row_matches_o_diagnostic() {
|
fn multi_empty_enter_on_non_openable_row_matches_o_diagnostic() {
|
||||||
let mut enter_app = test_app(vec![unreachable_live_info("unreachable")]);
|
let mut enter_app = test_app(vec![unreachable_live_info("unreachable")]);
|
||||||
|
|
@ -2223,6 +2498,15 @@ mod tests {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ticket_enabled_app(live: Vec<LivePodInfo>) -> MultiPodApp {
|
||||||
|
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
||||||
|
panel.composer = crate::workspace_panel::WorkspacePanelComposer::ticket_enabled();
|
||||||
|
app_with_panel(
|
||||||
|
PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], live, None, 10),
|
||||||
|
panel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn app_with_list(list: PodList) -> MultiPodApp {
|
fn app_with_list(list: PodList) -> MultiPodApp {
|
||||||
app_with_panel(list, WorkspacePanelViewModel::empty(Path::new("test")))
|
app_with_panel(list, WorkspacePanelViewModel::empty(Path::new("test")))
|
||||||
}
|
}
|
||||||
|
|
@ -2233,10 +2517,13 @@ mod tests {
|
||||||
panel,
|
panel,
|
||||||
input: InputBuffer::new(),
|
input: InputBuffer::new(),
|
||||||
selected_row: None,
|
selected_row: None,
|
||||||
|
composer_target: ComposerTarget::Companion,
|
||||||
notice: None,
|
notice: None,
|
||||||
sending: false,
|
sending: false,
|
||||||
|
runtime_command: PodRuntimeCommand::for_executable("/tmp/yoi"),
|
||||||
};
|
};
|
||||||
app.ensure_selection_visible();
|
app.ensure_selection_visible();
|
||||||
|
app.ensure_composer_target_available();
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2320,6 +2607,10 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn key(code: KeyCode) -> KeyEvent {
|
fn key(code: KeyCode) -> KeyEvent {
|
||||||
KeyEvent::new(code, KeyModifiers::NONE)
|
modified_key(code, KeyModifiers::NONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn modified_key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
|
||||||
|
KeyEvent::new(code, modifiers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ use crate::pod_list::{PodList, PodListEntry, StoredMetadataState};
|
||||||
pub(crate) struct WorkspacePanelViewModel {
|
pub(crate) struct WorkspacePanelViewModel {
|
||||||
pub(crate) header: WorkspacePanelHeader,
|
pub(crate) header: WorkspacePanelHeader,
|
||||||
pub(crate) rows: Vec<PanelRow>,
|
pub(crate) rows: Vec<PanelRow>,
|
||||||
|
pub(crate) composer: WorkspacePanelComposer,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkspacePanelViewModel {
|
impl WorkspacePanelViewModel {
|
||||||
|
|
@ -31,6 +32,7 @@ impl WorkspacePanelViewModel {
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
},
|
},
|
||||||
rows: Vec::new(),
|
rows: Vec::new(),
|
||||||
|
composer: WorkspacePanelComposer::companion_only(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,6 +50,44 @@ pub(crate) struct WorkspacePanelHeader {
|
||||||
pub(crate) diagnostics: Vec<String>,
|
pub(crate) diagnostics: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum ComposerTarget {
|
||||||
|
Companion,
|
||||||
|
TicketIntake,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ComposerTarget {
|
||||||
|
pub(crate) fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Companion => "Companion",
|
||||||
|
Self::TicketIntake => "Ticket Intake",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct WorkspacePanelComposer {
|
||||||
|
pub(crate) available_targets: Vec<ComposerTarget>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspacePanelComposer {
|
||||||
|
pub(crate) fn companion_only() -> Self {
|
||||||
|
Self {
|
||||||
|
available_targets: vec![ComposerTarget::Companion],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn ticket_enabled() -> Self {
|
||||||
|
Self {
|
||||||
|
available_targets: vec![ComposerTarget::Companion, ComposerTarget::TicketIntake],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_available(&self, target: ComposerTarget) -> bool {
|
||||||
|
self.available_targets.contains(&target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub(crate) struct OrchestratorPanelState {
|
pub(crate) struct OrchestratorPanelState {
|
||||||
pub(crate) pod_name: String,
|
pub(crate) pod_name: String,
|
||||||
|
|
@ -386,6 +426,7 @@ pub(crate) fn build_workspace_panel(
|
||||||
TicketConfigAvailability::Absent => {}
|
TicketConfigAvailability::Absent => {}
|
||||||
TicketConfigAvailability::Usable => {
|
TicketConfigAvailability::Usable => {
|
||||||
model.header.ticket_configured = true;
|
model.header.ticket_configured = true;
|
||||||
|
model.composer = WorkspacePanelComposer::ticket_enabled();
|
||||||
match TicketConfig::load_workspace(workspace_root) {
|
match TicketConfig::load_workspace(workspace_root) {
|
||||||
Ok(config) => {
|
Ok(config) => {
|
||||||
model.header.ticket_root = config.backend_root().to_path_buf();
|
model.header.ticket_root = config.backend_root().to_path_buf();
|
||||||
|
|
@ -938,6 +979,10 @@ mod tests {
|
||||||
let model = build_workspace_panel(temp.path(), &live_pods(&["idle"]));
|
let model = build_workspace_panel(temp.path(), &live_pods(&["idle"]));
|
||||||
|
|
||||||
assert!(model.header.diagnostics.is_empty());
|
assert!(model.header.diagnostics.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
model.composer.available_targets,
|
||||||
|
vec![ComposerTarget::Companion]
|
||||||
|
);
|
||||||
assert_eq!(model.rows.len(), 1);
|
assert_eq!(model.rows.len(), 1);
|
||||||
assert_eq!(model.rows[0].key, PanelRowKey::Pod("idle".to_string()));
|
assert_eq!(model.rows[0].key, PanelRowKey::Pod("idle".to_string()));
|
||||||
assert!(model.rows[0].ticket.is_none());
|
assert!(model.rows[0].ticket.is_none());
|
||||||
|
|
@ -957,6 +1002,10 @@ mod tests {
|
||||||
});
|
});
|
||||||
|
|
||||||
let model = build_workspace_panel(temp.path(), &empty_pods());
|
let model = build_workspace_panel(temp.path(), &empty_pods());
|
||||||
|
assert_eq!(
|
||||||
|
model.composer.available_targets,
|
||||||
|
vec![ComposerTarget::Companion, ComposerTarget::TicketIntake]
|
||||||
|
);
|
||||||
let rows = model
|
let rows = model
|
||||||
.rows
|
.rows
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -1164,6 +1213,10 @@ mod tests {
|
||||||
let model = build_workspace_panel(temp.path(), &empty_pods());
|
let model = build_workspace_panel(temp.path(), &empty_pods());
|
||||||
|
|
||||||
assert!(model.header.ticket_configured);
|
assert!(model.header.ticket_configured);
|
||||||
|
assert_eq!(
|
||||||
|
model.composer.available_targets,
|
||||||
|
vec![ComposerTarget::Companion]
|
||||||
|
);
|
||||||
assert!(model.header.diagnostics.iter().any(|diagnostic| {
|
assert!(model.header.diagnostics.iter().any(|diagnostic| {
|
||||||
diagnostic.contains("Ticket config is unusable")
|
diagnostic.contains("Ticket config is unusable")
|
||||||
&& diagnostic.contains("not a regular file")
|
&& diagnostic.contains("not a regular file")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user