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:
Claude committed 2026-10-11 14:48:03 +00:00
1 parent e2ee2d2599
commit 8fa735e53a
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() => {
-13
View File
@@ -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() {
-4
View File
@@ -856,10 +856,6 @@ pub enum ShellJobAction {
Show {
id: String,
},
Poll {
id: String,
wait: bool,
},
SendStdin {
id: String,
input: String,
+13
View File
@@ -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);
-11
View File
@@ -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) {
+25 -61
View File
@@ -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}"))
);
}
}