Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 256 additions & 1 deletion apps/maple-agent/app/src/desktop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use crate::backend::AgentBackend;
use crate::ui;
use crate::ui::chat::computers::{
Computer, Destination, Navigate, NavigationChanged, ProjectRenameRequest, ProjectTarget,
TaskAction, TaskActionRequest,
};
use crate::ui::chat::{ChatScreen, LoggedOut, SignOutFailed};
use crate::ui::login::{LoginScreen, LoginSucceeded};
Expand Down Expand Up @@ -61,6 +62,14 @@ struct PendingProjectRename {
source: gpui::WeakEntity<ChatScreen>,
}

struct PendingTaskAction {
request: TaskActionRequest,
account: String,
owner: gpui::EntityId,
grant: Option<[u8; 16]>,
source: gpui::WeakEntity<ChatScreen>,
}

enum ConnectionAttempt {
Idle,
Connecting(u64),
Expand Down Expand Up @@ -1425,7 +1434,14 @@ impl MapleApp {
&self,
target: &ProjectTarget,
) -> Option<(Entity<ChatScreen>, Option<[u8; 16]>)> {
match &target.computer {
self.computer_owner(target.computer.as_deref())
}

fn computer_owner(
&self,
computer: Option<&str>,
) -> Option<(Entity<ChatScreen>, Option<[u8; 16]>)> {
match computer {
None => self.local_chat.clone().map(|chat| (chat, None)),
Some(peer) => self.remote_computers.get(peer).and_then(|computer| {
computer
Expand Down Expand Up @@ -1535,6 +1551,117 @@ impl MapleApp {
}
}

fn task_action(
&mut self,
source: Entity<ChatScreen>,
request: TaskActionRequest,
cx: &mut Context<Self>,
) {
let Some(account) = self
.user_id
.clone()
.filter(|_| self.has_chat(source.entity_id()))
else {
return;
};
let Some((owner, grant)) = self
.computer_owner(request.target.computer.as_deref())
.filter(|(chat, _)| chat.read(cx).project_names_available())
else {
source.update(cx, |chat, cx| {
chat.sidebar_task_action_notice(
"This computer is unavailable. Reconnect it before changing the task.".into(),
cx,
);
});
return;
};
if let TaskAction::Pin(pinned) = request.action {
owner.update(cx, |chat, cx| {
chat.set_sidebar_task_pin(&request.target.id, pinned, cx)
});
self.sync_computers(cx);
return;
}
if matches!(request.action, TaskAction::SetState(state) if state != maple_agent::agent::AgentTaskState::Active)
{
owner.update(cx, |chat, cx| {
chat.set_sidebar_task_pin(&request.target.id, false, cx)
});
}
let backend = owner.read(cx).execution_backend();
let pending = PendingTaskAction {
request: request.clone(),
account: account.clone(),
owner: owner.entity_id(),
grant,
source: source.downgrade(),
};
let executor = backend.clone();
let bridge = ui::task::call(
&executor,
async move {
let id = &request.target.id;
match request.action {
TaskAction::Rename(name) => {
backend.rename_session(&account, id, name).await.map(Some)
}
TaskAction::SetState(state) => backend
.set_session_state(&account, id, state)
.await
.map(Some),
TaskAction::Delete => backend.delete_session(&account, id).await.map(|()| None),
TaskAction::Pin(_) => Err("This task action is unavailable.".into()),
}
},
cx,
move |app, result, cx| app.complete_task_action(pending, result, cx),
);
ui::task::retain(&self.remote_bridges, bridge);
}

fn complete_task_action(
&mut self,
pending: PendingTaskAction,
result: Result<Option<maple_agent::agent::AgentSessionSummary>, String>,
cx: &mut Context<Self>,
) {
if self.user_id.as_deref() != Some(&pending.account) {
return;
}
let owner = self
.computer_owner(pending.request.target.computer.as_deref())
.filter(|(chat, grant)| {
chat.entity_id() == pending.owner
&& *grant == pending.grant
&& !chat.read(cx).connection_ended()
});
let result = match owner {
Some((owner, _)) => result.map(|session| {
owner.update(cx, |chat, cx| {
chat.apply_sidebar_task_action(
&pending.request.target.id,
&pending.request.action,
session,
cx,
);
});
self.sync_computers(cx);
}),
None => {
Err("The computer connection changed. Check the task before trying again.".into())
}
};
if let Some(source) = pending.source.upgrade()
&& self.has_chat(source.entity_id())
{
source.update(cx, |chat, cx| match result {
Ok(()) => chat.refresh_sidebar_task_actions(cx),
Err(message) => chat.sidebar_task_action_notice(message, cx),
});
}
}

fn navigate(&mut self, request: Navigate, cx: &mut Context<Self>) {
self.requested_navigation = Some(PendingNavigation {
request: request.clone(),
Expand Down Expand Up @@ -1864,6 +1991,10 @@ impl MapleApp {
app.rename_project(source, request.clone(), cx)
})
.detach();
cx.subscribe(chat, |app, source, request: &TaskActionRequest, cx| {
app.task_action(source, request.clone(), cx)
})
.detach();
cx.subscribe(
chat,
|app: &mut MapleApp, emitter, _event: &LoggedOut, cx| {
Expand Down Expand Up @@ -4550,6 +4681,130 @@ mod tests {
});
}

fn task_summary(title: &str) -> maple_agent::agent::AgentSessionSummary {
maple_agent::agent::AgentSessionSummary {
id: "same-task".into(),
title: title.into(),
project_root: "/same/project".into(),
created_ms: 1,
updated_ms: 2,
message_count: 1,
model: None,
web_enabled: true,
state: maple_agent::agent::AgentTaskState::Active,
acp: false,
}
}

fn task_attempt(app: &MapleApp, computer: Option<&str>) -> PendingTaskAction {
let (owner, grant) = app.computer_owner(computer).unwrap();
let Screen::Chat(source) = &app.screen else {
panic!("chat fixture")
};
PendingTaskAction {
request: TaskActionRequest {
target: crate::settings::SidebarItemKey {
computer: computer.map(str::to_string),
id: "same-task".into(),
},
action: TaskAction::Rename("New title".into()),
},
account: "user".into(),
owner: owner.entity_id(),
grant,
source: source.downgrade(),
}
}

fn seed_task_owners(app: &MapleApp, cx: &mut Context<MapleApp>) {
for (computer, title) in [
(None, "Local task"),
(Some("east"), "East task"),
(Some("west"), "West task"),
] {
let (owner, _) = app.computer_owner(computer).unwrap();
owner.update(cx, |chat, cx| {
chat.apply_sidebar_task_action(
"same-task",
&TaskAction::Rename(title.into()),
Some(task_summary(title)),
cx,
);
});
}
}

fn task_title(app: &MapleApp, computer: Option<&str>, cx: &App) -> String {
let (owner, _) = app.computer_owner(computer).unwrap();
owner
.read(cx)
.computer_snapshot("Fixture".into(), cx)
.sessions
.into_iter()
.find(|session| session.id == "same-task")
.unwrap()
.title
}

#[gpui::test]
fn task_action_results_route_duplicate_ids_without_navigation(cx: &mut gpui::TestAppContext) {
let app = project_shell(cx);
app.update(cx, |app, cx| {
seed_task_owners(app, cx);
let east = app.remote_computers["east"].chat.as_ref().unwrap().clone();
let navigation = east.read(cx).navigation_generation();
app.screen = Screen::Chat(east.clone());
app.selected_host = Some("east".into());

let pending = task_attempt(app, None);
app.complete_task_action(pending, Ok(Some(task_summary("Renamed local"))), cx);
assert_eq!(task_title(app, None, cx), "Renamed local");
assert_eq!(task_title(app, Some("east"), cx), "East task");
assert_eq!(task_title(app, Some("west"), cx), "West task");

let pending = task_attempt(app, Some("west"));
app.complete_task_action(pending, Ok(Some(task_summary("Renamed west"))), cx);
assert_eq!(task_title(app, Some("west"), cx), "Renamed west");
assert_eq!(task_title(app, None, cx), "Renamed local");
assert_eq!(task_title(app, Some("east"), cx), "East task");
assert!(matches!(&app.screen, Screen::Chat(chat)
if chat.entity_id() == east.entity_id()
&& chat.read(cx).navigation_generation() == navigation));
assert_eq!(app.selected_host.as_deref(), Some("east"));
assert!(app.requested_navigation.is_none());
});
}

#[gpui::test]
fn stale_task_action_results_cannot_mutate_replacement_owners(cx: &mut gpui::TestAppContext) {
let app = project_shell(cx);
app.update(cx, |app, cx| {
seed_task_owners(app, cx);
let pending = task_attempt(app, Some("east"));
app.remote_computers.get_mut("east").unwrap().grant = [3; 16];
app.complete_task_action(pending, Ok(Some(task_summary("Old grant"))), cx);
assert_eq!(task_title(app, Some("east"), cx), "East task");

let pending = task_attempt(app, Some("east"));
let west = app.remote_computers["west"].chat.clone();
app.remote_computers.get_mut("east").unwrap().chat = west;
app.complete_task_action(pending, Ok(Some(task_summary("Old owner"))), cx);
assert_eq!(task_title(app, Some("east"), cx), "West task");

let pending = task_attempt(app, Some("east"));
app.remote_computers.remove("east");
app.complete_task_action(pending, Ok(Some(task_summary("Unpaired"))), cx);
assert!(!app.remote_computers.contains_key("east"));
assert_eq!(task_title(app, Some("west"), cx), "West task");

let pending = task_attempt(app, None);
app.user_id = Some("another-account".into());
app.complete_task_action(pending, Ok(Some(task_summary("Old account"))), cx);
assert_eq!(task_title(app, None, cx), "Local task");
assert!(app.requested_navigation.is_none());
});
}

fn rename_attempt(app: &MapleApp, computer: Option<&str>) -> PendingProjectRename {
let target = ProjectTarget {
computer: computer.map(str::to_string),
Expand Down
73 changes: 73 additions & 0 deletions apps/maple-agent/app/src/ui/chat/computers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ pub(crate) struct ProjectRenameRequest {
pub name: String,
}

#[derive(Clone, Debug)]
pub(crate) enum TaskAction {
Rename(String),
SetState(AgentTaskState),
Pin(bool),
Delete,
}

#[derive(Clone, Debug)]
pub(crate) struct TaskActionRequest {
pub target: crate::settings::SidebarItemKey,
pub action: TaskAction,
}

pub(crate) struct NavigationChanged;

#[derive(Clone)]
Expand Down Expand Up @@ -78,6 +92,7 @@ impl Computer {

impl EventEmitter<Navigate> for ChatScreen {}
impl EventEmitter<ProjectRenameRequest> for ChatScreen {}
impl EventEmitter<TaskActionRequest> for ChatScreen {}
impl EventEmitter<NavigationChanged> for ChatScreen {}

impl ChatScreen {
Expand All @@ -95,6 +110,64 @@ impl ChatScreen {
cx.notify();
}

pub(crate) fn set_sidebar_task_pin(
&mut self,
session_id: &str,
pinned: bool,
cx: &mut Context<Self>,
) {
let was_pinned = self
.sidebar
.read(cx)
.pinned_tasks()
.iter()
.any(|id| id == session_id);
if was_pinned != pinned {
self.toggle_task_pin(session_id, cx);
}
}

pub(crate) fn apply_sidebar_task_action(
&mut self,
session_id: &str,
action: &TaskAction,
session: Option<AgentSessionSummary>,
cx: &mut Context<Self>,
) {
if let Some(session) = session {
let root = session.project_root.clone();
self.upsert_session(session, cx);
if matches!(action, TaskAction::SetState(AgentTaskState::Archived))
&& self.selected_session.as_deref() == Some(session_id)
{
self.leave_selected_session(cx);
let next = self
.sessions
.iter()
.find(|session| {
session.state != AgentTaskState::Archived && session.project_root == root
})
.map(|session| session.id.clone());
if let Some(id) = next {
self.select_session(&id, cx);
}
}
} else if matches!(action, TaskAction::Delete) {
self.remove_session(session_id, cx);
}
cx.notify();
}

pub(crate) fn sidebar_task_action_notice(&mut self, message: String, cx: &mut Context<Self>) {
self.notice = Some(message.into());
cx.notify();
}

pub(crate) fn refresh_sidebar_task_actions(&mut self, cx: &mut Context<Self>) {
self.sidebar
.update(cx, |sidebar, cx| sidebar.refresh_computer_pages(cx));
}

#[cfg(test)]
pub(crate) fn seed_projects_for_test(
&mut self,
Expand Down
Loading
Loading