From a6ccb87d90e83ec9c7c7a8ab911ad14321a5fd44 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:49:02 -0700 Subject: [PATCH 1/6] feat(feedback): open hosted reports with safe diagnostic metadata --- crates/app/src/app_menus.rs | 14 +- crates/app/src/logging.rs | 67 ++++- crates/app/src/main.rs | 18 +- crates/app/src/title_items/launcher_bar.rs | 24 +- crates/app/src/title_items/mod.rs | 17 ++ docs/dev/feedback-reporting-options.md | 332 +++++++++++++++++++++ 6 files changed, 458 insertions(+), 14 deletions(-) create mode 100644 docs/dev/feedback-reporting-options.md diff --git a/crates/app/src/app_menus.rs b/crates/app/src/app_menus.rs index ecaeac1f..0ad94249 100644 --- a/crates/app/src/app_menus.rs +++ b/crates/app/src/app_menus.rs @@ -24,7 +24,9 @@ actions!( ReloadPlugins, Quit, CopyDebugInfo, - ReportIssue, + ReportBug, + RequestFeature, + ReportUxIssue, OpenLogsFolder, OpenAbout, /// What every greyed-out menu item below dispatches. Deliberately unhandled: the label is @@ -225,7 +227,15 @@ fn app_menus(cx: &gpui::App) -> Vec { // Unit actions, not `OpenBrowser { url }`: this runs once at startup, while the // debug info those items carry changes with every project opened and plugin loaded. MenuItem::action("Copy Debug Info", CopyDebugInfo), - MenuItem::action("Report an Issue", ReportIssue), + MenuItem::submenu(Menu { + name: "Send Feedback".into(), + disabled: false, + items: vec![ + MenuItem::action("Report a Bug…", ReportBug), + MenuItem::action("Request a Feature…", RequestFeature), + MenuItem::action("Report a UI/UX Issue…", ReportUxIssue), + ], + }), MenuItem::action("Open Logs Folder", OpenLogsFolder), MenuItem::Separator, MenuItem::action(format!("Version {}", env!("CARGO_PKG_VERSION")), OpenAbout), diff --git a/crates/app/src/logging.rs b/crates/app/src/logging.rs index 4aa2c77f..20ee91b7 100644 --- a/crates/app/src/logging.rs +++ b/crates/app/src/logging.rs @@ -1,4 +1,4 @@ -//! The session log, the panic hook, and the debug-info dump behind Help ▸ Copy Debug Info. +//! The session log, panic hook, and reports behind Help ▸ Copy Debug Info and Send Feedback. //! //! Everything here shares one thing — the path to the current session's log file — which is why //! the logger, the panic hook, the log tail, and the dump that embeds it live in one module. @@ -22,6 +22,12 @@ use simplelog::{ ColorChoice, CombinedLogger, ConfigBuilder, LevelFilter, TermLogger, TerminalMode, WriteLogger, }; +pub enum FeedbackKind { + Bug, + Feature, + Ux, +} + /// Where the current session writes. `None` only if the OS has no local data dir, in which case /// there is nowhere to log and the terminal sink is all there is. pub fn log_path() -> Option { @@ -195,8 +201,7 @@ pub fn debug_info(cx: &App, log_lines: usize) -> String { Some(project) => { let _ = writeln!( out, - "\nProject: {} ({} rows x {} columns)", - project.file.display(), + "\nProject: open ({} rows x {} columns)", project.data.rows.len(), project.data.headers.len() ); @@ -268,6 +273,41 @@ pub fn urlencode(text: &str) -> String { }) } +/// Open the hosted form with diagnostics in the fragment, which is not sent in the page request. +/// +/// The page removes the fragment after reading it and shows the exact text before submission. +pub fn feedback_url(cx: &App, kind: Option) -> String { + let kind = match kind { + Some(FeedbackKind::Bug) => "?type=bug", + Some(FeedbackKind::Feature) => "?type=feature", + Some(FeedbackKind::Ux) => "?type=ui_ux", + None => "", + }; + let plugins = plugin_host::status(cx); + let project = cx + .try_global::() + .map(|project| { + serde_json::json!({ + "rows": project.data.rows.len(), + "columns": project.data.headers.len(), + }) + }); + let diagnostics = serde_json::json!({ + "schema": 1, + "version": version(), + "commit": env!("QRATE_GIT_SHA"), + "os": std::env::consts::OS, + "arch": std::env::consts::ARCH, + "project": project, + "plugins": plugins.len(), + "plugin_failures": plugins.iter().filter(|(_, error)| error.is_some()).count(), + }); + format!( + "https://qrate.dvnl.work/feedback{kind}#diagnostics={}", + urlencode(&diagnostics.to_string()) + ) +} + /// The path Help ▸ Open Logs Folder reveals, or the folder itself if this session never opened a /// file to reveal. pub fn reveal_target() -> Option { @@ -294,6 +334,27 @@ mod tests { assert_eq!(super::urlencode("qrate-0.1_x.y~z"), "qrate-0.1_x.y~z"); } + #[gpui::test] + fn feedback_links_select_the_form_and_keep_diagnostics_in_the_fragment( + cx: &mut gpui::TestAppContext, + ) { + let (bug, feature, ux) = cx.update(|cx| { + ( + super::feedback_url(cx, Some(super::FeedbackKind::Bug)), + super::feedback_url(cx, Some(super::FeedbackKind::Feature)), + super::feedback_url(cx, Some(super::FeedbackKind::Ux)), + ) + }); + + assert!(bug.starts_with("https://qrate.dvnl.work/feedback?type=bug#diagnostics=")); + assert!(feature.contains("?type=feature#diagnostics=")); + assert!(ux.contains("?type=ui_ux#diagnostics=")); + assert!(bug.contains("%22schema%22%3A1")); + assert!(!bug.contains("log%20")); + assert!(!bug.contains("CPU")); + assert!(bug.len() < 7_000); + } + /// The demotion has to be narrow: it must catch gpui's two teardown messages and nothing else, /// or a real failure quietly stops being an error. #[test] diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index e13da789..affddf33 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -40,7 +40,7 @@ use crate::{ actions::{NewProject, ToggleBottomDock, ToggleLeftDock, ToggleRightDock}, app_menus::{ CopyDebugInfo, OpenAbout, OpenColumnSettings, OpenLogsFolder, OpenPluginsFolder, - OpenProjects, OpenSettings, Quit, REPO_URL, ReloadPlugins, ReportIssue, + OpenProjects, OpenSettings, Quit, ReloadPlugins, ReportBug, ReportUxIssue, RequestFeature, }, status_items::build_status_bar_registry, title_items::build_title_bar_registry, @@ -515,11 +515,17 @@ fn main() { cx.on_action(|_: &CopyDebugInfo, cx| { cx.write_to_clipboard(ClipboardItem::new_string(logging::debug_info(cx, 200))); }); - cx.on_action(|_: &ReportIssue, cx| { - // A shorter tail than the clipboard gets: GitHub stops honouring `?body=` somewhere - // past 8 KB, and a silently truncated report is worse than a short one. - let body = logging::urlencode(&logging::debug_info(cx, 30)); - cx.open_url(&format!("{REPO_URL}/issues/new?body={body}")); + cx.on_action(|_: &ReportBug, cx| { + cx.open_url(&logging::feedback_url(cx, Some(logging::FeedbackKind::Bug))); + }); + cx.on_action(|_: &RequestFeature, cx| { + cx.open_url(&logging::feedback_url( + cx, + Some(logging::FeedbackKind::Feature), + )); + }); + cx.on_action(|_: &ReportUxIssue, cx| { + cx.open_url(&logging::feedback_url(cx, Some(logging::FeedbackKind::Ux))); }); cx.on_action(|_: &OpenLogsFolder, _| match logging::reveal_target() { Some(target) => { diff --git a/crates/app/src/title_items/launcher_bar.rs b/crates/app/src/title_items/launcher_bar.rs index 62ef9f87..f025b4a8 100644 --- a/crates/app/src/title_items/launcher_bar.rs +++ b/crates/app/src/title_items/launcher_bar.rs @@ -15,7 +15,7 @@ use window_wrapper::OpenBrowser; use crate::actions::NewProject; use crate::app_menus::{ CopyDebugInfo, OpenAbout, OpenLogsFolder, OpenPluginsFolder, OpenSettings, REPO_URL, - ReloadPlugins, ReportIssue, + ReloadPlugins, ReportBug, ReportUxIssue, RequestFeature, }; use crate::title_items::update_notice::UpdateNotice; @@ -77,7 +77,11 @@ impl Render for LauncherBar { ) .separator() .menu("Copy Debug Info", Box::new(CopyDebugInfo)) - .menu("Report an Issue", Box::new(ReportIssue)) + .submenu("Send Feedback", window, cx, |menu, _, _| { + menu.menu("Report a Bug…", Box::new(ReportBug)) + .menu("Request a Feature…", Box::new(RequestFeature)) + .menu("Report a UI/UX Issue…", Box::new(ReportUxIssue)) + }) .menu("Open Logs Folder", Box::new(OpenLogsFolder)) }) .separator() @@ -91,6 +95,20 @@ impl Render for LauncherBar { ) }), ) - .child(self.update.clone()) + .child( + h_flex() + .gap_2() + .child( + Button::new("launcher-feedback") + .small() + .compact() + .primary() + .label("Feedback") + .on_click(|_, _, cx| { + cx.open_url(&crate::logging::feedback_url(cx, None)) + }), + ) + .child(self.update.clone()), + ) } } diff --git a/crates/app/src/title_items/mod.rs b/crates/app/src/title_items/mod.rs index 0af2fc86..9e1a0e04 100644 --- a/crates/app/src/title_items/mod.rs +++ b/crates/app/src/title_items/mod.rs @@ -3,6 +3,8 @@ mod update_notice; use gpui::*; use gpui_component::{ + Sizable as _, + button::{Button, ButtonVariants as _}, dock::{DockArea, DockPlacement}, menu::AppMenuBar, }; @@ -14,6 +16,19 @@ use update_notice::UpdateNotice; use window_wrapper::{BarRegistry, title_bar::TitleBarRegistry}; use workspace::DockToggleButton; +struct FeedbackButton; + +impl Render for FeedbackButton { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + Button::new("feedback") + .small() + .compact() + .primary() + .label("Feedback") + .on_click(|_, _, cx| cx.open_url(&crate::logging::feedback_url(cx, None))) + } +} + /// Populate the title bar registry. The app menus are the one always-present item, so they /// are registered on the left by default. Right: generic open/close buttons for each dock. pub fn build_title_bar_registry(cx: &mut App, dock: WeakEntity) -> TitleBarRegistry { @@ -30,6 +45,8 @@ pub fn build_title_bar_registry(cx: &mut App, dock: WeakEntity) -> Tit let plugins = cx.new(|cx| PluginBar::new(Bar::Title, Side::Right, cx)); registry.items_mut().add_right(plugins); + registry.items_mut().add_right(cx.new(|_| FeedbackButton)); + // Download progress and the explicit restart action from the signed updater. // Text before buttons, on the same reasoning as the plugin bar above. let update_notice = cx.new(UpdateNotice::new); diff --git a/docs/dev/feedback-reporting-options.md b/docs/dev/feedback-reporting-options.md new file mode 100644 index 00000000..fa40d93c --- /dev/null +++ b/docs/dev/feedback-reporting-options.md @@ -0,0 +1,332 @@ +# In-app feedback reporting options + +## Decision summary + +Use a hosted feedback form and send reports to a Cloudflare Worker. The Worker creates an issue in Grass Labs, project qrate, milestone Beta Intake & Stabilization, state Backlog. + +Add a persistent **Feedback** button to the main title bar and launcher title bar. Keep **Help > Send Feedback** as a second entry point. + +The app should open the browser form with a small diagnostic payload in the URL fragment. The form should remove the fragment after it reads the data. + +This design keeps temporary form code out of the desktop app. It also keeps reports private and does not require a reporter account. + +The beta does not depend on `qrate://`. Website-to-app attachment can follow later through the plugin branch's shared protocol handler. + +Do not send diagnostics inside a `qrate://` link. Operating systems can expose protocol arguments in process lists and logs. + +## Final implementation audit + +- Keep one hosted form and one Worker submission endpoint. +- Store reports and attachments in Linear. Do not add Notion synchronization or a separate upload bucket. +- Keep only a structured, versioned summary in the URL fragment. Exclude raw logs, plugin error strings, and file paths. +- Treat the fragment as visible to browser extensions and page scripts, not as encrypted storage. +- Remove the fragment immediately. Do not load analytics on the feedback page. +- Let users review and edit diagnostics before submission. Logs and screenshots require explicit file selection. +- Use `ui_ux` consistently in desktop links, the form, and the Worker. +- Keep Turnstile, bounded input, rate limiting, and duplicate-submit protection. Do not require a spoofable client header. +- Use one description field with category-specific guidance, rather than six separate forms. +- Crash is a manual report category. Automatic crash uploads and session recording are out of scope. +- Keep provider credentials in Worker secrets. Deployment and a live submission need operator configuration and verification. + +## Current qrate features + +qrate already has these local support features: + +- A current session log and one rotated session log. +- Panic messages and backtraces in the session log. +- App version, commit, operating system, hardware, project size, plugin status, and a short log tail. +- Home directory redaction. +- **Copy Debug Info**, **Open Logs Folder**, and **Report an Issue** actions. +- A GitHub issue link that lets the user review the report before submission. + +The current workflow has four limits: + +1. GitHub requires an account. +2. Issues in the public qrate repository are public. +3. One generic form handles all report types. +4. The app has no persistent feedback button or hosted report form. + +The work on `feat/in-app-feedback` adds bug, feature, and UI/UX shortcuts to the complete hosted form. It also removes the project filename from the diagnostic summary. + +## Recommended beta design + +```mermaid +flowchart TD + A["Feedback button"] --> B["Browser form with diagnostic fragment"] + B --> C["User reviews the report"] + C --> D["HTTPS POST"] + D --> E["Cloudflare Worker"] + E --> F["Grass Labs qrate Backlog"] + F --> G["Manual triage"] + G --> H["Accepted engineering or product issue"] +``` + +### Browser feedback form + +The form should contain: + +- Category: **Bug**, **UI / UX**, **Feature Request**, **Crash**, **Performance**, or **Improvement**. +- Summary. +- A type-specific description field. +- Optional contact email. +- An **Include diagnostics** checkbox. Select it by default for bugs and UI/UX issues. +- An expandable diagnostic preview. +- Optional screenshot or recording selection. +- A privacy notice and a required review checkbox. +- **Send feedback** and **Cancel** buttons. + +The form should keep entered text after a failed request. A successful request should show a public receipt ID, not a private Linear URL. + +### App-to-browser data transfer + +Use a URL fragment such as: + +```text +https://qrate.dvnl.work/feedback#qrate= +``` + +Browsers do not send a URL fragment in the HTTP request. Page JavaScript can read the payload locally and then call `history.replaceState` to remove it. + +Keep the payload small. Include only a structured diagnostic summary. Do not include logs in a URL. + +The form can accept a full log through a normal file picker. This action requires a separate user choice. + +The future `qrate://` handler works in the other direction. This is not a beta dependency: + +```text +qrate://feedback/new?return=https%3A%2F%2Fqrate.dvnl.work%2Ffeedback +``` + +qrate must allowlist the return origin. It then opens the browser form with a new diagnostic fragment. + +### Data contract + +Send only fields from an explicit allowlist. + +Send these report fields: + +- Report category. +- Summary and description. +- Reproduction steps and expected result for bugs. +- Optional contact email. +- Submission time from the Worker. +- A random request ID for safe retries. + +The default diagnostic summary can contain: + +- qrate version and commit. +- Operating system version and architecture. +- CPU model and logical core count. +- Used and total memory. +- Project row and column counts. +- Plugin counts and load-failure counts, without plugin names or error messages. + +Never add these fields automatically: + +- Project name or path. +- Cell values, notes, or column names. +- Linked file paths or file contents. +- Google tokens or account details. +- Local user name or home directory. +- The complete current or previous session log. + +Log files can contain workplace details and secrets. The browser must show diagnostic text for review and require an explicit choice for file attachments. + +Screenshots must be optional. The window must tell users to check screenshots for collection data. + +### Worker controls + +The Worker should: + +1. Accept only `POST` requests with JSON or bounded multipart data. +2. Reject unknown fields. +3. Limit text and attachment sizes. +4. Apply an IP-based rate limit. +5. Validate the Turnstile token and expected hostname and action. +6. Use the request ID as an idempotency key. +7. Keep the Linear token in a Worker secret. +8. Avoid logging request bodies. +9. Return a generic error without Linear details. + +Do not require a custom client header. It provides no authentication for a public browser form. + +The hosted form can use Cloudflare Turnstile. The Worker must validate the Turnstile token before it creates a Linear issue. + +### Storage and privacy + +Create all reports in the existing **Grass Labs** team and **qrate** project. Assign the active **Beta Intake & Stabilization** milestone. + +New reports start in **Backlog**. Triage moves accepted reports to **Todo**. + +The complete workflow is: + +- **Backlog**: raw intake. +- **Todo**: accepted work. +- **In Progress**: active work. +- **In Review**: testing and verification. +- **Done**: shipped work. +- **Canceled**: invalid, obsolete, or rejected reports. +- **Duplicate**: redundant reports. + +Every report gets the **Feedback**, **Needs Triage**, **Platform / OS**, and **App Version** labels. Add exactly one category label. + +Add **Logs Attached** only after the Worker uploads a log through Linear private storage. + +Do not add **Reproducible** during intake. A reviewer adds it after a successful reproduction. + +The Worker can upload screenshots to Linear private storage. Linear provides a `fileUpload` mutation and a server-side upload flow. + +After triage, remove Needs Triage and move accepted reports to Todo in the same project. Add Reproducible only after verification. + +### Linear configuration + +These IDs are public configuration values. Keep only the Linear API key in a Worker secret. + +| Item | ID | +|---|---| +| Grass Labs team | `d14f1e07-93ce-4cfe-968c-c6372c6f58a6` | +| qrate project | `aa8cfc24-95f6-461e-aac4-46437d89459e` | +| Beta Intake & Stabilization milestone | `7cdb86ec-c9a9-4d58-b46b-2e382566f1f5` | +| Backlog state | `4b7df4af-a498-4656-8d0a-c87e1c1d28aa` | +| Todo state | `8509604b-9991-4692-8b85-a083e0445f59` | +| In Progress state | `2758444f-10ab-4e6b-a3a6-c13e5326aa23` | +| In Review state | `8560a37f-8749-466e-ba8f-b622795ba3d4` | +| Done state | `ebed7fcf-e471-42c4-8d68-00c93ac41c4a` | +| Canceled state | `671784d6-b381-4624-9cc6-71dc96fecc73` | +| Duplicate state | `f0973e0d-201f-4904-bc49-bcb1cb0204dc` | +| Feedback label | `9caf21d4-082c-4a89-955e-981e850b6405` | +| Needs Triage label | `88dcf4fa-6ccd-4d48-b427-d6d190745e05` | +| Reproducible label | `d721d1ca-6213-4bda-8928-21ca37671d96` | +| Bug label | `9c1c54ef-3835-4b07-b7fe-c02952216f74` | +| UI / UX label | `8ac21715-4a1e-4a00-a4bb-06276a4ff7ca` | +| Crash label | `ed742751-05de-42db-a428-e52864a8a907` | +| Performance label | `154aeee1-f247-4bc6-b797-3b06051f1a7b` | +| Improvement label | `486d0f47-7520-42d5-88a5-c1fde5dcf769` | +| Logs Attached label | `0429eef3-39f3-4f67-a303-a9f985122a61` | +| Platform / OS label | `26451746-50ca-453e-840e-e8ed9c5a6fb7` | +| App Version label | `a47da0e4-5ef6-4a40-98ec-2ee8dcdcea20` | +| Feature Request label | `bf455f57-e866-46ae-8a73-5dcdd4f1cb23` | + +Configure these values as Worker variables. Configure `LINEAR_API_KEY` with `wrangler secret put LINEAR_API_KEY`. + +The issue creation mutation must set `teamId`, `projectId`, `projectMilestoneId`, `stateId`, and `labelIds`. The Worker should reject a successful HTTP response that contains GraphQL errors. + +The Worker category mapping is: + +| Form value | Linear label | +|---|---| +| `bug` | Bug | +| `ui_ux` | UI / UX | +| `feature` | Feature Request | +| `crash` | Crash | +| `performance` | Performance | +| `improvement` | Improvement | + +Reject unknown categories instead of creating an issue without a category. + +### Feedback Dashboard + +Linear does not let the Worker create a saved custom view. Create **Feedback Dashboard** in the Linear UI with these filters: + +- Project is **qrate**. +- Milestone is **Beta Intake & Stabilization**. +- State is **Backlog**, **Todo**, **In Progress**, or **In Review**. +- Label is **Feedback** or **Needs Triage**. + +This view shows active intake and accepted work. It excludes shipped, canceled, and duplicate reports. + +Update the website privacy policy before release. It currently states that qrate sends no reports and operates no data server. + +The policy must name: + +- The exact report data. +- Cloudflare and Linear as processors. +- The purpose of collection. +- The retention period. +- The deletion contact. +- The fact that submission is optional. + +## Options + +| Option | Beta effort | Reporter account | Privacy | Attachments | Tracker fit | Result | +|---|---:|---|---|---|---|---| +| Prefilled public GitHub issue | 1 day | GitHub required | Poor for workplace logs | Yes | Direct GitHub issue | Keep as fallback | +| Browser form, Worker, Linear | 3–5 days | None | Private with user preview | Yes | Separate feedback intake | **Recommended** | +| Browser form, Worker, Notion | 3–5 days | None | Private with user preview | Yes | Mixes raw feedback with planned work | Good alternative | +| Worker writes Notion and Linear | 5–7 days | None | Private with user preview | More complex | Creates duplicate state | Avoid | +| Email through the default mail app | 1–2 days | Email required | Depends on local email | Yes | Manual entry | Poor reliability | +| Sentry user feedback | 2–4 days | None | New external processor | Crash-focused | Separate tracker | Consider later for crashes | + +### GitHub + +GitHub issue forms support structured fields, labels, required answers, and file uploads. They still create issues in the target repository. + +The public qrate repository cannot safely receive workplace logs by default. GitHub also adds an account requirement and takes users out of the app. + +### Cloudflare Worker and Linear + +The qrate website already runs on Cloudflare Workers. Static assets bypass the Worker, and the site already has one dynamic route. + +The free Workers plan allows 100,000 requests each day. A feedback endpoint will use a very small part of that limit. + +Linear supports issue creation through its GraphQL API. A restricted key can have issue creation access for one team. + +Linear also supports private file storage through its server-side upload flow. This lets the Worker attach screenshots without a second storage service. + +This pattern matches common product operations. Raw feedback goes to a separate intake queue. Reviewed work moves to the product or engineering backlog. + +The separate team prevents unreviewed reports and spam from distorting planned work. It also gives the Worker token the smallest useful permission scope. + +### Cloudflare Worker and Notion + +Notion remains useful for planned product work and larger specifications. A separate feedback database could also hold raw reports. + +This option offers flexible views and page content. It adds a promotion step before the team sees an issue in Linear. + +Use Notion if the team wants research synthesis before engineering triage. Use Linear if the team wants one operational feedback queue. + +### Sentry + +Sentry can pair Rust error events with user comments. Its standard Rust feedback flow uses a JavaScript crash report modal. + +Sentry fits automatic crash reporting better than feature requests or general UI/UX feedback. It also changes qrate from local logs to continuous third-party error collection. + +Do not add Sentry for the beta feedback form. Review it as a separate opt-in crash reporting project. + +## Minimal delivery plan + +### Phase 1: browser intake + +1. Add the persistent **Feedback** button. +2. Add the hosted form and six report categories on the `site` branch. +3. Open the form with an allowlisted diagnostic fragment. +4. Keep the existing GitHub action as a fallback. + +### Phase 2: private delivery + +1. Add the Worker endpoint on the `site` branch. +2. Add schema validation, Turnstile, size limits, rate limits, and idempotency. +3. Create reports in the separate Linear feedback team. +4. Upload screenshots through Linear after text reports work. +5. Update the privacy policy. + +### Phase 3: beta operations + +1. Add Linear views for new, accepted, and duplicate reports. +2. Define the retention and deletion process. +3. Test failures with no network and invalid server responses. +4. Test a packaged build on Windows, macOS, and Linux. + +## External references + +- [Cloudflare Workers pricing](https://developers.cloudflare.com/workers/platform/pricing/) +- [Cloudflare Workers rate limits](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) +- [Cloudflare Turnstile server validation](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/) +- [Notion page creation](https://developers.notion.com/reference/post-page) +- [Notion API limits](https://developers.notion.com/reference/request-limits) +- [Notion file uploads](https://developers.notion.com/guides/data-apis/working-with-files-and-media) +- [Linear GraphQL API](https://linear.app/developers/graphql) +- [Linear API controls](https://linear.app/docs/api-and-webhooks) +- [Linear file uploads](https://linear.app/developers/how-to-upload-a-file-to-linear) +- [GitHub issue forms](https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-issue-forms) +- [Sentry feedback for Rust](https://docs.sentry.io/platforms/rust/user-feedback/) From 9dcb16af8067a3d169221393b90d22db55ae0f57 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:58:22 -0700 Subject: [PATCH 2/6] fix(feedback): use submenu context for launcher feedback menu --- crates/app/src/title_items/launcher_bar.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/app/src/title_items/launcher_bar.rs b/crates/app/src/title_items/launcher_bar.rs index f025b4a8..5aa392a7 100644 --- a/crates/app/src/title_items/launcher_bar.rs +++ b/crates/app/src/title_items/launcher_bar.rs @@ -62,7 +62,7 @@ impl Render for LauncherBar { menu.menu("Plugins Folder", Box::new(OpenPluginsFolder)) .menu("Reload Plugins", Box::new(ReloadPlugins)) }) - .submenu("Help", window, cx, |menu, _, _| { + .submenu("Help", window, cx, |menu, window, cx| { menu.menu( "Repository", Box::new(OpenBrowser { From 397e3023eb6f9adbd188fe27e403e3011d735882 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:19:51 -0700 Subject: [PATCH 3/6] dev(feedback): open local form from debug builds --- crates/app/src/logging.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/app/src/logging.rs b/crates/app/src/logging.rs index 20ee91b7..42f4e9c7 100644 --- a/crates/app/src/logging.rs +++ b/crates/app/src/logging.rs @@ -28,6 +28,11 @@ pub enum FeedbackKind { Ux, } +#[cfg(debug_assertions)] +const FEEDBACK_URL: &str = "http://localhost:4321/feedback"; +#[cfg(not(debug_assertions))] +const FEEDBACK_URL: &str = "https://qrate.dvnl.work/feedback"; + /// Where the current session writes. `None` only if the OS has no local data dir, in which case /// there is nowhere to log and the terminal sink is all there is. pub fn log_path() -> Option { @@ -303,7 +308,7 @@ pub fn feedback_url(cx: &App, kind: Option) -> String { "plugin_failures": plugins.iter().filter(|(_, error)| error.is_some()).count(), }); format!( - "https://qrate.dvnl.work/feedback{kind}#diagnostics={}", + "{FEEDBACK_URL}{kind}#diagnostics={}", urlencode(&diagnostics.to_string()) ) } @@ -346,7 +351,7 @@ mod tests { ) }); - assert!(bug.starts_with("https://qrate.dvnl.work/feedback?type=bug#diagnostics=")); + assert!(bug.starts_with(&format!("{}?type=bug#diagnostics=", super::FEEDBACK_URL))); assert!(feature.contains("?type=feature#diagnostics=")); assert!(ux.contains("?type=ui_ux#diagnostics=")); assert!(bug.contains("%22schema%22%3A1")); From aef92e2ca8bbfd89695abaf81c3d98b023e421ac Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:14:34 -0700 Subject: [PATCH 4/6] fix(feedback): make title bar control clickable --- crates/app/src/title_items/launcher_bar.rs | 3 ++- crates/app/src/title_items/mod.rs | 22 ++++++++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/app/src/title_items/launcher_bar.rs b/crates/app/src/title_items/launcher_bar.rs index 5aa392a7..028bebb5 100644 --- a/crates/app/src/title_items/launcher_bar.rs +++ b/crates/app/src/title_items/launcher_bar.rs @@ -102,8 +102,9 @@ impl Render for LauncherBar { Button::new("launcher-feedback") .small() .compact() - .primary() + .ghost() .label("Feedback") + .occlude() .on_click(|_, _, cx| { cx.open_url(&crate::logging::feedback_url(cx, None)) }), diff --git a/crates/app/src/title_items/mod.rs b/crates/app/src/title_items/mod.rs index 9e1a0e04..8625b738 100644 --- a/crates/app/src/title_items/mod.rs +++ b/crates/app/src/title_items/mod.rs @@ -3,9 +3,9 @@ mod update_notice; use gpui::*; use gpui_component::{ - Sizable as _, - button::{Button, ButtonVariants as _}, + ActiveTheme, dock::{DockArea, DockPlacement}, + h_flex, menu::AppMenuBar, }; use plugin_api::{Bar, Side}; @@ -19,12 +19,18 @@ use workspace::DockToggleButton; struct FeedbackButton; impl Render for FeedbackButton { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - Button::new("feedback") - .small() - .compact() - .primary() - .label("Feedback") + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + h_flex() + .id("feedback") + .px(px(6.)) + .py(px(2.)) + .rounded_md() + .text_sm() + .text_color(cx.theme().muted_foreground) + .cursor_pointer() + .hover(|this| this.bg(cx.theme().secondary_hover)) + .occlude() + .child("Feedback") .on_click(|_, _, cx| cx.open_url(&crate::logging::feedback_url(cx, None))) } } From 76d0d80e1b69e5216f16e5783cbee02bd9c639ab Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:31:42 -0700 Subject: [PATCH 5/6] feat(feedback): pass recent logs to report form --- crates/app/src/logging.rs | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/crates/app/src/logging.rs b/crates/app/src/logging.rs index 42f4e9c7..0879eb90 100644 --- a/crates/app/src/logging.rs +++ b/crates/app/src/logging.rs @@ -7,9 +7,9 @@ //! `qrate.old.log` is the one before it. That is the whole retention policy, and it is what makes //! "reproduce it, then send me the log" work — the reproduction is always the current file. //! -//! Nothing is uploaded. `qrate.dvnl.work` is a static site and cannot receive a report, so the -//! dump travels by clipboard or by a prefilled GitHub issue URL, both of which put the user in -//! front of the text before it leaves the machine. +//! Reports open on the qrate site with diagnostics and a short, redacted log tail in the fragment. +//! The fragment is not sent in the page request, and the user reviews what to include before the +//! site uploads anything. use std::fmt::Write as _; use std::fs::{self, File}; @@ -250,6 +250,18 @@ fn log_tail(lines: usize) -> String { all[all.len().saturating_sub(lines)..].join("\n") } +fn feedback_log_tail() -> String { + let log = redact(&log_tail(30)); + if log.chars().count() <= 1_500 { + return log; + } + let tail: String = log.chars().rev().take(1_500).collect(); + format!( + "(earlier lines omitted)\n{}", + tail.chars().rev().collect::() + ) +} + /// Replace the user's home directory with `~` throughout. /// /// The dump carries project paths and plugin file names, which on a real machine means a real @@ -278,9 +290,9 @@ pub fn urlencode(text: &str) -> String { }) } -/// Open the hosted form with diagnostics in the fragment, which is not sent in the page request. +/// Open the hosted form with diagnostics and a bounded log tail in the fragment. /// -/// The page removes the fragment after reading it and shows the exact text before submission. +/// The page removes the fragment after reading it and shows both values before submission. pub fn feedback_url(cx: &App, kind: Option) -> String { let kind = match kind { Some(FeedbackKind::Bug) => "?type=bug", @@ -308,8 +320,9 @@ pub fn feedback_url(cx: &App, kind: Option) -> String { "plugin_failures": plugins.iter().filter(|(_, error)| error.is_some()).count(), }); format!( - "{FEEDBACK_URL}{kind}#diagnostics={}", - urlencode(&diagnostics.to_string()) + "{FEEDBACK_URL}{kind}#diagnostics={}&logs={}", + urlencode(&diagnostics.to_string()), + urlencode(&feedback_log_tail()) ) } @@ -355,7 +368,7 @@ mod tests { assert!(feature.contains("?type=feature#diagnostics=")); assert!(ux.contains("?type=ui_ux#diagnostics=")); assert!(bug.contains("%22schema%22%3A1")); - assert!(!bug.contains("log%20")); + assert!(bug.contains("&logs=")); assert!(!bug.contains("CPU")); assert!(bug.len() < 7_000); } From 12f870209502b6a1d0bcee96b285b774214879fa Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:33:32 -0700 Subject: [PATCH 6/6] docs(feedback): record receipt email plan --- docs/dev/feedback-email-receipts.md | 147 +++++++++++++++++++++++++ docs/dev/feedback-reporting-options.md | 21 ++-- 2 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 docs/dev/feedback-email-receipts.md diff --git a/docs/dev/feedback-email-receipts.md b/docs/dev/feedback-email-receipts.md new file mode 100644 index 00000000..c138d75e --- /dev/null +++ b/docs/dev/feedback-email-receipts.md @@ -0,0 +1,147 @@ +# Feedback email receipts + +## Status + +This document records a future requirement. The beta feedback flow does not send email receipts. + +## Recommendation + +Use Resend Transactional Email after Linear confirms issue creation. + +Resend fits the current Cloudflare Worker design: + +- The Worker can use one HTTPS request. +- The free tier allows 3,000 emails each month and 100 each day. +- Resend supports idempotency keys for 24 hours. +- An API key can have access to one sender domain. +- Resend publishes a data processing agreement and a subprocessor list. + +Do not send a private Linear URL. Send only the public ticket identifier, such as `TSGB-123`. + +Do not include the report text, diagnostics, logs, attachments, or user environment in the email. + +Use plain text. Disable open tracking and click tracking. + +## Proposed flow + +1. Validate the optional email address. +2. Validate Turnstile and the feedback report. +3. Create the Linear issue. +4. Return feedback success even if the email later fails. +5. If the user supplied an email address, send one receipt. +6. Use the Linear issue ID and a recipient digest in the idempotency key. +7. Retry only temporary network and provider errors. + +Use an idempotency key with this form: + +```text +feedback-receipt// +``` + +The receipt can use this content: + +```text +Subject: qrate received your feedback: TSGB-123 + +qrate received your feedback. + +Ticket: TSGB-123 + +Keep this ticket number if you need to contact the qrate team. +``` + +Do not add a resend endpoint during the beta. + +## Failure behavior + +Email delivery is a secondary action. A receipt failure must not change a successful feedback result. + +Use `waitUntil()` for a best-effort beta receipt. Do not describe this method as durable delivery. + +If receipts become a contractual requirement, add a queue and explicit delivery state. Do not add this system before it is necessary. + +Log only these values: + +- Linear issue ID. +- Provider response status. +- Provider message ID. +- A one-way digest of the recipient address. + +Do not log the email address or email body. + +## Provider comparison + +| Provider | Cost at beta volume | Idempotency | Main tradeoff | +|---|---:|---|---| +| Resend | 3,000 emails each month and 100 each day at no cost | 24-hour request key | Adds one processor with 30-day free-plan retention | +| Cloudflare Email Sending | 3,000 each month with Workers Paid | No documented request key | Fewer vendors, but the outbound service is in beta | +| Postmark | 100 each month at no cost | No provider key | Mature delivery, but higher cost and 45-day message retention | +| MailChannels | 100 each day at no cost, with account restrictions | No documented request key | Pricing and verified-recipient rules need confirmation | +| Amazon SES | Low unit cost | No provider key | Adds AWS, IAM, sandbox approval, and more operations | + +Cloudflare Email Routing does not provide general outbound transactional email. Recheck Cloudflare Email Sending after it leaves beta. + +## Privacy requirements + +The email provider receives personal data: + +- Recipient email address. +- Sender address. +- Ticket identifier. +- Delivery and bounce metadata. + +Before release: + +1. Add the provider and receipt purpose to the privacy notice. +2. Review the provider data processing agreement. +3. Review the provider subprocessor list and retention policy. +4. Use a dedicated sender subdomain. +5. Add SPF, DKIM, return-path, and DMARC records. +6. Disable marketing, open, and click tracking. +7. Configure bounce and suppression handling. + +Do not use a user value for the subject, sender, reply-to address, header, or recipient list. + +## Abuse controls + +Turnstile does not prevent all email abuse. A user can submit a third party's email address. + +Keep these controls: + +- One receipt for each confirmed issue and recipient. +- Existing IP rate limits. +- A fresh Turnstile check before any report. +- No receipt attachments. +- No user-controlled email content. +- No public resend action. + +## Launch checklist + +- [ ] Create a dedicated receipt sender subdomain. +- [ ] Configure SPF, DKIM, return-path, and DMARC records. +- [ ] Add a restricted Resend API key as a Worker secret. +- [ ] Send only after Linear returns a confirmed issue. +- [ ] Use a deterministic idempotency key. +- [ ] Keep email failure non-fatal. +- [ ] Disable open and click tracking. +- [ ] Update the privacy notice. +- [ ] Test success, timeout, retry, suppression, bounce, and duplicate submission. +- [ ] Test a submission without an email address. + +## Sources + +- [Cloudflare Email Service](https://developers.cloudflare.com/email-service/) +- [Cloudflare Email Service pricing](https://developers.cloudflare.com/email-service/platform/pricing/) +- [Cloudflare outbound email setup](https://developers.cloudflare.com/email-service/get-started/send-emails/) +- [Resend pricing](https://resend.com/pricing) +- [Resend domain setup](https://resend.com/docs/add-a-domain) +- [Resend API keys](https://resend.com/docs/dashboard/api-keys/introduction) +- [Resend idempotency keys](https://resend.com/docs/dashboard/emails/idempotency-keys) +- [Resend data processing agreement](https://resend.com/legal/dpa) +- [Postmark pricing](https://postmarkapp.com/pricing) +- [Postmark email API](https://postmarkapp.com/developer/api/email-api) +- [Postmark idempotency guidance](https://postmarkapp.com/support/article/what-is-an-idempotency-key) +- [MailChannels Email API pricing](https://docs.mailchannels.com/email-api/billing/pricing) +- [MailChannels authentication](https://docs.mailchannels.com/email-api/authentication) +- [Amazon SES pricing](https://aws.amazon.com/ses/pricing/) +- [Amazon SES sandbox requirements](https://aws.amazon.com/ses/faqs/) diff --git a/docs/dev/feedback-reporting-options.md b/docs/dev/feedback-reporting-options.md index fa40d93c..2eccbde8 100644 --- a/docs/dev/feedback-reporting-options.md +++ b/docs/dev/feedback-reporting-options.md @@ -18,10 +18,10 @@ Do not send diagnostics inside a `qrate://` link. Operating systems can expose p - Keep one hosted form and one Worker submission endpoint. - Store reports and attachments in Linear. Do not add Notion synchronization or a separate upload bucket. -- Keep only a structured, versioned summary in the URL fragment. Exclude raw logs, plugin error strings, and file paths. +- Keep a structured summary and a bounded current-session log tail in the URL fragment. - Treat the fragment as visible to browser extensions and page scripts, not as encrypted storage. - Remove the fragment immediately. Do not load analytics on the feedback page. -- Let users review and edit diagnostics before submission. Logs and screenshots require explicit file selection. +- Let users review and edit diagnostics and logs before submission. Require an explicit choice to include logs. - Use `ui_ux` consistently in desktop links, the form, and the Worker. - Keep Turnstile, bounded input, rate limiting, and duplicate-submit protection. Do not require a spoofable client header. - Use one description field with category-specific guidance, rather than six separate forms. @@ -87,9 +87,9 @@ https://qrate.dvnl.work/feedback#qrate= Browsers do not send a URL fragment in the HTTP request. Page JavaScript can read the payload locally and then call `history.replaceState` to remove it. -Keep the payload small. Include only a structured diagnostic summary. Do not include logs in a URL. +Keep the payload small. Include a structured diagnostic summary and a bounded, home-path-redacted log tail. -The form can accept a full log through a normal file picker. This action requires a separate user choice. +The form shows the exact log tail and does not include it until the user selects the log option. The future `qrate://` handler works in the other direction. This is not a beta dependency: @@ -168,9 +168,9 @@ The complete workflow is: - **Canceled**: invalid, obsolete, or rejected reports. - **Duplicate**: redundant reports. -Every report gets the **Feedback**, **Needs Triage**, **Platform / OS**, and **App Version** labels. Add exactly one category label. +Every report gets one category label. Reports opened from qrate also get **Windows**, **macOS**, or **Linux**. -Add **Logs Attached** only after the Worker uploads a log through Linear private storage. +Add **Logs Attached** when the report includes an inline log or an uploaded log. Do not add **Reproducible** during intake. A reviewer adds it after a successful reproduction. @@ -194,8 +194,6 @@ These IDs are public configuration values. Keep only the Linear API key in a Wor | Done state | `ebed7fcf-e471-42c4-8d68-00c93ac41c4a` | | Canceled state | `671784d6-b381-4624-9cc6-71dc96fecc73` | | Duplicate state | `f0973e0d-201f-4904-bc49-bcb1cb0204dc` | -| Feedback label | `9caf21d4-082c-4a89-955e-981e850b6405` | -| Needs Triage label | `88dcf4fa-6ccd-4d48-b427-d6d190745e05` | | Reproducible label | `d721d1ca-6213-4bda-8928-21ca37671d96` | | Bug label | `9c1c54ef-3835-4b07-b7fe-c02952216f74` | | UI / UX label | `8ac21715-4a1e-4a00-a4bb-06276a4ff7ca` | @@ -203,8 +201,9 @@ These IDs are public configuration values. Keep only the Linear API key in a Wor | Performance label | `154aeee1-f247-4bc6-b797-3b06051f1a7b` | | Improvement label | `486d0f47-7520-42d5-88a5-c1fde5dcf769` | | Logs Attached label | `0429eef3-39f3-4f67-a303-a9f985122a61` | -| Platform / OS label | `26451746-50ca-453e-840e-e8ed9c5a6fb7` | -| App Version label | `a47da0e4-5ef6-4a40-98ec-2ee8dcdcea20` | +| Windows label | `951df0ef-4d13-4eaa-97cc-b7621702126c` | +| macOS label | `6ad3615e-c1ad-4be6-91bf-9ce6a9d6b093` | +| Linux label | `2dae9fe2-a98c-43bd-90d5-3a786923d8af` | | Feature Request label | `bf455f57-e866-46ae-8a73-5dcdd4f1cb23` | Configure these values as Worker variables. Configure `LINEAR_API_KEY` with `wrangler secret put LINEAR_API_KEY`. @@ -231,8 +230,6 @@ Linear does not let the Worker create a saved custom view. Create **Feedback Das - Project is **qrate**. - Milestone is **Beta Intake & Stabilization**. - State is **Backlog**, **Todo**, **In Progress**, or **In Review**. -- Label is **Feedback** or **Needs Triage**. - This view shows active intake and accepted work. It excludes shipped, canceled, and duplicate reports. Update the website privacy policy before release. It currently states that qrate sends no reports and operates no data server.