mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 17:09:13 -10:00
fix(tui): /jobs wait stops freezing the UI; dock rows open the live inspector
Refs #6931 (acceptance 4 and 5, "inspectable and stoppable"). Found by
the 0.10.2 UX audit's live-work lens; each finding was checked against
the code before this change.
- `/jobs wait <id>` called `wait_job_for_session(…, 5_000)` on the UI
event loop. It held the ShellManager std Mutex and slept in 100 ms
steps, so the screen and the model's shell tools stalled for up to
5 s, against the #6149 blocking-call rule. `poll` and `wait` now
parse to the same action as `show`: the live-following pager from
f7264f7d72. The `ShellJobAction::Poll` variant, its handler arm and
`wait_job_for_session`, which had no other caller, are deleted.
- Work-dock shell rows, live and finished, ran `shell_inspector_body`
in every projection. That is several passes per frame, on every dock
tab: a `try_lock`, then `inspect_job_for_session`, which polls the
child and copies the full retained stdout and stderr (up to 16 MiB
each). The pager it opened was a frozen snapshot that said "Open this
row to watch live output". Shell rows now run `/jobs show <id>`, and
`shell_inspector_body` is deleted, so projection never touches
ShellManager.
- The right-click menu keeps "Stop work" for a running shell
(`/jobs cancel <id>` behind the same in-menu confirm). The pager has
its confirmed `s` Stop.
- Durable task rows ran `/jobs show task_…` against the shell manager,
which printed "Command lookup failed: Job task_… not found". They now
run `/task show <id>`, and RLM pseudo-rows (`rlm-N`) have no action.
- Tests updated where this made them wrong: the live shell row's
primary action, and the shell-prefixed task rows' `/task show`.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BsXVvReYca2UxzimQtj7Mj
This commit is contained in:
6 files changed
+40
-98
No files matched your search
@@ -42,18 +42,11 @@ fn jobs(args: Option<&str>) -> CommandResult {
|
||||
|
||||
match action.as_str() {
|
||||
"list" => CommandResult::action(AppAction::ShellJob(ShellJobAction::List)),
|
||||
"show" | "inspect" => match id {
|
||||
"show" | "inspect" | "poll" | "wait" => match id {
|
||||
Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Show {
|
||||
id: id.to_string(),
|
||||
})),
|
||||
None => CommandResult::error("Usage: /jobs show <id>"),
|
||||
},
|
||||
"poll" | "wait" => match id {
|
||||
Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Poll {
|
||||
id: id.to_string(),
|
||||
wait: action == "wait",
|
||||
})),
|
||||
None => CommandResult::error("Usage: /jobs poll <id>"),
|
||||
None => CommandResult::error(format!("Usage: /jobs {action} <id>")),
|
||||
},
|
||||
"stdin" | "send" => match id {
|
||||
Some(id) if !rest.is_empty() => {
|
||||
|
||||
@@ -3487,19 +3487,6 @@ impl ShellManager {
|
||||
self.inspect_job(task_id)
|
||||
}
|
||||
|
||||
pub fn wait_job_for_session(
|
||||
&mut self,
|
||||
active_session_id: &str,
|
||||
task_id: &str,
|
||||
timeout_ms: u64,
|
||||
) -> Result<ShellJobDetail> {
|
||||
self.require_session_owner(task_id, active_session_id)?;
|
||||
if let Some(shell) = self.processes.get_mut(task_id) {
|
||||
wait_while_running(shell, true, timeout_ms);
|
||||
}
|
||||
self.inspect_job(task_id)
|
||||
}
|
||||
|
||||
/// List all live and known-stale background shell jobs for the TUI.
|
||||
pub fn list_jobs(&mut self) -> Vec<ShellJobSnapshot> {
|
||||
for shell in self.processes.values_mut() {
|
||||
|
||||
@@ -856,10 +856,6 @@ pub enum ShellJobAction {
|
||||
Show {
|
||||
id: String,
|
||||
},
|
||||
Poll {
|
||||
id: String,
|
||||
wait: bool,
|
||||
},
|
||||
SendStdin {
|
||||
id: String,
|
||||
input: String,
|
||||
|
||||
@@ -1608,6 +1608,19 @@ fn push_work_row_entries(app: &App, mouse: MouseEvent, entries: &mut Vec<Context
|
||||
entries.push(row(app.tr(MessageId::CtxMenuOpen).into_owned(), action).primary());
|
||||
}
|
||||
Some(SidebarRowAction::Command(command)) => {
|
||||
if let Some(id) = command.strip_prefix("/jobs show ")
|
||||
&& app.task_panel.iter().any(|entry| {
|
||||
entry.id == id && crate::tui::background_indicator::is_live_shell_entry(entry)
|
||||
})
|
||||
{
|
||||
stop = Some(
|
||||
row(
|
||||
app.tr(MessageId::CtxMenuStopWork).into_owned(),
|
||||
SidebarRowAction::Command(format!("/jobs cancel {id}")),
|
||||
)
|
||||
.confirm(confirm()),
|
||||
);
|
||||
}
|
||||
let label = app
|
||||
.tr(MessageId::CtxMenuRunCommand)
|
||||
.replace("{command}", &command);
|
||||
|
||||
@@ -1244,17 +1244,6 @@ pub(crate) fn handle_shell_job_action(app: &mut App, action: crate::tui::app::Sh
|
||||
Err(err) => add_shell_job_message(app, format!("Command lookup failed: {err}")),
|
||||
}
|
||||
}
|
||||
crate::tui::app::ShellJobAction::Poll { id, wait } => {
|
||||
let detail = if wait {
|
||||
manager.wait_job_for_session(&active_session_id, &id, 5_000)
|
||||
} else {
|
||||
manager.inspect_job_for_session(&active_session_id, &id)
|
||||
};
|
||||
match detail {
|
||||
Ok(detail) => open_shell_job_pager(app, &detail),
|
||||
Err(err) => add_shell_job_message(app, format!("Command poll failed: {err}")),
|
||||
}
|
||||
}
|
||||
crate::tui::app::ShellJobAction::SendStdin { id, input, close } => {
|
||||
match manager.write_stdin_for_session(&active_session_id, &id, &input, close) {
|
||||
Ok(()) => match manager.inspect_job_for_session(&active_session_id, &id) {
|
||||
|
||||
@@ -981,16 +981,25 @@ fn durable_task_rows(app: &App) -> Vec<WorkRow> {
|
||||
detail: format!("{status} · {}", entry.id),
|
||||
tone: bucket_tone(bucket),
|
||||
selectable: true,
|
||||
primary_action: Some(SidebarRowAction::Command(format!(
|
||||
"/jobs show {}",
|
||||
entry.id
|
||||
))),
|
||||
primary_action: task_show_action(entry),
|
||||
agent: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn task_show_action(entry: &TaskPanelEntry) -> Option<SidebarRowAction> {
|
||||
(!entry.id.starts_with("rlm-"))
|
||||
.then(|| SidebarRowAction::Command(format!("/task show {}", entry.id)))
|
||||
}
|
||||
|
||||
fn shell_show_action(entry: &TaskPanelEntry) -> Option<SidebarRowAction> {
|
||||
Some(SidebarRowAction::Command(format!(
|
||||
"/jobs show {}",
|
||||
entry.id
|
||||
)))
|
||||
}
|
||||
|
||||
/// Finished shells and durable tasks, muted, each saying how it ended:
|
||||
/// `exit 0 · 12s`, `failed · exit 2`, `killed`, `timed out`. The shell
|
||||
/// leads with its command; its id is only in the detail and inspector.
|
||||
@@ -1020,11 +1029,7 @@ fn finished_background_rows(app: &App) -> Vec<WorkRow> {
|
||||
detail: format!("{outcome} · {}", entry.id),
|
||||
tone,
|
||||
selectable: true,
|
||||
primary_action: Some(SidebarRowAction::InspectWork {
|
||||
title: format!("Shell {}", entry.id),
|
||||
body: shell_inspector_body(app, entry, &command, &outcome),
|
||||
stop_action: None,
|
||||
}),
|
||||
primary_action: shell_show_action(entry),
|
||||
agent: Some(AgentRowFacts {
|
||||
role_label: "shell".to_string(),
|
||||
status: outcome,
|
||||
@@ -1059,10 +1064,7 @@ fn finished_background_rows(app: &App) -> Vec<WorkRow> {
|
||||
),
|
||||
tone,
|
||||
selectable: true,
|
||||
primary_action: Some(SidebarRowAction::Command(format!(
|
||||
"/jobs show {}",
|
||||
entry.id
|
||||
))),
|
||||
primary_action: task_show_action(entry),
|
||||
agent: None,
|
||||
}
|
||||
}
|
||||
@@ -2635,14 +2637,7 @@ fn shell_work_rows(app: &App) -> Vec<WorkRow> {
|
||||
detail: format!("{status} · {}", entry.id),
|
||||
tone: WorkTone::Live,
|
||||
selectable: true,
|
||||
primary_action: Some(SidebarRowAction::InspectWork {
|
||||
title: format!("Shell {}", entry.id),
|
||||
body: shell_inspector_body(app, entry, command, status),
|
||||
stop_action: Some(Box::new(SidebarRowAction::Command(format!(
|
||||
"/jobs cancel {}",
|
||||
entry.id
|
||||
)))),
|
||||
}),
|
||||
primary_action: shell_show_action(entry),
|
||||
agent: Some(AgentRowFacts {
|
||||
role_label: "shell".to_string(),
|
||||
status: status.to_string(),
|
||||
@@ -2697,27 +2692,6 @@ fn shells_section_heading(shells: &[WorkRow]) -> WorkRow {
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_inspector_body(app: &App, entry: &TaskPanelEntry, command: &str, status: &str) -> String {
|
||||
let session = app.current_session_id.as_deref().unwrap_or("");
|
||||
if let Some(manager) = app.runtime_services.shell_manager.as_ref()
|
||||
&& let Ok(mut manager) = manager.try_lock()
|
||||
&& let Ok(detail) = manager.inspect_job_for_session(session, &entry.id)
|
||||
{
|
||||
let live = detail.snapshot.status == crate::tools::shell::ShellStatus::Running
|
||||
&& !detail.snapshot.stale;
|
||||
let body = crate::tui::shell_job_routing::format_shell_job_detail(&detail);
|
||||
return if live {
|
||||
format!("Cancel: /jobs cancel {}\n\n{body}", entry.id)
|
||||
} else {
|
||||
body
|
||||
};
|
||||
}
|
||||
format!(
|
||||
"Job: {}\nStatus: {status}\nCommand: {command}\n\nCancel: /jobs cancel {}\n\nOpen this row to watch live output.",
|
||||
entry.id, entry.id
|
||||
)
|
||||
}
|
||||
|
||||
fn graph_node_row(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> WorkRow {
|
||||
let (mark, tone) = match node.state {
|
||||
NodeState::Ready => (crate::tui::glyphs::READY, WorkTone::Muted),
|
||||
@@ -4375,24 +4349,14 @@ mod tests {
|
||||
// #6565: the command leads; the id stays in the detail.
|
||||
assert_eq!(shell.label, "cd /workspace/example-project");
|
||||
assert!(shell.detail.contains("shell_a1b2c3d4"), "{}", shell.detail);
|
||||
let Some(SidebarRowAction::InspectWork {
|
||||
title,
|
||||
body,
|
||||
stop_action,
|
||||
}) = shell.primary_action.as_ref()
|
||||
else {
|
||||
panic!(
|
||||
"shell row must open live output, got {:?}",
|
||||
shell.primary_action
|
||||
);
|
||||
};
|
||||
assert!(title.contains("shell_a1b2c3d4"), "{title}");
|
||||
assert!(body.contains("Job: shell_a1b2c3d4"), "{body}");
|
||||
assert!(body.contains("/jobs cancel shell_a1b2c3d4"), "{body}");
|
||||
assert!(matches!(
|
||||
stop_action.as_deref(),
|
||||
Some(SidebarRowAction::Command(command)) if command == "/jobs cancel shell_a1b2c3d4"
|
||||
));
|
||||
assert!(
|
||||
matches!(
|
||||
shell.primary_action.as_ref(),
|
||||
Some(SidebarRowAction::Command(command)) if command == "/jobs show shell_a1b2c3d4"
|
||||
),
|
||||
"shell row must open the live inspector, got {:?}",
|
||||
shell.primary_action
|
||||
);
|
||||
let facts = shell
|
||||
.agent
|
||||
.as_ref()
|
||||
@@ -4625,7 +4589,7 @@ mod tests {
|
||||
.find(|row| row.id.0 == format!("task:{id}"))
|
||||
.expect("task row");
|
||||
assert!(
|
||||
matches!(&row.primary_action, Some(SidebarRowAction::Command(command)) if command == &format!("/jobs show {id}"))
|
||||
matches!(&row.primary_action, Some(SidebarRowAction::Command(command)) if command == &format!("/task show {id}"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user