Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/maple-agent/app/assets/icons/circle-alert.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/maple-agent/app/assets/icons/download.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions apps/maple-agent/app/src/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@ assets!(
"icons/check.svg",
"icons/chevron-down.svg",
"icons/chevron-right.svg",
"icons/circle-alert.svg",
// Claude mark (simple-icons, CC0) for the Claude Code integration card.
"icons/claude-mark.svg",
"icons/copy.svg",
// Contrast-safe partner marks published at https://cua.ai/branding.
"icons/cua-mark-black.svg",
"icons/cua-mark-white.svg",
"icons/download.svg",
"icons/ellipsis.svg",
"icons/folder-open.svg",
"icons/folder-plus.svg",
Expand Down
13 changes: 9 additions & 4 deletions apps/maple-agent/app/src/desktop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1080,17 +1080,22 @@ impl MapleApp {
return;
}
self.restart_prompt_pending = true;
let title = crate::update::restart_title(self.updater.snapshot().version.as_deref());
#[cfg(target_os = "linux")]
let answer = {
let (prompt, answer) = ui::update_confirmation::open(window, cx);
let (prompt, answer) = ui::update_confirmation::open(title, window, cx);
self.restart_confirmation = Some(prompt);
cx.notify();
answer
};
#[cfg(not(target_os = "linux"))]
let answer = window.prompt(gpui::PromptLevel::Warning, "Restart Maple to update?",
Some("This stops running tasks and remote access on this computer. Editors using Maple must reconnect after the update."),
&["Later", "Restart to Update"], cx);
let answer = window.prompt(
gpui::PromptLevel::Warning,
&title,
Some(crate::update::RESTART_DETAIL),
&["Later", "Restart to Update"],
cx,
);
let controller = self.updater.clone();
let backend = self.backend.clone();
let install_backend = backend.clone();
Expand Down
175 changes: 91 additions & 84 deletions apps/maple-agent/app/src/ui/chat/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3777,90 +3777,31 @@ impl ChatScreen {
}
}

fn render_update_banner(&self, cx: &mut Context<Self>) -> Option<Div> {
/// What the Settings menu says about the updater, when it has
/// something for the user: the row label and whether it failed.
fn update_notice(&self) -> Option<(&'static str, bool)> {
let snapshot = &self.update;
if !snapshot.show_banner {
return None;
}
let action = snapshot.primary;
Some(
div()
.debug_selector(|| "update-banner".to_string())
.w_full()
.min_w_0()
.flex()
.flex_col()
.gap_2()
.px_3()
.py_2()
.rounded(theme::RADIUS_SM)
.bg(gpui::rgb(theme::bg_elevated()))
.border_1()
.border_color(gpui::rgb(theme::border()))
.text_sm()
.text_color(gpui::rgb(theme::text_primary()))
.child(
div()
.w_full()
.flex()
.items_start()
.gap_3()
.child(div().flex_shrink_0().child(icon(
"arrow-up",
px(14.),
theme::accent(),
)))
.child(
div()
.debug_selector(|| "update-banner-label".to_string())
.flex_1()
.min_w_0()
.child(snapshot.label.clone()),
),
)
.child(
div()
.flex()
.flex_wrap()
.justify_end()
.gap_2()
.when_some(action, |row, action| {
row.child(
div()
.id("update-primary")
.debug_selector(|| "update-primary".to_string())
.flex_shrink_0()
.px_2()
.py_0p5()
.rounded(theme::RADIUS_SM)
.text_color(gpui::rgb(theme::accent()))
.hover(|style| {
style.bg(theme::overlay_hover()).cursor_pointer()
})
.on_click(cx.listener(move |_, _, _, cx| {
cx.emit(crate::update::UpdateRequested(action));
}))
.child(snapshot.primary_label),
)
})
.child(
div()
.id("update-later")
.debug_selector(|| "update-later".to_string())
.flex_shrink_0()
.px_2()
.py_0p5()
.rounded(theme::RADIUS_SM)
.hover(|style| style.bg(theme::overlay_hover()).cursor_pointer())
.on_click(cx.listener(|_, _, _, cx| {
cx.emit(crate::update::UpdateRequested(
crate::update::UpdateAction::Later,
));
}))
.child("Later"),
),
),
)
Some(match snapshot.state {
crate::update::State::Error => ("Update failed", true),
crate::update::State::RestartRequired => ("Restart to finish update", false),
_ => ("Update available", false),
})
}

/// A ready update opens the restart confirmation; anything else
/// (manual download, a failure) opens About, which shows the full
/// message and actions.
fn open_update(&mut self, cx: &mut Context<Self>) {
if self.update.state == crate::update::State::Ready {
cx.emit(crate::update::UpdateRequested(
crate::update::UpdateAction::Restart,
));
} else {
cx.emit(OpenSettingsSection(Section::About));
}
}

/// Keep a sent prompt for Up/Down recall. Repeats move to the end.
Expand Down Expand Up @@ -6602,6 +6543,28 @@ fn transcript_list_state() -> gpui::ListState {
state
}

/// The update mark beside Settings and on its menu row: a download glyph
/// on the accent, or a muted alert when the update failed.
fn update_badge(size: gpui::Pixels, failed: bool) -> Div {
let badge = div()
.flex_none()
.size(size)
.rounded_full()
.flex()
.items_center()
.justify_center();
if failed {
badge
.border_1()
.border_color(gpui::rgb(theme::border()))
.child(icon("circle-alert", size * 0.67, theme::text_muted()))
} else {
badge
.bg(gpui::rgb(theme::accent()))
.child(icon("download", size * 0.6, theme::on_accent()))
}
}

impl Render for ChatScreen {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// Refreshed every frame; activation changes force a redraw, so
Expand Down Expand Up @@ -6940,8 +6903,13 @@ impl ChatScreen {
) -> Div {
let expanded = expansion > 0.95;
let open = self.popup.is_open(&ChatPopup::Settings);
let update = self.update_notice();
let button = widgets::ghost_button("open-settings")
.aria_label("Settings menu")
.aria_label(if update.is_some() {
"Settings menu, update available"
} else {
"Settings menu"
})
.a11y_synthetic_children(|tree| {
tree.parent_node()
.set_has_popup(gpui::accesskit::HasPopup::Menu);
Expand All @@ -6956,12 +6924,37 @@ impl ChatScreen {
.when(open, |button| {
button.bg(gpui::rgb(theme::bg_sidebar_row_hover()))
})
.child(icon("settings", px(16.), theme::text_secondary()))
.child(
div()
.relative()
.flex_none()
.child(icon("settings", px(16.), theme::text_secondary()))
// Collapsed, a dot on the gear stands in for the badge.
.when(!expanded && update.is_some(), |gear| {
gear.child(
div()
.debug_selector(|| "settings-update-dot".to_string())
.absolute()
.top(px(-2.))
.right(px(-2.))
.size(px(6.))
.rounded_full()
.bg(gpui::rgb(theme::accent())),
)
}),
)
.when(expanded, |button| {
button
.justify_start()
.gap_2()
.child(div().flex_1().child("Settings"))
.child("Settings")
.when_some(update, |button, (_, failed)| {
button.child(
update_badge(px(18.), failed)
.debug_selector(|| "settings-update-badge".to_string()),
)
})
.child(div().flex_1())
.child(
icon("chevron-down", px(12.), theme::text_muted()).when(!open, |icon| {
icon.with_transformation(gpui::Transformation::rotate(gpui::radians(
Expand Down Expand Up @@ -7075,6 +7068,21 @@ impl ChatScreen {
.content(usage),
)
.separator()
.items(update.map(|(label, failed)| {
MenuItem::new("settings-menu-update", label, |this: &mut Self, _, cx| {
this.open_update(cx);
})
.content(
div()
.flex()
.items_center()
.gap_2()
// The badge overhangs the icon column by 2 px a
// side, so its label lines up with the rows below.
.child(update_badge(px(18.), failed).mx(px(-2.)))
.child(label),
)
}))
.item(
MenuItem::new("settings-menu-account", "Account", |_: &mut Self, _, cx| {
cx.emit(OpenSettingsSection(Section::Account));
Expand Down Expand Up @@ -7301,7 +7309,6 @@ impl ChatScreen {
column.child(widgets::banner(theme::status_error()).child(error))
})
.children(self.render_persistence_failure(cx))
.children(self.render_update_banner(cx))
.children(self.render_notice(cx)),
);
div()
Expand Down
120 changes: 72 additions & 48 deletions apps/maple-agent/app/src/ui/chat/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11019,55 +11019,79 @@ mod state_tests {
);
}
#[gpui::test]
fn long_update_failure_wraps_without_hiding_download_or_later(cx: &mut TestAppContext) {
struct ChatHost {
chat: Entity<ChatScreen>,
}
impl Render for ChatHost {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div().size_full().flex().flex_col().child(self.chat.clone())
}
}
cx.executor().allow_parking();
let chat = cx.new(|cx| {
let _guard = SETTINGS_LOCK.lock();
let backend = Arc::new(crate::backend::AgentBackend::for_test("http://127.0.0.1:9".into(), String::new()).unwrap());
let mut chat = ChatScreen::new_without_start(backend, "user".into(), cx);
chat.update = crate::update::Snapshot {
state: crate::update::State::Error,
label: "Could not install the update: permission denied. Quit and reopen Maple, then retry. If this keeps happening, download the latest version manually.".into(),
version: Some("0.0.6".into()),
remote_candidate: None,
primary: Some(crate::update::UpdateAction::ManualDownload),
primary_label: "Download",
manual_download_url: Some("https://updates.maple.ai/download/agent/stable/macos".into()),
show_banner: true,
};
chat
fn update_badge_marks_settings_and_its_menu_row_routes_by_state(cx: &mut TestAppContext) {
use std::cell::RefCell;
use std::rc::Rc;
let (chat, cx) = chat_window(cx, |_, _| {});
let actions = Rc::new(RefCell::new(Vec::new()));
let sections = Rc::new(RefCell::new(Vec::new()));
let (action_log, section_log) = (actions.clone(), sections.clone());
let _actions = cx.update(|_, app| {
app.subscribe(
&chat,
move |_, request: &crate::update::UpdateRequested, _| {
action_log.borrow_mut().push(request.0);
},
)
});
let (_host, cx) = cx.add_window_view(|_, _| ChatHost { chat: chat.clone() });
for width in [1213., 900.] {
cx.simulate_resize(gpui::size(px(width), px(800.)));
let banner = cx.debug_bounds("update-banner").expect("update banner");
let label = cx
.debug_bounds("update-banner-label")
.expect("failure advice");
assert!(
label.size.height > px(25.),
"failure advice must wrap: {label:?}"
);
for selector in ["update-primary", "update-later"] {
let action = cx.debug_bounds(selector).expect("update action");
assert!(
action.left() >= banner.left() && action.right() <= banner.right(),
"{selector}: {action:?}, {banner:?}"
);
assert!(
action.top() >= label.bottom() && action.bottom() <= banner.bottom(),
"{selector}: {action:?}, {label:?}"
);
}
}
let _sections = cx.update(|_, app| {
app.subscribe(&chat, move |_, request: &OpenSettingsSection, _| {
section_log.borrow_mut().push(request.0);
})
});
let set = |cx: &mut gpui::VisualTestContext, state, show_banner| {
chat.update(cx, |chat, cx| {
chat.set_update(
crate::update::Snapshot {
state,
label: "v0.0.14 is ready to install".into(),
version: Some("0.0.14".into()),
remote_candidate: None,
primary: Some(crate::update::UpdateAction::Restart),
primary_label: "Restart to Update",
manual_download_url: None,
show_banner,
},
cx,
)
});
cx.update(|window, _| window.refresh());
cx.run_until_parked();
};

assert!(cx.debug_bounds("settings-update-badge").is_none());
press(cx, "open-settings");
assert!(cx.debug_bounds("settings-menu-update").is_none());
press(cx, "open-settings");

set(cx, crate::update::State::Ready, true);
let badge = cx.debug_bounds("settings-update-badge").expect("badge");
let settings = cx.debug_bounds("open-settings").expect("settings button");
assert!(
badge.left() > settings.left() + px(40.) && badge.right() < settings.center().x,
"the badge follows the Settings label: {badge:?}, {settings:?}"
);
assert!(badge.top() >= settings.top() && badge.bottom() <= settings.bottom());
press(cx, "open-settings");
assert!(
cx.debug_bounds("settings-menu-update").unwrap().top()
< cx.debug_bounds("settings-menu-account").unwrap().top()
);
press(cx, "settings-menu-update");
assert!(cx.debug_bounds("settings-menu").is_none());
assert_eq!(*actions.borrow(), [crate::update::UpdateAction::Restart]);

set(cx, crate::update::State::Error, true);
press(cx, "open-settings");
press(cx, "settings-menu-update");
assert_eq!(*sections.borrow(), [Section::About]);
assert_eq!(actions.borrow().len(), 1);

// "Later" in About hides both until the next release.
set(cx, crate::update::State::Ready, false);
assert!(cx.debug_bounds("settings-update-badge").is_none());
press(cx, "open-settings");
assert!(cx.debug_bounds("settings-menu-update").is_none());
}

#[gpui::test]
Expand Down
Loading
Loading