From 619789a3b80751e24e28f2ced163cc2d768dacc3 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:22:25 +0000 Subject: [PATCH 1/2] Add anonymous Maple Work feedback form and Rust client --- apps/maple-agent/README.md | 12 + apps/maple-agent/app/src/feedback.rs | 76 +++++++ apps/maple-agent/app/src/main.rs | 2 + apps/maple-agent/app/src/ui/chat/feedback.rs | 219 +++++++++++++++++++ apps/maple-agent/app/src/ui/chat/mod.rs | 34 ++- apps/maple-agent/app/src/ui/chat/tests.rs | 27 +++ 6 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 apps/maple-agent/app/src/feedback.rs create mode 100644 apps/maple-agent/app/src/ui/chat/feedback.rs diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index d887e7bea..c1eef9d67 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -678,3 +678,15 @@ See [remote access](docs/remote-access.md) for pairing, host selection, recovery local-only account controls and the separate infrastructure/native trial gates. Remote access is off by default; the initial development configuration has no implicit public-relay fallback. + +### Feedback + +The quiet **Send feedback** button at the bottom of the expanded sidebar opens +an anonymous form. It sends only the chosen category, message, app version, +platform and build profile to the shared `/v1/feedback` service. Dev and Prod +use the same endpoint. No account, conversation, logs or attachments are added. +The Rust client is owned by this product (`app/src/feedback.rs`); it has no +submission credential. Failed submissions retain the draft, and an unchanged +retry reuses its random submission ID to avoid duplicate rows. Shift+Enter adds +a line; Escape or Cancel closes the form when no submission is in flight. +Feedback is conventional Cloudflare-hosted storage; avoid sensitive content. diff --git a/apps/maple-agent/app/src/feedback.rs b/apps/maple-agent/app/src/feedback.rs new file mode 100644 index 000000000..e649cb039 --- /dev/null +++ b/apps/maple-agent/app/src/feedback.rs @@ -0,0 +1,76 @@ +//! Anonymous feedback for Maple Work only. No account or conversation data. +use std::time::Duration; + +use serde::Serialize; + +pub const ENDPOINT: &str = "https://maple-feedback.mutiny-waitlist.workers.dev/v1/feedback"; +pub const MAX_MESSAGE: usize = 5000; + +#[derive(Clone, Serialize, PartialEq, Debug)] +pub struct Submission { + pub id: String, + pub category: String, + pub message: String, + pub app_version: &'static str, + pub platform: &'static str, + pub build_profile: &'static str, +} + +impl Submission { + pub fn new(category: &str, message: &str) -> Result { + if !["bug", "idea", "other"].contains(&category) { + return Err("Choose a feedback category.".into()); + } + let message = message.trim(); + if message.is_empty() || message.encode_utf16().count() > MAX_MESSAGE { + return Err("Please enter between 1 and 5,000 characters.".into()); + } + Ok(Self { + id: uuid::Uuid::new_v4().to_string(), + category: category.into(), + message: message.into(), + app_version: env!("CARGO_PKG_VERSION"), + platform: std::env::consts::OS, + build_profile: crate::profile::PROFILE, + }) + } +} + +pub async fn submit(submission: &Submission) -> Result<(), String> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(15)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| "Could not prepare feedback. Please try again.".to_string())?; + let response = client + .post(ENDPOINT) + .json(submission) + .send() + .await + .map_err(|_| { + "Could not send feedback. Your draft is here; please try again.".to_string() + })?; + match response.status().as_u16() { + 201 => Ok(()), + 429 => Err("Too many submissions. Please wait a minute and try again.".into()), + _ => Err("Could not send feedback. Your draft is here; please try again.".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn submission_is_bounded_and_contains_no_identity() { + assert!(Submission::new("other", " ").is_err()); + assert!(Submission::new("other", &"x".repeat(5001)).is_err()); + assert!(Submission::new("other", &"šŸ˜€".repeat(2501)).is_err()); + assert!(Submission::new("unknown", "hello").is_err()); + let value = serde_json::to_value(Submission::new("idea", " hello ").unwrap()).unwrap(); + let object = value.as_object().unwrap(); + assert_eq!(object.len(), 6); + assert_eq!(value["message"], "hello"); + assert!(uuid::Uuid::parse_str(value["id"].as_str().unwrap()).is_ok()); + } +} diff --git a/apps/maple-agent/app/src/main.rs b/apps/maple-agent/app/src/main.rs index 8653045d3..406832645 100644 --- a/apps/maple-agent/app/src/main.rs +++ b/apps/maple-agent/app/src/main.rs @@ -11,6 +11,8 @@ mod billing; #[cfg(feature = "desktop")] mod desktop; mod env; +#[cfg(feature = "desktop")] +mod feedback; mod framing; mod host; mod hosted_oauth; diff --git a/apps/maple-agent/app/src/ui/chat/feedback.rs b/apps/maple-agent/app/src/ui/chat/feedback.rs new file mode 100644 index 000000000..0b630c0d4 --- /dev/null +++ b/apps/maple-agent/app/src/ui/chat/feedback.rs @@ -0,0 +1,219 @@ +//! Explicitly submitted feedback. Drafts never enter chat history. +use super::ChatScreen; +use crate::feedback::{self, Submission}; +use crate::ui::{text_input::TextInput, theme, widgets}; +use gpui::{Context, Div, Entity, Focusable, Window, div, prelude::*, px}; + +pub(super) struct FeedbackForm { + input: Entity, + category: &'static str, + request: Option, + busy: bool, + error: Option, + focus_pending: bool, + controls: Vec, +} +impl ChatScreen { + pub(super) fn open_feedback(&mut self, window: &mut Window, cx: &mut Context) { + if self.feedback.is_some() { + return; + } + self.popup.close(cx); + self.sidebar + .update(cx, |sidebar, cx| sidebar.close_popups(cx)); + let owner = cx.entity().downgrade(); + let input = cx.new(|cx| { + TextInput::new("What would you like us to know?", cx) + .multiline(8) + .with_tab_index(0) + .on_key(move |event, _, window, cx| { + if !matches!(event.keystroke.key.as_str(), "escape" | "tab") { + return false; + } + let owner = owner.clone(); + let event = event.clone(); + window.defer(cx, move |window, cx| { + owner + .update(cx, |this, cx| { + if event.keystroke.key == "escape" { + this.close_feedback(cx); + } else { + this.feedback_tab(event.keystroke.modifiers.shift, window, cx); + } + }) + .ok(); + }); + true + }) + }); + cx.observe(&input, |_, _, cx| cx.notify()).detach(); + window.focus(&input.read(cx).focus_handle(cx), cx); + let mut controls = vec![input.read(cx).focus_handle(cx)]; + controls.extend((1..6).map(|index| cx.focus_handle().tab_index(index).tab_stop(true))); + self.feedback = Some(FeedbackForm { + input, + category: "other", + request: None, + busy: false, + error: None, + focus_pending: true, + controls, + }); + cx.notify(); + } + pub(super) fn close_feedback(&mut self, cx: &mut Context) { + if self.feedback.as_ref().is_some_and(|f| f.busy) { + return; + } + self.feedback = None; + self.screen_focus_pending = true; + cx.notify(); + } + pub(super) fn focus_feedback(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(form) = self.feedback.as_mut() + && form.focus_pending + { + form.focus_pending = false; + window.focus(&form.input.read(cx).focus_handle(cx), cx); + } + } + fn feedback_tab(&self, backwards: bool, window: &mut Window, cx: &mut Context) { + let Some(form) = self.feedback.as_ref().filter(|form| !form.busy) else { + return; + }; + let current = window.focused(cx); + let index = form + .controls + .iter() + .position(|handle| Some(handle) == current.as_ref()) + .unwrap_or(0); + let next = if backwards { + (index + form.controls.len() - 1) % form.controls.len() + } else { + (index + 1) % form.controls.len() + }; + window.focus(&form.controls[next], cx); + } + fn submit_feedback(&mut self, cx: &mut Context) { + let Some(form) = self.feedback.as_mut() else { + return; + }; + if form.busy { + return; + } + let candidate = match Submission::new(form.category, &form.input.read(cx).text()) { + Ok(candidate) => candidate, + Err(error) => { + form.error = Some(error); + form.focus_pending = true; + cx.notify(); + return; + } + }; + let request = form + .request + .as_ref() + .filter(|old| old.message == candidate.message && old.category == candidate.category) + .cloned() + .unwrap_or(candidate); + form.request = Some(request.clone()); + form.busy = true; + form.error = None; + self.dialog_focus.get_or_insert_with(|| cx.focus_handle()); + self.dialog_focus_pending = true; + cx.notify(); + self.call( + async move { feedback::submit(&request).await }, + cx, + |this, result, cx| { + if let Some(form) = this.feedback.as_mut() { + form.busy = false; + match result { + Ok(()) => { + this.feedback = None; + this.notice = Some("Thanks for your feedback!".into()); + this.screen_focus_pending = true; + } + Err(error) => { + form.error = Some(error); + form.focus_pending = true; + } + } + } + cx.notify(); + }, + ); + } + pub(super) fn render_feedback(&self, cx: &mut Context) -> Option> { + let form = self.feedback.as_ref()?; + let busy = form.busy; + let mut categories = div().flex().gap_2(); + for (index, (value, label)) in [("bug", "Bug"), ("idea", "Idea"), ("other", "Other")] + .into_iter() + .enumerate() + { + categories = categories.child( + widgets::secondary_button(value) + .track_focus(&form.controls[index + 1]) + .text_sm() + .py_1() + .when(form.category == value, |b| { + b.bg(gpui::rgb(theme::bg_sidebar_row_selected())) + }) + .child(label) + .on_key_down(cx.listener(move |this, event: &gpui::KeyDownEvent, _, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + if let Some(form) = this.feedback.as_mut().filter(|f| !f.busy) { + form.category = value; + cx.notify(); + } + cx.stop_propagation(); + } + })) + .on_click(cx.listener(move |this, _, _, cx| { + if let Some(form) = this.feedback.as_mut().filter(|f| !f.busy) { + form.category = value; + cx.notify(); + } + })), + ); + } + let length = form.input.read(cx).text_ref().encode_utf16().count(); + Some(div().id("feedback-backdrop").absolute().inset_0().occlude() + .bg(theme::scrim()).flex().items_center().justify_center() + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child(div().id("feedback-form").role(gpui::Role::Dialog).aria_label("Send feedback") + .key_context("Dialog") + .when_some(self.dialog_focus.clone(), |card, focus| card.track_focus(&focus)) + .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, window, cx| { + match event.keystroke.key.as_str() { + "escape" => { this.close_feedback(cx); cx.stop_propagation(); } + "tab" => { this.feedback_tab(event.keystroke.modifiers.shift, window, cx); cx.stop_propagation(); } + _ => {} + } + })) + .w(px(460.)).max_w(gpui::relative(0.95)).max_h(gpui::relative(0.9)) + .overflow_y_scroll().p_5().rounded(theme::RADIUS_XL).shadow_lg() + .bg(gpui::rgb(theme::bg_elevated())).border_1().border_color(gpui::rgb(theme::border())) + .flex().flex_col().gap_3().text_color(gpui::rgb(theme::text_primary())) + .child(div().text_lg().child("Send feedback")) + .child(categories) + .child(widgets::input_frame().min_h(px(120.)).child( + if busy { div().text_sm().child("Sending your feedback…").into_any_element() } + else { form.input.clone().into_any_element() } + )) + .child(div().text_xs().text_color(gpui::rgb(theme::text_secondary())).child(format!("{length} / 5,000 Ā· Shift+Enter for a new line"))) + .child(div().text_xs().text_color(gpui::rgb(theme::text_secondary())).child("Sent anonymously with app version, platform and build profile. No account or chat history is attached. Please avoid sharing sensitive information.")) + .when_some(form.error.clone(), |card, error| card.child(widgets::banner(theme::status_error()).child(error))) + .child(div().flex().justify_end().gap_2() + .child(widgets::secondary_button("feedback-cancel").track_focus(&form.controls[4]).child("Cancel") + .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, _, cx| { if matches!(event.keystroke.key.as_str(), "enter" | "space") { this.close_feedback(cx); cx.stop_propagation(); } })) + .when(busy, |b| b.opacity(0.5)) + .on_click(cx.listener(|this, _, _, cx| this.close_feedback(cx)))) + .child(widgets::primary_button("feedback-submit").debug_selector(|| "feedback-submit".into()).track_focus(&form.controls[5]) + .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, _, cx| { if matches!(event.keystroke.key.as_str(), "enter" | "space") { this.submit_feedback(cx); cx.stop_propagation(); } })) + .when(busy, |b| b.opacity(0.5)).child(if busy { "Sending…" } else { "Send feedback" }) + .on_click(cx.listener(|this, _, _, cx| this.submit_feedback(cx))))) + )) + } +} diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 0aa45dcb5..e5b14592a 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -37,6 +37,7 @@ pub(crate) mod computers; use computers::{Computer, Destination, Navigate, NavigationChanged}; mod attachments; mod dialogs; +mod feedback; mod navigation; mod paging; mod project_folder; @@ -576,6 +577,7 @@ pub struct ChatScreen { dialog_focus: Option, /// A dialog just opened; the next render moves focus into it. dialog_focus_pending: bool, + feedback: Option, /// Whether the project-trust question may open its dialog. Tests that /// drive typing turn it off, since the dialog rightly takes focus. trust_prompts: bool, @@ -1343,6 +1345,7 @@ impl ChatScreen { popup: Popup::new(|this| &mut this.popup, cx), dialog_focus: None, dialog_focus_pending: false, + feedback: None, trust_prompts: true, sidebar_collapsed: false, sidebar_transition: None, @@ -4127,6 +4130,10 @@ impl ChatScreen { } fn escape(&mut self, cx: &mut Context) { + if self.feedback.is_some() { + self.close_feedback(cx); + return; + } // A rename ends first. A project's rename field sits in the // switcher, which passes Escape on and stays open. if self @@ -4235,7 +4242,7 @@ impl ChatScreen { return; } // A modal dialog owns the keyboard; nothing types past it. - if self.trust_prompt.is_some() || self.confirm.is_some() { + if self.trust_prompt.is_some() || self.confirm.is_some() || self.feedback.is_some() { return; } // A focused text field already receives typing: the composer, the @@ -6558,6 +6565,8 @@ impl Render for ChatScreen { window.focus(&handle, cx); } } + self.focus_feedback(window, cx); + let feedback = self.render_feedback(cx); let confirm = self .confirm .clone() @@ -6773,6 +6782,7 @@ impl Render for ChatScreen { )) }) .children(confirm) + .children(feedback) .children(trust_prompt) .children(self.project_folder_dialog.clone().map(|dialog| { dialog.cached( @@ -6821,6 +6831,28 @@ impl ChatScreen { ), ) }) + .when(expansion > 0.95, |column| { + column.child( + widgets::ghost_button("send-feedback") + .debug_selector(|| "send-feedback".to_string()) + .absolute() + .bottom_2() + .right_2() + .text_xs() + .text_color(gpui::rgb(theme::text_secondary())) + .tab_index(7) + .child("Send feedback") + .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, window, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + this.open_feedback(window, cx); + cx.stop_propagation(); + } + })) + .on_click( + cx.listener(|this, _, window, cx| this.open_feedback(window, cx)), + ), + ) + }) // Keep one opaque Settings control outside the fading sidebar. .child( div() diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 83976fe18..76857f10d 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -5821,6 +5821,33 @@ mod state_tests { cx.simulate_click(bounds.center(), gpui::Modifiers::default()); } + #[gpui::test] + fn feedback_form_keeps_typing_out_of_chat_and_rejects_empty_submission( + cx: &mut TestAppContext, + ) { + let (chat, cx) = chat_window(cx, |_, _| {}); + let composer = chat.update(cx, |this, _| this.composer.clone().expect("composer")); + composer.update(cx, |input, cx| input.set_text("Unsent chat draft", cx)); + press(cx, "send-feedback"); + press(cx, "feedback-submit"); + assert!(chat.update(cx, |this, _| this.feedback.is_some())); + cx.run_until_parked(); + let input_focus = cx.update(|window, app| window.focused(app)); + cx.simulate_keystrokes("tab tab tab tab tab tab"); + assert_eq!(cx.update(|window, app| window.focused(app)), input_focus); + cx.simulate_input("Feedback draft, not a chat message"); + assert_eq!( + composer.update(cx, |input, _| input.text()), + "Unsent chat draft" + ); + cx.simulate_keystrokes("escape"); + assert!(chat.update(cx, |this, _| this.feedback.is_none())); + assert_eq!( + composer.update(cx, |input, _| input.text()), + "Unsent chat draft" + ); + } + #[gpui::test] fn unsaved_local_work_has_a_retry_beside_both_chat_composers(cx: &mut TestAppContext) { use crate::ui::store_gate::RetryStorePersistence; From d7eb53dd3872431865f84405cb1449ae7eed13cf Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:25:47 +0000 Subject: [PATCH 2/2] Label feedback controls for accessibility --- apps/maple-agent/app/src/ui/chat/feedback.rs | 5 +++-- apps/maple-agent/app/src/ui/chat/mod.rs | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/feedback.rs b/apps/maple-agent/app/src/ui/chat/feedback.rs index 0b630c0d4..229a71de8 100644 --- a/apps/maple-agent/app/src/ui/chat/feedback.rs +++ b/apps/maple-agent/app/src/ui/chat/feedback.rs @@ -154,6 +154,7 @@ impl ChatScreen { { categories = categories.child( widgets::secondary_button(value) + .aria_label(label) .track_focus(&form.controls[index + 1]) .text_sm() .py_1() @@ -206,11 +207,11 @@ impl ChatScreen { .child(div().text_xs().text_color(gpui::rgb(theme::text_secondary())).child("Sent anonymously with app version, platform and build profile. No account or chat history is attached. Please avoid sharing sensitive information.")) .when_some(form.error.clone(), |card, error| card.child(widgets::banner(theme::status_error()).child(error))) .child(div().flex().justify_end().gap_2() - .child(widgets::secondary_button("feedback-cancel").track_focus(&form.controls[4]).child("Cancel") + .child(widgets::secondary_button("feedback-cancel").aria_label("Cancel feedback").track_focus(&form.controls[4]).child("Cancel") .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, _, cx| { if matches!(event.keystroke.key.as_str(), "enter" | "space") { this.close_feedback(cx); cx.stop_propagation(); } })) .when(busy, |b| b.opacity(0.5)) .on_click(cx.listener(|this, _, _, cx| this.close_feedback(cx)))) - .child(widgets::primary_button("feedback-submit").debug_selector(|| "feedback-submit".into()).track_focus(&form.controls[5]) + .child(widgets::primary_button("feedback-submit").aria_label("Send feedback").debug_selector(|| "feedback-submit".into()).track_focus(&form.controls[5]) .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, _, cx| { if matches!(event.keystroke.key.as_str(), "enter" | "space") { this.submit_feedback(cx); cx.stop_propagation(); } })) .when(busy, |b| b.opacity(0.5)).child(if busy { "Sending…" } else { "Send feedback" }) .on_click(cx.listener(|this, _, _, cx| this.submit_feedback(cx))))) diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index e5b14592a..5de498e99 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -6834,6 +6834,7 @@ impl ChatScreen { .when(expansion > 0.95, |column| { column.child( widgets::ghost_button("send-feedback") + .aria_label("Send feedback") .debug_selector(|| "send-feedback".to_string()) .absolute() .bottom_2()