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
12 changes: 12 additions & 0 deletions apps/maple-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
76 changes: 76 additions & 0 deletions apps/maple-agent/app/src/feedback.rs
Original file line number Diff line number Diff line change
@@ -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<Self, String> {
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());
}
}
2 changes: 2 additions & 0 deletions apps/maple-agent/app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
220 changes: 220 additions & 0 deletions apps/maple-agent/app/src/ui/chat/feedback.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
//! 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<TextInput>,
category: &'static str,
request: Option<Submission>,
busy: bool,
error: Option<String>,
focus_pending: bool,
controls: Vec<gpui::FocusHandle>,
}
impl ChatScreen {
pub(super) fn open_feedback(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) -> Option<gpui::Stateful<Div>> {
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)
.aria_label(label)
.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").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").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)))))
))
}
}
Loading
Loading