diff --git a/apps/maple-agent/app/assets/icons/circle-alert.svg b/apps/maple-agent/app/assets/icons/circle-alert.svg new file mode 100644 index 000000000..1379cdc70 --- /dev/null +++ b/apps/maple-agent/app/assets/icons/circle-alert.svg @@ -0,0 +1 @@ + diff --git a/apps/maple-agent/app/assets/icons/download.svg b/apps/maple-agent/app/assets/icons/download.svg new file mode 100644 index 000000000..fb3bf3e76 --- /dev/null +++ b/apps/maple-agent/app/assets/icons/download.svg @@ -0,0 +1 @@ + diff --git a/apps/maple-agent/app/src/assets.rs b/apps/maple-agent/app/src/assets.rs index 2d5ebf4ac..3c2bdfb2c 100644 --- a/apps/maple-agent/app/src/assets.rs +++ b/apps/maple-agent/app/src/assets.rs @@ -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", diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 26904658b..eeaa7f31d 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -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(); diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index a957647a9..94e36f9e9 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -3777,90 +3777,31 @@ impl ChatScreen { } } - fn render_update_banner(&self, cx: &mut Context) -> Option
{ + /// 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) { + 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. @@ -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) -> impl IntoElement { // Refreshed every frame; activation changes force a redraw, so @@ -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); @@ -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( @@ -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)); @@ -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() diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 37298edaa..d9fc3f56e 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -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, - } - impl Render for ChatHost { - fn render(&mut self, _: &mut Window, _: &mut Context) -> 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] diff --git a/apps/maple-agent/app/src/ui/update_confirmation.rs b/apps/maple-agent/app/src/ui/update_confirmation.rs index c2a205563..2a74d1e9e 100644 --- a/apps/maple-agent/app/src/ui/update_confirmation.rs +++ b/apps/maple-agent/app/src/ui/update_confirmation.rs @@ -1,15 +1,13 @@ //! Bounded Linux updater confirmation. The platform's fallback prompt does not //! constrain its detail text; keep this dialog in the owning desktop instead. -use gpui::{App, Context, Entity, FocusHandle, Render, Window, div, prelude::*, px}; +use gpui::{App, Context, Entity, FocusHandle, Render, SharedString, Window, div, prelude::*, px}; use tokio::sync::oneshot; use super::{theme, widgets}; -pub(crate) const TITLE: &str = "Restart Maple to update?"; -pub(crate) const DETAIL: &str = "This stops running tasks and remote access on this computer. Editors using Maple must reconnect after the update."; - pub(crate) struct UpdateConfirmation { + title: SharedString, answer: Option>, previous_focus: Option, later_focus: FocusHandle, @@ -17,12 +15,14 @@ pub(crate) struct UpdateConfirmation { } pub(crate) fn open( + title: String, window: &mut Window, cx: &mut App, ) -> (Entity, oneshot::Receiver) { let (sender, receiver) = oneshot::channel(); let previous_focus = window.focused(cx); let view = cx.new(|cx| UpdateConfirmation { + title: title.into(), answer: Some(sender), previous_focus, later_focus: cx.focus_handle(), @@ -67,7 +67,7 @@ impl Render for UpdateConfirmation { .id("update-confirmation") .debug_selector(|| "update-confirmation".to_string()) .role(gpui::Role::Dialog) - .aria_label(TITLE) + .aria_label(self.title.clone()) .key_context("Dialog") .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, window, cx| { match event.keystroke.key.as_str() { @@ -103,7 +103,13 @@ impl Render for UpdateConfirmation { .flex_col() .gap_4() .text_color(gpui::rgb(theme::text_primary())) - .child(div().w_full().flex_shrink_0().text_lg().child(TITLE)) + .child( + div() + .w_full() + .flex_shrink_0() + .text_lg() + .child(self.title.clone()), + ) .child( div() .id("update-confirmation-body") @@ -113,7 +119,7 @@ impl Render for UpdateConfirmation { .min_h_0() .overflow_y_scroll() .text_sm() - .child(DETAIL), + .child(crate::update::RESTART_DETAIL), ) .child( div() @@ -173,7 +179,7 @@ mod tests { let answer = Rc::new(RefCell::new(None)); let result = answer.clone(); let (host, cx) = cx.add_window_view(move |window, cx| { - let (prompt, receiver) = open(window, cx); + let (prompt, receiver) = open(crate::update::restart_title(Some("0.0.14")), window, cx); *result.borrow_mut() = Some(receiver); PromptHost { prompt: Some(prompt), diff --git a/apps/maple-agent/app/src/update.rs b/apps/maple-agent/app/src/update.rs index d2c34e64a..4ecf89615 100644 --- a/apps/maple-agent/app/src/update.rs +++ b/apps/maple-agent/app/src/update.rs @@ -43,6 +43,17 @@ pub enum UpdateAction { AutomaticDownloads(bool), } +/// Title of the restart confirmation. It names the version, since the +/// sidebar's update button shows only an icon. +pub fn restart_title(version: Option<&str>) -> String { + version.map_or_else( + || "Restart Maple to update?".to_string(), + |version| format!("Restart Maple to install v{version}?"), + ) +} + +pub const RESTART_DETAIL: &str = "This stops running tasks and remote access on this computer. Editors using Maple must reconnect after the update."; + #[derive(Clone, Debug, PartialEq, Eq)] pub struct Snapshot { pub state: State, diff --git a/apps/maple-agent/docs/updates.md b/apps/maple-agent/docs/updates.md index 78ca3b0d1..3df6c9fcc 100644 --- a/apps/maple-agent/docs/updates.md +++ b/apps/maple-agent/docs/updates.md @@ -30,9 +30,13 @@ latest Release. The desktop checks at startup and about hourly, with jitter. Automatic download is enabled by default and can be disabled in Settings. An authenticated update is downloaded in the background, but replacement requires **Restart to Update** -and confirmation that active work will stop. **Later** hides the banner while -keeping the prepared update available in Settings. About, the native application -menu, and the tray provide update access. +and confirmation that active work will stop. A pending update marks the sidebar's +Settings button with a download badge (a dot when the sidebar is collapsed), and +its menu gains an update row. For a ready update that row opens the +confirmation, which names the version; other update notices open About. +**Later** in About hides the badge and row while keeping the prepared update +available there. About, +the native application menu, and the tray provide update access. Paired controllers can inspect a computer's version and prepared update through the optional [experimental remote update controls](remote-access.md#experimental-remote-updates).