diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-02 21:04:11 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-02 21:25:32 +0300 |
| commit | 48641e253f1aa3886b470cbebec60103247a7622 (patch) | |
| tree | e4b76585fc8b928436d6379fe5caaf193ea88300 /src | |
| parent | bed8f1ebe9417d448837c107d92a8770f60b6d1c (diff) | |
fix: resume restored agents across background panes
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/agent_resume.rs | 286 |
1 files changed, 234 insertions, 52 deletions
diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs index 3523da05..8ff6cc4d 100644 --- a/src/app/agent_resume.rs +++ b/src/app/agent_resume.rs @@ -1,6 +1,10 @@ use std::time::Instant; use bytes::Bytes; +use ratatui::{ + layout::Rect, + widgets::{Block, Borders}, +}; use super::App; @@ -75,55 +79,79 @@ impl App { } fn pending_agent_resume_candidates(&self) -> Vec<PendingAgentResumeCandidate> { - let Some(ws_idx) = self.state.active else { - return Vec::new(); - }; - let Some(ws) = self.state.workspaces.get(ws_idx) else { - return Vec::new(); - }; - let Some(tab) = ws.tabs.get(ws.active_tab) else { + let terminal_area = self.state.view.terminal_area; + if terminal_area.width == 0 || terminal_area.height == 0 { return Vec::new(); }; let mut pending = Vec::new(); - for pane_id in tab.layout.pane_ids() { - let Some(pane) = tab.panes.get(&pane_id) else { - continue; - }; - if self - .terminal_runtimes - .get(&pane.attached_terminal_id) - .is_some() - { - continue; + for (ws_idx, ws) in self.state.workspaces.iter().enumerate() { + for (tab_idx, tab) in ws.tabs.iter().enumerate() { + for info in + self.pending_agent_resume_pane_infos(ws_idx, tab_idx, tab, terminal_area) + { + let Some(pane) = tab.panes.get(&info.id) else { + continue; + }; + if self + .terminal_runtimes + .get(&pane.attached_terminal_id) + .is_some() + { + continue; + } + let Some(terminal) = self.state.terminals.get(&pane.attached_terminal_id) + else { + continue; + }; + let Some(plan) = terminal.pending_agent_resume_plan.clone() else { + continue; + }; + pending.push(PendingAgentResumeCandidate { + pane_id: info.id, + terminal_id: pane.attached_terminal_id.clone(), + cwd: terminal.cwd.clone(), + plan, + rows: info.inner_rect.height, + cols: info.inner_rect.width, + }); + } } - let Some(info) = self - .state - .view - .pane_infos - .iter() - .find(|info| info.id == pane_id) - else { - continue; - }; - let Some(terminal) = self.state.terminals.get(&pane.attached_terminal_id) else { - continue; - }; - let Some(plan) = terminal.pending_agent_resume_plan.clone() else { - continue; - }; - pending.push(PendingAgentResumeCandidate { - pane_id, - terminal_id: pane.attached_terminal_id.clone(), - cwd: terminal.cwd.clone(), - plan, - rows: info.inner_rect.height, - cols: info.inner_rect.width, - }); } pending } + fn pending_agent_resume_pane_infos( + &self, + ws_idx: usize, + tab_idx: usize, + tab: &crate::workspace::Tab, + terminal_area: Rect, + ) -> Vec<crate::layout::PaneInfo> { + let mut pane_infos = derived_pending_agent_resume_pane_infos(tab, terminal_area); + + if self.state.active == Some(ws_idx) + && self + .state + .workspaces + .get(ws_idx) + .is_some_and(|ws| tab_idx == ws.active_tab_index()) + { + for visible_info in &self.state.view.pane_infos { + if let Some(info) = pane_infos + .iter_mut() + .find(|info| info.id == visible_info.id) + { + *info = visible_info.clone(); + } else { + pane_infos.push(visible_info.clone()); + } + } + } + + pane_infos + } + pub(crate) fn start_pending_agent_resume_for_terminal( &mut self, terminal_id: &crate::terminal::TerminalId, @@ -247,6 +275,39 @@ impl App { } } +fn derived_pending_agent_resume_pane_infos( + tab: &crate::workspace::Tab, + terminal_area: Rect, +) -> Vec<crate::layout::PaneInfo> { + let multi_pane = tab.layout.pane_count() > 1; + tab.layout + .panes(terminal_area) + .into_iter() + .map(|mut info| { + let pane_inner = if multi_pane { + Block::default().borders(Borders::ALL).inner(info.rect) + } else { + terminal_area + }; + info.inner_rect = stable_terminal_inner_rect(pane_inner); + info + }) + .collect() +} + +fn stable_terminal_inner_rect(pane_inner: Rect) -> Rect { + if pane_inner.width <= 4 { + return pane_inner; + } + + Rect::new( + pane_inner.x, + pane_inner.y, + pane_inner.width.saturating_sub(1), + pane_inner.height, + ) +} + fn shell_command_from_argv(argv: &[String]) -> Option<String> { let mut parts = argv.iter(); let first = shell_quote(parts.next()?); @@ -301,6 +362,7 @@ mod tests { app.state.workspaces = vec![workspace]; app.state.active = Some(0); app.state.ensure_test_terminals(); + app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 100, 30); app.state.view.pane_infos = pane_infos; let terminal = app .state @@ -379,6 +441,7 @@ mod tests { app.state.view.pane_infos = workspace.tabs[0] .layout .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); + app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 100, 30); app.state.workspaces = vec![workspace]; app.state.active = Some(0); app.state.ensure_test_terminals(); @@ -403,7 +466,7 @@ mod tests { } #[tokio::test] - async fn pending_agent_resume_skips_hidden_panes_without_visible_geometry() { + async fn pending_agent_resume_launches_hidden_panes_with_current_terminal_area() { let mut app = test_app(); let active_workspace = crate::workspace::Workspace::test_new("active"); let active_pane = active_workspace.tabs[0].root_pane; @@ -414,6 +477,7 @@ mod tests { app.state.view.pane_infos = active_workspace.tabs[0] .layout .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); + app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 100, 30); app.state.workspaces = vec![active_workspace, hidden_workspace]; app.state.active = Some(0); app.state.ensure_test_terminals(); @@ -445,19 +509,131 @@ mod tests { assert!(app.start_pending_agent_resumes(false)); assert!(app.terminal_runtimes.get(&active_terminal).is_some()); - assert!(app.terminal_runtimes.get(&hidden_terminal).is_none()); + assert!(app.terminal_runtimes.get(&hidden_terminal).is_some()); assert!( app.pending_agent_resume_deadline.is_none(), - "hidden-only pending resumes should not keep an expired wakeup deadline active" + "launched pending resumes should clear the wakeup deadline" + ); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + } + + #[tokio::test] + async fn pending_agent_resume_launches_inactive_tab_panes_with_current_terminal_area() { + let mut app = test_app(); + let mut workspace = crate::workspace::Workspace::test_new("tabs"); + let active_pane = workspace.tabs[0].root_pane; + let inactive_tab = workspace.test_add_tab(Some("agents")); + let inactive_pane = workspace.tabs[inactive_tab].root_pane; + let inactive_terminal = workspace.tabs[inactive_tab] + .terminal_id(inactive_pane) + .cloned() + .unwrap(); + app.state.view.pane_infos = workspace.tabs[0] + .layout + .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); + app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 100, 30); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + assert!(app + .state + .workspaces + .first() + .and_then(|ws| ws.tabs[0].terminal_id(active_pane)) + .is_some()); + app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + app.state + .terminals + .get_mut(&inactive_terminal) + .expect("inactive tab terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0inactive-tab-session".into(), + }); + + assert!(app.start_pending_agent_resumes(false)); + assert!(app.terminal_runtimes.get(&inactive_terminal).is_some()); + assert!( + app.state + .terminals + .get(&inactive_terminal) + .expect("inactive tab terminal should still exist") + .pending_agent_resume_plan + .is_none(), + "inactive tab restored panes should not wait for tab focus" ); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + } + + #[tokio::test] + async fn pending_agent_resume_launches_zoom_hidden_active_tab_panes() { + let mut app = test_app(); + let mut workspace = crate::workspace::Workspace::test_new("zoomed"); + let hidden_pane = workspace.tabs[0].root_pane; + let visible_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); + workspace.tabs[0].zoomed = true; + let hidden_terminal = workspace.terminal_id(hidden_pane).cloned().unwrap(); + app.state.view.pane_infos = vec![crate::layout::PaneInfo { + id: visible_pane, + rect: ratatui::layout::Rect::new(0, 0, 100, 30), + inner_rect: ratatui::layout::Rect::new(1, 1, 98, 28), + scrollbar_rect: None, + is_focused: true, + }]; + app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 100, 30); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + app.state + .terminals + .get_mut(&hidden_terminal) + .expect("hidden zoom pane terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0zoom-hidden-session".into(), + }); + + assert!(app.start_pending_agent_resumes(false)); + assert!(app.terminal_runtimes.get(&hidden_terminal).is_some()); assert!( app.state .terminals .get(&hidden_terminal) - .expect("hidden terminal should still exist") + .expect("hidden zoom pane terminal should still exist") .pending_agent_resume_plan - .is_some(), - "hidden restored panes should wait until their tab has computed geometry" + .is_none(), + "zoom-hidden restored panes should not wait for pane focus" ); for (_, runtime) in app.terminal_runtimes.drain() { @@ -466,7 +642,7 @@ mod tests { } #[tokio::test] - async fn pending_agent_resume_ignores_stale_geometry_from_previous_active_view() { + async fn pending_agent_resume_uses_current_terminal_area_for_background_panes() { let mut app = test_app(); let previous_workspace = crate::workspace::Workspace::test_new("previous"); let previous_pane = previous_workspace.tabs[0].root_pane; @@ -478,6 +654,7 @@ mod tests { app.state.view.pane_infos = previous_workspace.tabs[0] .layout .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); + app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 80, 24); app.state.workspaces = vec![previous_workspace, current_workspace]; app.state.active = Some(1); app.state.ensure_test_terminals(); @@ -504,18 +681,22 @@ mod tests { }); app.sync_pending_agent_resume_deadline(std::time::Instant::now()); - assert!(app.pending_agent_resume_deadline.is_none()); - assert!(!app.start_pending_agent_resumes(false)); - assert!(app.terminal_runtimes.get(&previous_terminal).is_none()); + assert!(app.pending_agent_resume_deadline.is_some()); + assert!(app.start_pending_agent_resumes(false)); + assert!(app.terminal_runtimes.get(&previous_terminal).is_some()); assert!( app.state .terminals .get(&previous_terminal) .expect("previous terminal should still exist") .pending_agent_resume_plan - .is_some(), - "a pane hidden by navigation should wait for a fresh visible geometry snapshot" + .is_none(), + "background restored panes should not wait for focus once terminal area is known" ); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } } #[tokio::test] @@ -531,6 +712,7 @@ mod tests { scrollbar_rect: None, is_focused: true, }]; + app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 100, 30); app.state.workspaces = vec![workspace]; app.state.active = Some(0); app.state.ensure_test_terminals(); |
