From 1971b2a4ea803719897211e41a4c873e716cf6cb Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:15:25 +0000 Subject: [PATCH 1/2] feat(agent): move the update notice to a sidebar footer button Replace the new-chat update banner with a round download button left of Settings in the sidebar footer. A ready update opens the existing restart confirmation, whose title now names the version; other update states open About, which shows the full message and actions. Co-Authored-By: Claude Opus 5.5 --- .../app/assets/icons/circle-alert.svg | 1 + .../maple-agent/app/assets/icons/download.svg | 1 + apps/maple-agent/app/src/assets.rs | 2 + apps/maple-agent/app/src/desktop.rs | 13 +- apps/maple-agent/app/src/ui/chat/mod.rs | 141 ++++++++---------- apps/maple-agent/app/src/ui/chat/tests.rs | 80 +++++----- .../app/src/ui/update_confirmation.rs | 22 ++- apps/maple-agent/app/src/update.rs | 11 ++ apps/maple-agent/docs/updates.md | 8 +- 9 files changed, 151 insertions(+), 128 deletions(-) create mode 100644 apps/maple-agent/app/assets/icons/circle-alert.svg create mode 100644 apps/maple-agent/app/assets/icons/download.svg 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..2e17db5f1 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -3777,89 +3777,66 @@ impl ChatScreen { } } - fn render_update_banner(&self, cx: &mut Context) -> Option
{ + /// The sidebar footer's update button, shown only when the updater has + /// something for the user. A ready update opens the restart + /// confirmation; anything else (manual download, finishing an install, + /// a failure) opens About, which shows the full message and actions. + fn render_update_button(&self, cx: &mut Context) -> Option> { let snapshot = &self.update; if !snapshot.show_banner { return None; } - let action = snapshot.primary; + let failed = snapshot.state == crate::update::State::Error; + let ready = snapshot.state == crate::update::State::Ready; + let label = if ready { + snapshot.version.as_ref().map_or_else( + || snapshot.label.clone(), + |version| format!("Update to v{version}"), + ) + } else { + snapshot.label.clone() + }; Some( div() - .debug_selector(|| "update-banner".to_string()) - .w_full() - .min_w_0() + .id("update-button") + .debug_selector(|| "update-button".to_string()) + .role(gpui::Role::Button) + .aria_label(SharedString::from(label.clone())) + .flex_none() + .size(px(28.)) + .rounded_full() .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), - ) + .items_center() + .justify_center() + .when(failed, |button| { + button + .border_1() + .border_color(gpui::rgb(theme::border())) + .child(icon("circle-alert", px(16.), theme::text_muted())) + .hover(|style| { + style + .bg(gpui::rgb(theme::bg_sidebar_row_hover())) + .cursor_pointer() }) - .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"), - ), - ), + }) + .when(!failed, |button| { + button + .bg(gpui::rgb(theme::accent())) + .child(icon("download", px(15.), theme::on_accent())) + .hover(|style| style.opacity(0.9).cursor_pointer()) + }) + .focus_visible(widgets::focus_ring) + .tooltip_show_delay(widgets::ICON_TOOLTIP_DELAY) + .tooltip(widgets::tooltip(label, None)) + .on_click(cx.listener(move |_, _, _, cx| { + if ready { + cx.emit(crate::update::UpdateRequested( + crate::update::UpdateAction::Restart, + )); + } else { + cx.emit(OpenSettingsSection(Section::About)); + } + })), ) } @@ -6975,6 +6952,7 @@ impl ChatScreen { .trigger(ChatPopup::Settings, button, cx, |this, _, cx| { this.popup.toggle(ChatPopup::Settings, cx); }); + let update = self.render_update_button(cx); let mut footer = div() .absolute() .bottom_0() @@ -6984,12 +6962,22 @@ impl ChatScreen { } else { SIDEBAR_RAIL_WIDTH }) - .h(px(54.)) + // Collapsed, the update button stacks above the Settings gear. + .h(if update.is_some() && !expanded { + px(90.) + } else { + px(54.) + }) .px_2() .py(px(6.)) .border_t_1() .border_color(gpui::rgb(theme::border_subtle())) .bg(gpui::rgb(theme::bg_sidebar())) + .flex() + .when(!expanded, |footer| footer.flex_col().justify_end()) + .items_center() + .gap_1() + .children(update) .child(button); if open { let usage_label = self.sidebar_plan.as_ref().map_or_else( @@ -7301,7 +7289,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..dd6607f71 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -11019,7 +11019,7 @@ mod state_tests { ); } #[gpui::test] - fn long_update_failure_wraps_without_hiding_download_or_later(cx: &mut TestAppContext) { + fn update_button_sits_left_of_settings_in_the_sidebar_footer(cx: &mut TestAppContext) { struct ChatHost { chat: Entity, } @@ -11031,43 +11031,51 @@ mod state_tests { 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 + let backend = Arc::new( + crate::backend::AgentBackend::for_test("http://127.0.0.1:9".into(), String::new()) + .unwrap(), + ); + ChatScreen::new_without_start(backend, "user".into(), cx) }); 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:?}" - ); - } - } + cx.simulate_resize(gpui::size(px(1213.), px(800.))); + assert!(cx.debug_bounds("update-button").is_none()); + let ready = crate::update::Snapshot { + state: crate::update::State::Ready, + 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: true, + }; + chat.update(cx, |chat, cx| chat.set_update(ready.clone(), cx)); + cx.run_until_parked(); + let update = cx.debug_bounds("update-button").expect("update button"); + let settings = cx.debug_bounds("open-settings").expect("settings button"); + assert!( + update.right() <= settings.left(), + "{update:?}, {settings:?}" + ); + assert!( + update.top() >= settings.top() && update.bottom() <= settings.bottom(), + "{update:?}, {settings:?}" + ); + assert!(settings.right() <= SIDEBAR_WIDTH, "{settings:?}"); + + // "Later" in About hides the button until the next release. + chat.update(cx, |chat, cx| { + chat.set_update( + crate::update::Snapshot { + show_banner: false, + ..ready + }, + cx, + ) + }); + cx.run_until_parked(); + assert!(cx.debug_bounds("update-button").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..de32aec1b 100644 --- a/apps/maple-agent/docs/updates.md +++ b/apps/maple-agent/docs/updates.md @@ -30,9 +30,11 @@ 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 ready update shows a download +button left of Settings in the sidebar footer; it opens that confirmation, which +names the version. Other update notices on that button open About. **Later** in +About hides the button 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). From aeb02bf0eaceeab63650f9828d71982f8e0461bb Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:46:46 +0000 Subject: [PATCH 2/2] feat(agent): mark Settings with an update badge and menu row Per design review, drop the separate footer button. A pending update now shows a download badge after the Settings label (a dot on the gear when the sidebar is collapsed), and the Settings menu gains an update row under Usage. The row opens the restart confirmation for a ready update and About for other notices. Co-Authored-By: Claude Opus 5.5 --- apps/maple-agent/app/src/ui/chat/mod.rs | 162 ++++++++++++---------- apps/maple-agent/app/src/ui/chat/tests.rs | 116 +++++++++------- apps/maple-agent/docs/updates.md | 10 +- 3 files changed, 163 insertions(+), 125 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 2e17db5f1..94e36f9e9 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -3777,67 +3777,31 @@ impl ChatScreen { } } - /// The sidebar footer's update button, shown only when the updater has - /// something for the user. A ready update opens the restart - /// confirmation; anything else (manual download, finishing an install, - /// a failure) opens About, which shows the full message and actions. - fn render_update_button(&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 failed = snapshot.state == crate::update::State::Error; - let ready = snapshot.state == crate::update::State::Ready; - let label = if ready { - snapshot.version.as_ref().map_or_else( - || snapshot.label.clone(), - |version| format!("Update to v{version}"), - ) + 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 { - snapshot.label.clone() - }; - Some( - div() - .id("update-button") - .debug_selector(|| "update-button".to_string()) - .role(gpui::Role::Button) - .aria_label(SharedString::from(label.clone())) - .flex_none() - .size(px(28.)) - .rounded_full() - .flex() - .items_center() - .justify_center() - .when(failed, |button| { - button - .border_1() - .border_color(gpui::rgb(theme::border())) - .child(icon("circle-alert", px(16.), theme::text_muted())) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() - }) - }) - .when(!failed, |button| { - button - .bg(gpui::rgb(theme::accent())) - .child(icon("download", px(15.), theme::on_accent())) - .hover(|style| style.opacity(0.9).cursor_pointer()) - }) - .focus_visible(widgets::focus_ring) - .tooltip_show_delay(widgets::ICON_TOOLTIP_DELAY) - .tooltip(widgets::tooltip(label, None)) - .on_click(cx.listener(move |_, _, _, cx| { - if ready { - cx.emit(crate::update::UpdateRequested( - crate::update::UpdateAction::Restart, - )); - } else { - cx.emit(OpenSettingsSection(Section::About)); - } - })), - ) + cx.emit(OpenSettingsSection(Section::About)); + } } /// Keep a sent prompt for Up/Down recall. Repeats move to the end. @@ -6579,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 @@ -6917,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); @@ -6933,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( @@ -6952,7 +6968,6 @@ impl ChatScreen { .trigger(ChatPopup::Settings, button, cx, |this, _, cx| { this.popup.toggle(ChatPopup::Settings, cx); }); - let update = self.render_update_button(cx); let mut footer = div() .absolute() .bottom_0() @@ -6962,22 +6977,12 @@ impl ChatScreen { } else { SIDEBAR_RAIL_WIDTH }) - // Collapsed, the update button stacks above the Settings gear. - .h(if update.is_some() && !expanded { - px(90.) - } else { - px(54.) - }) + .h(px(54.)) .px_2() .py(px(6.)) .border_t_1() .border_color(gpui::rgb(theme::border_subtle())) .bg(gpui::rgb(theme::bg_sidebar())) - .flex() - .when(!expanded, |footer| footer.flex_col().justify_end()) - .items_center() - .gap_1() - .children(update) .child(button); if open { let usage_label = self.sidebar_plan.as_ref().map_or_else( @@ -7063,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)); diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index dd6607f71..d9fc3f56e 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -11019,63 +11019,79 @@ mod state_tests { ); } #[gpui::test] - fn update_button_sits_left_of_settings_in_the_sidebar_footer(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(), - ); - ChatScreen::new_without_start(backend, "user".into(), cx) - }); - let (_host, cx) = cx.add_window_view(|_, _| ChatHost { chat: chat.clone() }); - cx.simulate_resize(gpui::size(px(1213.), px(800.))); - assert!(cx.debug_bounds("update-button").is_none()); - let ready = crate::update::Snapshot { - state: crate::update::State::Ready, - 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: true, + 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 _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(); }; - chat.update(cx, |chat, cx| chat.set_update(ready.clone(), cx)); - cx.run_until_parked(); - let update = cx.debug_bounds("update-button").expect("update button"); + + 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!( - update.right() <= settings.left(), - "{update:?}, {settings:?}" + 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!( - update.top() >= settings.top() && update.bottom() <= settings.bottom(), - "{update:?}, {settings:?}" + cx.debug_bounds("settings-menu-update").unwrap().top() + < cx.debug_bounds("settings-menu-account").unwrap().top() ); - assert!(settings.right() <= SIDEBAR_WIDTH, "{settings:?}"); + press(cx, "settings-menu-update"); + assert!(cx.debug_bounds("settings-menu").is_none()); + assert_eq!(*actions.borrow(), [crate::update::UpdateAction::Restart]); - // "Later" in About hides the button until the next release. - chat.update(cx, |chat, cx| { - chat.set_update( - crate::update::Snapshot { - show_banner: false, - ..ready - }, - cx, - ) - }); - cx.run_until_parked(); - assert!(cx.debug_bounds("update-button").is_none()); + 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/docs/updates.md b/apps/maple-agent/docs/updates.md index de32aec1b..3df6c9fcc 100644 --- a/apps/maple-agent/docs/updates.md +++ b/apps/maple-agent/docs/updates.md @@ -30,10 +30,12 @@ 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. A ready update shows a download -button left of Settings in the sidebar footer; it opens that confirmation, which -names the version. Other update notices on that button open About. **Later** in -About hides the button while keeping the prepared update available there. About, +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