From cc0e75afc34f7eb4c4865aba73d53b1544557053 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:19:40 +0000 Subject: [PATCH 1/2] Agent: keep application Vim on a task opened on another computer Each paired computer has its own Chat and sidebar, and the window swaps them when a task on another computer opens. The swap carried the sidebar's search, archive and scroll state but not application Vim's row, so the computer switched to showed the row it had when last left, usually the task just navigated away from. The keyboard focus also stayed on the Chat that was replaced, which is no longer drawn, so Vim keys did nothing until a click. Carry the row with the rest of the browse state. A sidebar names its own computer's tasks as plain task rows and other computers' tasks by computer, so the row travels in the computer form and turns back into a plain task row on its own computer. The Chat swapped in takes the keyboard focus, as it already did on its first visit. Co-Authored-By: Claude Opus 5.5 --- apps/maple-agent/app/src/ui/chat/computers.rs | 3 + apps/maple-agent/app/src/ui/chat/sidebar.rs | 25 ++++ apps/maple-agent/app/src/ui/chat/tests.rs | 133 ++++++++++++++++++ 3 files changed, 161 insertions(+) diff --git a/apps/maple-agent/app/src/ui/chat/computers.rs b/apps/maple-agent/app/src/ui/chat/computers.rs index e1e3df5cd..c0beeba16 100644 --- a/apps/maple-agent/app/src/ui/chat/computers.rs +++ b/apps/maple-agent/app/src/ui/chat/computers.rs @@ -128,6 +128,9 @@ impl ChatScreen { ) { self.sidebar .update(cx, |sidebar, cx| sidebar.restore_browse_state(state, cx)); + // The keyboard focus stayed on the computer this one replaced, which + // is no longer drawn. + self.screen_focus_pending = true; } /// Name updates are pushed. Refresh the small folder list periodically for diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index a6085bde7..e753d5508 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -54,6 +54,9 @@ pub(crate) struct BrowseState { search: String, archived_expanded: bool, scroll: gpui::ListOffset, + /// Application Vim's row. Other computers' sidebars name a task by its + /// computer, so it travels in that form. + vim_selected: Option, computers: computers::ComputerBrowseState, } @@ -582,6 +585,13 @@ impl Sidebar { search: self.search_input.read(cx).text(), archived_expanded: self.archived_expanded, scroll: self.list.logical_scroll_top(), + vim_selected: self.vim_selected.clone().map(|target| match target { + SidebarTarget::Task(id) => SidebarTarget::Computer(Navigate { + computer: self.backend.remote_host().map(str::to_owned), + destination: Destination::Task(id), + }), + target => target, + }), computers: self.computer_browse_state(), } } @@ -606,6 +616,21 @@ impl Sidebar { } self.restore_computer_browse_state(state.computers, cx); self.rebuild_sections(); + // Stay on the row the window had, usually the task just opened + // here. This computer's own tasks are plain task rows. + if let Some(target) = state + .vim_selected + .map(|target| match target { + SidebarTarget::Computer(Navigate { + computer, + destination: Destination::Task(id), + }) if computer.as_deref() == self.backend.remote_host() => SidebarTarget::Task(id), + target => target, + }) + .filter(|target| self.vim_target_row(target).is_some()) + { + self.vim_selected = Some(target); + } self.list.scroll_to(state.scroll); cx.notify(); } diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 83976fe18..636a5c80e 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -5784,6 +5784,139 @@ mod state_tests { assert!(!sidebar.update(cx, |sidebar, _| sidebar.switcher_menu_open())); } + /// Every computer has its own Chat and sidebar, and the window swaps + /// them. Application Vim's row is window browsing: a task opened on + /// another computer stays the selected row there, also on a return + /// visit, and the keys reach the Chat swapped in. + #[gpui::test] + fn test_application_vim_row_follows_a_task_opened_on_another_computer(cx: &mut TestAppContext) { + use super::super::computers::{Destination, Navigate}; + use super::super::navigation::{ChatRegion, SidebarTarget}; + use std::cell::RefCell; + use std::rc::Rc; + struct Shell { + chat: Entity, + } + impl Render for Shell { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .w(px(1200.)) + .h(px(800.)) + .flex() + .flex_col() + .child(self.chat.clone()) + } + } + cx.executor().allow_parking(); + let (here, there) = { + let _guard = SETTINGS_LOCK.lock(); + ( + crate::backend::AgentBackend::for_test("http://127.0.0.1:9".into(), String::new()) + .expect("backend"), + crate::backend::AgentBackend::for_remote_test("peer").expect("backend"), + ) + }; + let requests = Rc::new(RefCell::new(Vec::new())); + cx.update(crate::desktop::register_key_bindings); + let mut screen = |backend, task: &str, root: &str| { + let seen = requests.clone(); + let chat = cx.new(|cx| { + let _guard = SETTINGS_LOCK.lock(); + let mut chat = ChatScreen::new_without_start(Arc::new(backend), "user".into(), cx); + chat.booting = false; + chat.trust_prompts = false; + chat.sidebar + .update(cx, |sidebar, _| sidebar.reset_persisted_for_test()); + chat.sessions = vec![summary_at(task, task, root)]; + chat.sync_sidebar(cx); + chat.set_application_vim_enabled(true, cx); + chat + }); + cx.update(|cx| { + cx.subscribe(&chat, move |_, request: &Navigate, _| { + seen.borrow_mut().push(request.clone()); + }) + .detach(); + }); + chat + }; + let local = screen(here, "here", "/a"); + let peer = screen(there, "there", "/b"); + let computers = Arc::new(vec![ + local.read_with(cx, |chat, cx| { + chat.computer_snapshot("This computer".into(), cx) + }), + peer.read_with(cx, |chat, cx| chat.computer_snapshot("Peer".into(), cx)), + ]); + for chat in [&local, &peer] { + chat.update(cx, |chat, cx| chat.set_computers(computers.clone(), cx)); + } + let (shell, cx) = cx.add_window_view(|_, _| Shell { + chat: local.clone(), + }); + cx.simulate_resize(gpui::size(px(1200.), px(800.))); + cx.update(|window, _| window.activate_window()); + cx.run_until_parked(); + // Put Application Vim on another computer's task row and press + // Enter, then swap Chats the way the desktop does. + let mut open = |from: &Entity, to: &Entity, row: Navigate| { + cx.update(|window, cx| { + from.update(cx, |chat, cx| { + chat.select_sidebar_from_pointer( + SidebarTarget::Computer(row.clone()), + window, + cx, + ) + }) + }); + cx.simulate_keystrokes("enter"); + assert_eq!( + requests.borrow_mut().drain(..).collect::>(), + std::slice::from_ref(&row) + ); + let browse = from.update(cx, |chat, cx| chat.sidebar_browse_state(cx)); + from.update(cx, |chat, cx| chat.set_visible(false, cx)); + shell.update(cx, |shell, cx| { + shell.chat = to.clone(); + cx.notify(); + }); + to.update(cx, |chat, cx| { + chat.set_visible(true, cx); + chat.restore_sidebar_browse_state(browse, cx); + chat.navigate(row.destination, cx); + }); + cx.run_until_parked(); + }; + let task = |computer: Option<&str>, id: &str| Navigate { + computer: computer.map(str::to_owned), + destination: Destination::Task(id.into()), + }; + open(&local, &peer, task(Some("peer"), "there")); + open(&peer, &local, task(None, "here")); + open(&local, &peer, task(Some("peer"), "there")); + let state = |cx: &mut gpui::VisualTestContext| { + peer.update(cx, |chat, cx| { + ( + chat.application_vim.region, + chat.sidebar.read(cx).vim_selected().cloned(), + ) + }) + }; + let opened = state(cx); + // The keys reach the Chat now on screen: k leaves the open task. + cx.simulate_keystrokes("k"); + let moved = state(cx); + assert_eq!( + opened, + ( + ChatRegion::Sidebar, + Some(SidebarTarget::Task("there".into())) + ), + "after k: {moved:?}" + ); + assert_ne!(moved.1, opened.1, "k reaches the swapped-in Chat"); + } + /// What menu, if any, the chat and its sidebar show. fn open_menu(chat: &Entity, cx: &mut gpui::VisualTestContext) -> String { chat.update(cx, |this, cx| { From 5444a6fe8e00933c95603b6092c68a479c2276d0 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:16:04 +0000 Subject: [PATCH 2/2] Agent: keep the computer-switch Vim test off fixture page reads The new test failed on Linux CI, and about once in 30 local runs. Each fixture backend opens an empty store, and the sidebar read the computers' task pages from it. An empty reply is authoritative, so the task rows disappeared whenever one landed mid-test, and application Vim fell back to New Task. The test's computers now have no backend, so their rows come from the snapshots alone. Co-Authored-By: Claude Opus 5.5 --- apps/maple-agent/app/src/ui/chat/tests.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 636a5c80e..b90f8bc90 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -5842,12 +5842,19 @@ mod state_tests { }; let local = screen(here, "here", "/a"); let peer = screen(there, "there", "/b"); - let computers = Arc::new(vec![ + let mut computers = vec![ local.read_with(cx, |chat, cx| { chat.computer_snapshot("This computer".into(), cx) }), peer.read_with(cx, |chat, cx| chat.computer_snapshot("Peer".into(), cx)), - ]); + ]; + // Rows come from these snapshots alone. With a backend, the sidebar + // also reads pages from the fixtures' empty stores, and those replies + // remove the rows whenever they happen to land. + for computer in &mut computers { + computer.backend = None; + } + let computers = Arc::new(computers); for chat in [&local, &peer] { chat.update(cx, |chat, cx| chat.set_computers(computers.clone(), cx)); }