M4: jobs pane, subtask drill-in, reminders

Adds the jobs pane to the TUI with a new snapshot test, subtask drill-in navigation, and periodic reminders injected into the engine loop for long-running background jobs.
This commit is contained in:
2026-07-09 06:46:31 +02:00
parent ac516a7e23
commit 9ee5a1940d
15 changed files with 426 additions and 28 deletions
+26
View File
@@ -325,6 +325,8 @@ impl EngineHandle {
instructions: self.inner.config.instructions.clone(),
cost: Some(self.inner.catalog.cost(provider_id, model_id)),
inject_job_board,
reminder_turn_start: self.inner.reminder("turn_start"),
reminder_after_file_tool: self.inner.reminder("after_file_tool"),
};
// Reserve the run slot *before* spawning. If we inserted after spawning, a run that
@@ -386,6 +388,18 @@ impl EngineHandle {
Ok(self.inner.store.parts(message_id).await?)
}
pub async fn get_session(&self, session_id: SessionId) -> Result<Option<Session>, AppError> {
Ok(self.inner.store.session(session_id).await?)
}
/// Background jobs spawned by `session_id` (the parent), for the TUI jobs pane / drill-in.
pub async fn jobs(
&self,
session_id: SessionId,
) -> Result<Vec<harness_core::engine::JobRecord>, AppError> {
Ok(self.inner.store.jobs_for_parent(session_id).await?)
}
pub fn is_running(&self, session_id: &SessionId) -> bool {
let runs = self.inner.runs.lock().unwrap();
runs.contains_key(session_id)
@@ -477,6 +491,16 @@ impl EngineInner {
}
}
/// An optional orchestration reminder string by hook key (`turn_start`/`after_file_tool`).
fn reminder(&self, key: &str) -> Option<String> {
self.config
.orchestration
.reminders
.as_ref()?
.get(key)
.cloned()
}
/// The ruleset a session evaluates its own tool calls against: low-priority depth guards
/// (depth > 0), then global config permissions, then the agent's own rules (highest).
fn effective_static_rules(&self, agent: &AgentDef, depth: u8) -> Ruleset {
@@ -712,6 +736,8 @@ impl SubagentSpawner for EngineInner {
&child_session.model.model_id,
)),
inject_job_board: agent.mode.is_primary(),
reminder_turn_start: self.reminder("turn_start"),
reminder_after_file_tool: self.reminder("after_file_tool"),
};
// Register the launch on the parent board (also makes foreground results reusable).
+96 -9
View File
@@ -24,6 +24,10 @@ pub struct RunConfig {
pub cost: Option<crate::types::ModelCost>,
/// Whether to append the background job board to requests (primary/delegating agents).
pub inject_job_board: bool,
/// Optional user-provided reminder injected at the start of every turn (off by default).
pub reminder_turn_start: Option<String>,
/// Optional user-provided reminder injected on the turn after a file tool ran.
pub reminder_after_file_tool: Option<String>,
}
/// Adds a step's usage/cost onto the persisted session and republishes it. Cost accounting is
@@ -162,6 +166,8 @@ pub async fn run_session(
) -> RunOutcome {
let mut doomloop = DoomLoopGuard::new();
let mut steps = 0u32;
// Whether the previous step ran a file tool, gating the `after_file_tool` reminder.
let mut prev_used_file_tool = false;
loop {
match should_continue(&ctx.store, &ctx.session_id).await {
@@ -200,18 +206,33 @@ pub async fn run_session(
wire_messages.extend(convert_message(message, &parts));
}
// Append the (synthetic, non-persisted) job board to the last user message so the
// orchestrator sees running/reusable subtasks. docs/04-multiagent.md.
// Collect synthetic (non-persisted) blocks to append to the last user message this
// turn: the optional turn-start reminder, the job board, and — if the previous step
// ran a file tool — the optional after-file-tool reminder. docs/04-multiagent.md.
let mut synthetic: Vec<String> = Vec::new();
if let Some(reminder) = &run_config.reminder_turn_start {
synthetic.push(reminder.clone());
}
if run_config.inject_job_board {
if let Some(board) = &ctx.job_board {
if let Some(block) = board.format_for_prompt() {
if let Some(last_user) = wire_messages
.iter_mut()
.rev()
.find(|m| m.role == WireRole::User)
{
last_user.content.push(WireContent::Text { text: block });
}
synthetic.push(block);
}
}
}
if prev_used_file_tool {
if let Some(reminder) = &run_config.reminder_after_file_tool {
synthetic.push(reminder.clone());
}
}
if !synthetic.is_empty() {
if let Some(last_user) = wire_messages
.iter_mut()
.rev()
.find(|m| m.role == WireRole::User)
{
for text in synthetic {
last_user.content.push(WireContent::Text { text });
}
}
}
@@ -278,6 +299,7 @@ pub async fn run_session(
}
Ok(outcome) => {
accumulate_session_usage(&ctx, &outcome.usage, outcome.cost, now_fn()).await;
prev_used_file_tool = outcome.used_file_tool;
// A completed step means the orchestrator has now seen any terminal jobs
// that were on the board this turn; mark them reconciled.
if run_config.inject_job_board {
@@ -494,6 +516,8 @@ mod tests {
..Default::default()
}),
inject_job_board: false,
reminder_turn_start: None,
reminder_after_file_tool: None,
};
let outcome = run_session(Arc::new(provider), ctx, &run_config, || 2).await;
@@ -602,6 +626,8 @@ mod tests {
instructions: Vec::new(),
cost: None,
inject_job_board: false,
reminder_turn_start: None,
reminder_after_file_tool: None,
};
let outcome = run_session(Arc::new(provider), ctx, &run_config, || 2).await;
@@ -707,6 +733,8 @@ mod tests {
instructions: Vec::new(),
cost: None,
inject_job_board: true,
reminder_turn_start: None,
reminder_after_file_tool: None,
};
let provider = std::sync::Arc::new(CapturingProvider {
@@ -735,4 +763,63 @@ mod tests {
assert!(text.contains("exp-1"), "got: {text}");
assert!(text.contains("map the auth flow"), "got: {text}");
}
#[tokio::test]
async fn turn_start_reminder_is_injected_into_the_request() {
let store = Store::open_in_memory().unwrap();
let bus = EventBus::new();
let model = ModelRef::new("mock", "mock-model");
let session = Session::new_root("orchestrator", model.clone(), 1);
let session_id = session.id.clone();
store.upsert_session(session).await.unwrap();
let user_message = Message::new_user(session_id.clone(), 1);
store.upsert_message(user_message.clone()).await.unwrap();
store
.upsert_part(Part {
id: crate::types::PartId::new(),
message_id: user_message.id.clone(),
session_id: session_id.clone(),
idx: 0,
body: PartBody::Text {
text: "do the thing".into(),
synthetic: false,
},
})
.await
.unwrap();
let cwd = tempfile::tempdir().unwrap();
let ctx = make_ctx(store, bus, session_id, cwd.path().to_path_buf()).await;
let run_config = RunConfig {
agent_name: "orchestrator".into(),
agent_prompt: "You orchestrate.".into(),
model,
temperature: None,
max_steps: 1,
instructions: Vec::new(),
cost: None,
inject_job_board: false,
reminder_turn_start: Some("REMEMBER: stay on task.".into()),
reminder_after_file_tool: None,
};
let provider = std::sync::Arc::new(CapturingProvider {
last: StdMutex::new(None),
});
let outcome = run_session(provider.clone(), ctx, &run_config, || 2).await;
assert!(matches!(outcome, RunOutcome::Stopped));
let req = provider.last.lock().unwrap().clone().expect("a request");
let last_user = req
.messages
.iter()
.rev()
.find(|m| m.role == WireRole::User)
.expect("a user message");
let has_reminder = last_user.content.iter().any(
|c| matches!(c, WireContent::Text { text } if text.contains("REMEMBER: stay on task.")),
);
assert!(has_reminder, "turn-start reminder should be injected");
}
}
@@ -33,6 +33,9 @@ pub struct StepOutcome {
/// Dollar cost of this step's usage (0.0 when no pricing is available).
pub cost: f64,
pub aborted: bool,
/// Whether a file-mutating tool (`edit`/`write`) ran this step — drives the optional
/// `after_file_tool` reminder injection on the next turn.
pub used_file_tool: bool,
}
pub struct StepError {
@@ -98,9 +101,13 @@ impl FlushTracker {
}
}
/// Tool names that mutate files — after one runs, the optional `after_file_tool` reminder fires.
const FILE_TOOLS: &[&str] = &["edit", "write"];
struct Run<'a> {
ctx: &'a StepContext,
assistant: Option<Message>,
used_file_tool: bool,
next_idx: u32,
active_text: Option<PartId>,
active_reasoning: Option<PartId>,
@@ -117,6 +124,7 @@ impl<'a> Run<'a> {
Self {
ctx,
assistant: None,
used_file_tool: false,
next_idx: 0,
active_text: None,
active_reasoning: None,
@@ -386,6 +394,9 @@ impl<'a> Run<'a> {
input: serde_json::Value,
doomloop: &mut DoomLoopGuard,
) -> Result<(), ProviderError> {
if FILE_TOOLS.contains(&name.as_str()) {
self.used_file_tool = true;
}
let part_id = self.pending_tools.remove(&call_id).unwrap_or_default();
let running = Part {
id: part_id.clone(),
@@ -607,6 +618,7 @@ pub async fn process_step(
usage,
cost: step_cost,
aborted: true,
used_file_tool: run.used_file_tool,
});
}
};
@@ -679,5 +691,6 @@ pub async fn process_step(
usage,
cost: step_cost,
aborted: false,
used_file_tool: run.used_file_tool,
})
}
+56
View File
@@ -23,6 +23,10 @@ pub enum InputAction {
SessionPickerUp,
SessionPickerDown,
SessionPickerSelect,
OpenJobs,
JobsUp,
JobsDown,
JobsDrillIn,
}
/// Translate a crossterm event into an action and/or mutate `state` directly.
@@ -49,10 +53,21 @@ fn handle_key(key: KeyEvent, state: &mut AppState) -> InputAction {
match &state.modal {
ModalState::Permission { .. } => handle_permission_key(key, state),
ModalState::SessionPicker { .. } => handle_session_picker_key(key, state),
ModalState::JobsPane { .. } => handle_jobs_pane_key(key, state),
ModalState::None => handle_normal_key(key, state),
}
}
fn handle_jobs_pane_key(key: KeyEvent, _state: &mut AppState) -> InputAction {
match key.code {
KeyCode::Up => InputAction::JobsUp,
KeyCode::Down => InputAction::JobsDown,
KeyCode::Enter => InputAction::JobsDrillIn,
KeyCode::Esc => InputAction::CloseModal,
_ => InputAction::None,
}
}
fn handle_permission_key(key: KeyEvent, _state: &mut AppState) -> InputAction {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
@@ -119,6 +134,7 @@ fn handle_normal_key(key: KeyEvent, state: &mut AppState) -> InputAction {
}
}
KeyCode::Char('s') if ctrl => InputAction::LoadSessions,
KeyCode::Char('j') if ctrl => InputAction::OpenJobs,
KeyCode::Up => {
let (row, _) = state.input.cursor();
if row == 0 {
@@ -158,6 +174,7 @@ fn parse_slash_command(text: &str) -> Option<InputAction> {
"/model" if !rest.is_empty() => Some(InputAction::SetModel(rest)),
"/agent" if !rest.is_empty() => Some(InputAction::SetAgent(rest)),
"/sessions" => Some(InputAction::LoadSessions),
"/jobs" => Some(InputAction::OpenJobs),
"/quit" => Some(InputAction::Quit),
_ => None,
}
@@ -235,6 +252,45 @@ pub async fn apply_action(action: InputAction, state: &mut AppState, engine: &En
}
}
}
InputAction::OpenJobs => {
if let Some(session_id) = state.session_id.clone() {
match engine.jobs(session_id).await {
Ok(jobs) => {
state.set_jobs(jobs);
state.open_jobs_pane();
}
Err(e) => tracing::error!(error = %e, "failed to load jobs"),
}
}
}
InputAction::JobsUp => {
if let ModalState::JobsPane { selected } = &mut state.modal {
*selected = selected.saturating_sub(1);
}
state.dirty = true;
}
InputAction::JobsDown => {
let job_count = state.jobs.len();
if let ModalState::JobsPane { selected } = &mut state.modal {
if *selected + 1 < job_count {
*selected += 1;
}
}
state.dirty = true;
}
InputAction::JobsDrillIn => {
if let Some(child) = state.selected_job_child() {
match engine.get_session(child).await {
Ok(Some(session)) => {
if let Err(e) = modal::select_session(state, engine, session).await {
tracing::error!(error = %e, "failed to open subtask session");
}
}
Ok(None) => tracing::warn!("subtask session no longer exists"),
Err(e) => tracing::error!(error = %e, "failed to load subtask session"),
}
}
}
InputAction::None => {}
}
}
+5 -1
View File
@@ -26,13 +26,17 @@ pub async fn select_session(
session: Session,
) -> Result<(), harness_app::AppError> {
let session_id = session.id.clone();
let messages = engine.session_messages(session_id).await?;
let messages = engine.session_messages(session_id.clone()).await?;
let mut all_parts = Vec::new();
for message in &messages {
let parts = engine.message_parts(message.id.clone()).await?;
all_parts.extend(parts);
}
state.set_session(session, messages, all_parts);
// Surface any subtasks this session spawned (drill-in and session-switch both land here).
if let Ok(jobs) = engine.jobs(session_id).await {
state.set_jobs(jobs);
}
state.modal = ModalState::None;
Ok(())
}
+137 -1
View File
@@ -33,6 +33,9 @@ pub fn render(frame: &mut Frame, state: &mut AppState) {
ModalState::SessionPicker { sessions, selected } => {
render_session_picker(frame, sessions, *selected, area);
}
ModalState::JobsPane { selected } => {
render_jobs_pane(frame, &state.jobs, *selected, area);
}
ModalState::None => {}
}
}
@@ -169,7 +172,7 @@ fn render_status(frame: &mut Frame, state: &AppState, area: Rect) {
let hints = if state.ctrl_c_pressed {
"Press Ctrl+C again to quit"
} else {
"Ctrl+S: sessions | Esc: abort | Ctrl+C: quit"
"Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C: quit"
};
let usage = format!(
"{} · ${:.4}",
@@ -242,6 +245,86 @@ fn render_session_picker(
frame.render_widget(paragraph, popup);
}
fn render_jobs_pane(
frame: &mut Frame,
jobs: &[harness_core::engine::JobRecord],
selected: usize,
area: Rect,
) {
let popup = centered_rect(70, 70, area);
frame.render_widget(Clear, popup);
let mut text: Vec<Line<'static>> =
vec![Line::from("Background jobs").style(Style::new().add_modifier(Modifier::BOLD))];
if jobs.is_empty() {
text.push(Line::default());
text.push(Line::from("No subtasks spawned yet.").style(Style::new().fg(Color::DarkGray)));
} else {
for (i, job) in jobs.iter().enumerate() {
let marker = if i == selected { "> " } else { " " };
let (icon, color) = job_state_style(job.state);
let header = format!(
"{marker}{icon} {} · {} · {}",
job.alias,
job.agent,
job_state_label(job.state),
);
let style = if i == selected {
Style::new().bg(Color::Blue).fg(Color::White)
} else {
Style::new().fg(color)
};
text.push(Line::from(Span::styled(header, style)));
if let Some(objective) = &job.objective {
text.push(
Line::from(format!(" {objective}")).style(Style::new().fg(Color::Gray)),
);
}
if !job.context_files.is_empty() {
let files: Vec<&str> = job
.context_files
.iter()
.take(8)
.map(|f| f.path.as_str())
.collect();
text.push(
Line::from(format!(" read: {}", files.join(", ")))
.style(Style::new().fg(Color::DarkGray)),
);
}
}
text.push(Line::default());
text.push(
Line::from("Enter: open subtask · Esc: close").style(Style::new().fg(Color::DarkGray)),
);
}
let block = Block::default().borders(Borders::ALL).title(" jobs ");
let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: false });
frame.render_widget(paragraph, popup);
}
fn job_state_label(state: harness_core::engine::JobState) -> &'static str {
use harness_core::engine::JobState;
match state {
JobState::Running => "running",
JobState::Completed => "completed",
JobState::Error => "error",
JobState::Cancelled => "cancelled",
}
}
fn job_state_style(state: harness_core::engine::JobState) -> (&'static str, Color) {
use harness_core::engine::JobState;
match state {
JobState::Running => ("", Color::Yellow),
JobState::Completed => ("", Color::Green),
JobState::Error => ("", Color::Red),
JobState::Cancelled => ("", Color::DarkGray),
}
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
@@ -487,6 +570,59 @@ mod tests {
insta::assert_snapshot!(buffer_to_string(terminal.backend()));
}
#[test]
fn snapshot_jobs_pane() {
use harness_core::engine::{ContextFile, JobRecord, JobState};
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let mut state = AppState::new(
"anthropic/claude-sonnet-4-5".to_string(),
"orchestrator".to_string(),
);
state.session_id = Some(SessionId("ses_test_001".to_string()));
state.jobs = vec![
JobRecord {
task_id: "t1".to_string(),
alias: "exp-1".to_string(),
parent_session: SessionId("ses_test_001".to_string()),
child_session: SessionId("ses_child_001".to_string()),
agent: "explorer".to_string(),
description: "map auth".to_string(),
objective: Some("map the auth flow".to_string()),
state: JobState::Completed,
reconciled: true,
result_summary: Some("done".to_string()),
context_files: vec![ContextFile {
path: "src/auth.rs".to_string(),
lines: 42,
}],
launched_at: 1,
updated_at: 2,
last_used_at: 2,
},
JobRecord {
task_id: "t2".to_string(),
alias: "fix-1".to_string(),
parent_session: SessionId("ses_test_001".to_string()),
child_session: SessionId("ses_child_002".to_string()),
agent: "fixer".to_string(),
description: "patch bug".to_string(),
objective: Some("fix the null deref".to_string()),
state: JobState::Running,
reconciled: false,
result_summary: None,
context_files: Vec::new(),
launched_at: 3,
updated_at: 3,
last_used_at: 3,
},
];
state.modal = ModalState::JobsPane { selected: 1 };
terminal.draw(|frame| render(frame, &mut state)).unwrap();
insta::assert_snapshot!(buffer_to_string(terminal.backend()));
}
#[test]
fn render_empty_state_produces_frame() {
let backend = TestBackend::new(80, 24);
@@ -1,6 +1,6 @@
---
source: crates/harness-tui/src/render.rs
assertion_line: 300
assertion_line: 381
expression: buffer_to_string(terminal.backend())
---
new session · orchestrator · anthropic/claude-sonnet-4-5
@@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend())
┌ input ───────────────────────────────────────────────────────────────────────┐
│ │
└──────────────────────────────────────────────────────────────────────────────┘
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C:
@@ -0,0 +1,29 @@
---
source: crates/harness-tui/src/render.rs
assertion_line: 621
expression: buffer_to_string(terminal.backend())
---
new session · orchestrator · anthropic/claude-sonnet-4-5
┌ chat ────────────────────────────────────────────────────────────────────────┐
│ │
│ │
│ ┌ jobs ────────────────────────────────────────────────┐ │
│ │Background jobs │ │
│ │ ✓ exp-1 · explorer · completed │ │
│ │ map the auth flow │ │
│ │ read: src/auth.rs │ │
│ │> ⚙ fix-1 · fixer · running │ │
│ │ fix the null deref │ │
│ │ │ │
│ │Enter: open subtask · Esc: close │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
└───────────└──────────────────────────────────────────────────────┘───────────┘
┌ input ───────────────────────────────────────────────────────────────────────┐
│ │
└──────────────────────────────────────────────────────────────────────────────┘
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C:
@@ -1,6 +1,6 @@
---
source: crates/harness-tui/src/render.rs
assertion_line: 430
assertion_line: 511
expression: buffer_to_string(terminal.backend())
---
new session · orchestrator · anthropic/claude-sonnet-4-5
@@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend())
┌ input ───────────────────────────────────────────────────────────────────────┐
│ │
└──────────────────────────────────────────────────────────────────────────────┘
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C:
@@ -1,6 +1,6 @@
---
source: crates/harness-tui/src/render.rs
assertion_line: 487
assertion_line: 568
expression: buffer_to_string(terminal.backend())
---
new session · orchestrator · anthropic/claude-sonnet-4-5
@@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend())
┌ input ───────────────────────────────────────────────────────────────────────┐
│ │
└──────────────────────────────────────────────────────────────────────────────┘
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C:
@@ -1,6 +1,6 @@
---
source: crates/harness-tui/src/render.rs
assertion_line: 357
assertion_line: 438
expression: buffer_to_string(terminal.backend())
---
new session · orchestrator · anthropic/claude-sonnet-4-5
@@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend())
┌ input ───────────────────────────────────────────────────────────────────────┐
│ │
└──────────────────────────────────────────────────────────────────────────────┘
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C:
@@ -1,6 +1,6 @@
---
source: crates/harness-tui/src/render.rs
assertion_line: 407
assertion_line: 488
expression: buffer_to_string(terminal.backend())
---
new session · orchestrator · anthropic/claude-sonnet-4-5
@@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend())
┌ input ───────────────────────────────────────────────────────────────────────┐
│ │
└──────────────────────────────────────────────────────────────────────────────┘
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C:
@@ -1,6 +1,6 @@
---
source: crates/harness-tui/src/render.rs
assertion_line: 382
assertion_line: 463
expression: buffer_to_string(terminal.backend())
---
new session · orchestrator · anthropic/claude-sonnet-4-5
@@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend())
┌ input ───────────────────────────────────────────────────────────────────────┐
│ │
└──────────────────────────────────────────────────────────────────────────────┘
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C:
@@ -1,6 +1,6 @@
---
source: crates/harness-tui/src/render.rs
assertion_line: 331
assertion_line: 412
expression: buffer_to_string(terminal.backend())
---
new session · orchestrator · anthropic/claude-sonnet-4-5
@@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend())
┌ input ───────────────────────────────────────────────────────────────────────┐
│ │
└──────────────────────────────────────────────────────────────────────────────┘
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C:
+50 -3
View File
@@ -1,4 +1,5 @@
use harness_app::EngineHandle;
use harness_core::engine::JobRecord;
use harness_core::event::{AppEvent, PermissionRequest, RunOutcome};
use harness_core::permission::PermissionReply;
use harness_core::types::{Message, Part, PartBody, PartId, Role, Session, SessionId, ToolState};
@@ -86,6 +87,10 @@ pub enum ModalState {
sessions: Vec<Session>,
selected: usize,
},
/// Background job board for the current session, with a cursor for drill-in.
JobsPane {
selected: usize,
},
}
/// All mutable UI state lives here.
@@ -109,6 +114,9 @@ pub struct AppState {
pub session_cost: f64,
/// Accumulated session tokens (input + output), for the status bar.
pub session_tokens: u64,
/// Background jobs spawned by the current session, newest activity last. Populated on
/// session load and kept live via `JobUpdated` events; surfaced in the jobs pane.
pub jobs: Vec<JobRecord>,
}
impl AppState {
@@ -131,6 +139,7 @@ impl AppState {
render_width: 0,
session_cost: 0.0,
session_tokens: 0,
jobs: Vec::new(),
}
}
@@ -156,6 +165,8 @@ impl AppState {
self.session_cost = session.cost;
self.session_tokens = session.usage.input + session.usage.output;
self.messages.clear();
// Jobs are reloaded for the newly-selected session by the caller.
self.jobs.clear();
let mut messages = messages;
messages.sort_by_key(|m| m.created_at);
@@ -258,14 +269,50 @@ impl AppState {
}
self.dirty = true;
}
AppEvent::JobUpdated { .. }
| AppEvent::AuthPrompt { .. }
| AppEvent::ServerNotice { .. } => {
AppEvent::JobUpdated { job } => {
if let Ok(record) = serde_json::from_value::<JobRecord>(job.0) {
if self.session_id.as_ref() == Some(&record.parent_session) {
self.upsert_job(record);
self.dirty = true;
}
}
}
AppEvent::AuthPrompt { .. } | AppEvent::ServerNotice { .. } => {
self.dirty = true;
}
}
}
/// Replaces this session's job list (e.g. after loading a session).
pub fn set_jobs(&mut self, jobs: Vec<JobRecord>) {
self.jobs = jobs;
self.dirty = true;
}
/// Inserts or replaces a job by `task_id`, preserving list order for stable rendering.
fn upsert_job(&mut self, record: JobRecord) {
if let Some(existing) = self.jobs.iter_mut().find(|j| j.task_id == record.task_id) {
*existing = record;
} else {
self.jobs.push(record);
}
}
/// Opens the jobs pane (cursor at the top).
pub fn open_jobs_pane(&mut self) {
self.modal = ModalState::JobsPane { selected: 0 };
self.dirty = true;
}
/// Child session of the currently-selected job in the jobs pane, for drill-in.
pub fn selected_job_child(&self) -> Option<SessionId> {
if let ModalState::JobsPane { selected } = &self.modal {
self.jobs.get(*selected).map(|j| j.child_session.clone())
} else {
None
}
}
pub fn scroll_up(&mut self, n: u16) {
self.scroll_offset = self.scroll_offset.saturating_sub(n);
self.dirty = true;