fix: project public agent focus to attached clients (#3764)

refs #3760

Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
This commit is contained in:
akbash
2026-09-09 22:40:57 +03:00
committed by GitHub
co-authored by akbash-bot
parent 1f1b20cb5d
commit 985d3442b4
2 changed files with 117 additions and 7 deletions
+27 -7
View File
@@ -248,7 +248,8 @@ impl HeadlessServer {
matches!(
method,
Method::CommandInvoke(_)
Method::AgentFocus(_)
| Method::CommandInvoke(_)
| Method::LayoutSetSplitRatio(_)
| Method::PaneClose(_)
| Method::PaneCopyMotion(_)
@@ -286,7 +287,8 @@ impl HeadlessServer {
matches!(
method,
Method::CommandInvoke(_)
Method::AgentFocus(_)
| Method::CommandInvoke(_)
| Method::LayoutSetSplitRatio(_)
| Method::PaneClose(_)
| Method::PaneEditScrollback(_)
@@ -839,6 +841,10 @@ impl HeadlessServer {
}),
_ => None,
};
let agent_focus_target = match &msg.request.method {
api::schema::Method::AgentFocus(params) => Some(params.target.clone()),
_ => None,
};
let create_focus_requested = match &msg.request.method {
api::schema::Method::WorkspaceCreate(params) => params.focus,
api::schema::Method::TabCreate(params) => params.focus,
@@ -848,19 +854,33 @@ impl HeadlessServer {
&msg.request.method,
api::schema::Method::WorktreeOpen(params) if params.focus
);
let response_proxy = inspect_worktree_open.then(|| {
let response_proxy = (agent_focus_target.is_some() || inspect_worktree_open).then(|| {
let (proxy_tx, proxy_rx) = std::sync::mpsc::channel();
let original = std::mem::replace(&mut msg.respond_to, proxy_tx);
(original, proxy_rx)
});
let reconcile = Self::shell_locations_may_need_reconcile(&msg.request.method);
let changed = self.handle_api_request_with_shutdown_check_inner(msg, false);
let worktree_open_succeeded = forward_proxied_api_response(response_proxy);
let proxied_request_succeeded = forward_proxied_api_response(response_proxy);
let successful_agent_focus_target = proxied_request_succeeded
.then(|| {
agent_focus_target.as_deref().and_then(|target| {
self.app.resolve_agent_target(target).ok().map(|resolved| {
crate::ui::TabSurfaceTarget {
workspace_index: resolved.ws_idx,
tab_index: resolved.tab_idx,
}
})
})
})
.flatten();
let target_changed = self.default_shell_target() != target_before;
let public_focus_succeeded = explicit_public_focus_target
.is_some_and(|target| self.default_shell_target() == Some(target))
let explicit_focus_succeeded = successful_agent_focus_target
.or(explicit_public_focus_target)
.is_some_and(|target| self.default_shell_target() == Some(target));
let public_focus_succeeded = explicit_focus_succeeded
|| (create_focus_requested && target_changed)
|| worktree_open_succeeded;
|| (inspect_worktree_open && proxied_request_succeeded);
if public_focus_succeeded {
self.focus_all_shell_clients_on_default_target();
}
+90
View File
@@ -2129,6 +2129,96 @@ async fn public_workspace_focus_preserves_each_clients_remembered_tabs() {
shutdown_test_runtimes(&mut server);
}
#[tokio::test]
async fn public_agent_focus_replaces_a_diverged_client_shell_projection() {
let mut server = test_headless_server();
let mut first = crate::workspace::Workspace::test_new("first");
let first_pane = first.tabs[0].root_pane;
first.insert_test_runtime(
first_pane,
crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"FIRST_AGENT"),
);
let mut second = crate::workspace::Workspace::test_new("second");
let second_pane = second.tabs[0].root_pane;
second.insert_test_runtime(
second_pane,
crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"SECOND_WORKSPACE"),
);
server.app.state.workspaces = vec![first, second];
server.app.state.ensure_test_terminals();
server.app.state.active = Some(0);
server.app.state.selected = 0;
server.app.state.mode = crate::app::Mode::Terminal;
let first_workspace_id = server.app.public_workspace_id(0);
let first_tab_id = server.app.public_tab_id(0, 0).unwrap();
let first_pane_id = server.app.public_pane_id(0, first_pane).unwrap();
let second_tab_id = server.app.public_tab_id(1, 0).unwrap();
let (control_rx, render_rx) = connect_test_shell(&mut server, 9, 80, 23);
let _ = control_rx.recv().expect("initial snapshot");
assert!(server.focus_shell_client_on_tab(9, &second_tab_id));
assert!(server.claim_shell_tab_geometry(9, false));
server.render_and_stream();
let diverged = client_shell_snapshot(read_server_message(
control_rx.recv().expect("diverged snapshot"),
));
assert_eq!(
diverged.focused_workspace_id.as_deref(),
Some(server.app.public_workspace_id(1).as_str())
);
let diverged_surface = recv_pane_surface(&render_rx, "diverged surface");
assert!(frame_text(&diverged_surface.frame).contains("SECOND_WORKSPACE"));
server
.app
.event_tx
.try_send(AppEvent::AgentProcessDetected {
pane_id: first_pane,
agent: crate::detect::Agent::Claude,
observed_at: Instant::now(),
})
.unwrap();
let (respond_to, response_rx) = std::sync::mpsc::channel();
server.handle_api_request_with_shutdown_check(crate::api::ApiRequestMessage {
request: crate::api::schema::Request {
id: "focus-first-agent".into(),
method: crate::api::schema::Method::AgentFocus(crate::api::schema::AgentTarget {
target: first_pane_id.clone(),
}),
},
respond_to,
response_write_complete: None,
stream_active: None,
});
let response: crate::api::schema::SuccessResponse =
serde_json::from_str(&response_rx.recv().expect("agent focus response")).unwrap();
let crate::api::schema::ResponseResult::AgentInfo { agent } = response.result else {
panic!("expected agent info");
};
assert_eq!(agent.pane_id, first_pane_id);
assert!(agent.focused);
assert_eq!(server.app.state.active, Some(0));
let location = server.clients[&9].shell_location.as_ref().unwrap();
assert_eq!(
location.focused_workspace_id.as_deref(),
Some(first_workspace_id.as_str())
);
assert_eq!(location.focused_tab_id(), Some(first_tab_id.as_str()));
server.render_and_stream();
let replacement = client_shell_snapshot(read_server_message(
control_rx.recv().expect("agent focus replacement snapshot"),
));
assert_eq!(
replacement.focused_workspace_id.as_deref(),
Some(first_workspace_id.as_str())
);
let replacement_surface = recv_pane_surface(&render_rx, "agent focus replacement surface");
assert!(frame_text(&replacement_surface.frame).contains("FIRST_AGENT"));
assert!(!frame_text(&replacement_surface.frame).contains("SECOND_WORKSPACE"));
shutdown_test_runtimes(&mut server);
}
#[tokio::test]
async fn public_api_focus_replaces_every_client_shell_projection() {
let mut server = test_headless_server();