From 18a891f70943a05cf834554eef132712606bda3e Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:26:44 +0400 Subject: [PATCH] feat: add ability to provide feedback --- AGENTS.md | 5 +- e2e/coverage-map.mjs | 5 + e2e/tests/ui.test.mjs | 254 ++++++++++ src-tauri/src/agent_console.rs | 68 +++ src-tauri/src/feedback.rs | 553 ++++++++++++++++++++++ src-tauri/src/lib.rs | 3 + src-tauri/src/mcp_server.rs | 206 +++++++- src-tauri/src/settings_manager.rs | 197 +++++--- src/app/page.tsx | 19 + src/components/agent-conversation.tsx | 40 ++ src/components/command-palette.tsx | 2 + src/components/feedback-dialog.tsx | 468 ++++++++++++++++++ src/components/rail-nav.tsx | 25 + src/components/tips/scene-for.tsx | 3 + src/components/tips/scenes-essentials.tsx | 85 ++++ src/hooks/use-tips.ts | 11 +- src/i18n/locales/de.json | 73 ++- src/i18n/locales/en.json | 73 ++- src/i18n/locales/es.json | 73 ++- src/i18n/locales/fr.json | 73 ++- src/i18n/locales/ja.json | 73 ++- src/i18n/locales/ko.json | 73 ++- src/i18n/locales/pt.json | 73 ++- src/i18n/locales/ru.json | 73 ++- src/i18n/locales/tr.json | 73 ++- src/i18n/locales/vi.json | 73 ++- src/i18n/locales/zh.json | 73 ++- src/lib/agent-console.ts | 7 +- src/lib/backend-errors.ts | 23 + src/lib/shortcuts.ts | 10 + src/lib/tips.test.mjs | 1 + src/lib/tips.ts | 11 +- 32 files changed, 2693 insertions(+), 106 deletions(-) create mode 100644 src-tauri/src/feedback.rs create mode 100644 src/components/feedback-dialog.tsx diff --git a/AGENTS.md b/AGENTS.md index 2fd2e9c2..16e7f55d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,7 @@ donutbrowser/ │ │ ├── automation_rate_limiter.rs # Shared REST/MCP automation quota │ │ ├── agent_console.rs # The person's side of connected MCP agents: activity, questions, notes, take-over, pause │ │ ├── recipes.rs # Thin client for cloud recipes; storage and replay stay in the cloud +│ │ ├── feedback.rs # Feedback to the Donut team from the dialog and the send_feedback MCP tool, optional redacted logs │ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler) │ │ ├── vpn/ # WireGuard tunnels │ │ ├── xray/ # Xray share links (VLESS, VMess, Trojan, Hysteria2): parse, export, client config @@ -316,8 +317,8 @@ and are mapped in `scene-for.tsx`; the dialog is snapshot: profile fields, groups, extension groups, team, Cookie Bot enrolments and the sync server come from data `page.tsx` has loaded; API, remote control, fingerprint gate, proxy check history, default - browser, trash and agent runs are read by `use-tips.ts` through existing - commands, and only for the tips still in the running. An unknown fact + browser, trash, agent runs and sent feedback are read by `use-tips.ts` + through existing commands, and only for the tips still in the running. An unknown fact never hides a tip. The command palette and import tips have no signal. - Plan tips carry `requires`; they are listed only when the signed-in plan grants the capability. The paid welcome opens once per account when the diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index d093a77e..e206d1fb 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -337,6 +337,11 @@ export const commandCoverage = { "recipes::delete_agent_recipe", ], }, + feedback: { + suite: "ui", + level: "integration", + commands: ["feedback::send_feedback", "feedback::preview_feedback_logs"], + }, agentConsole: { suite: "integrations", level: "integration", diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index aa976536..1ce2d608 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { mkdir, realpath, writeFile } from "node:fs/promises"; +import http from "node:http"; import path from "node:path"; import test from "node:test"; import Color from "color"; @@ -4234,3 +4235,256 @@ test("the create dialog warns a free user who creates quickly and holds a capped { seedDownloadedBrowser: true }, ); }); + +/** Stands in for the cloud feedback route and keeps every request it gets. */ +async function feedbackCaptureServer() { + const received = []; + let status = 201; + const server = http.createServer((request, response) => { + let body = ""; + request.setEncoding("utf8"); + request.on("data", (chunk) => { + body += chunk; + }); + request.on("end", () => { + received.push({ + method: request.method, + url: request.url, + headers: request.headers, + body: body ? JSON.parse(body) : null, + }); + response.writeHead(status, { "content-type": "application/json" }); + response.end( + JSON.stringify( + status < 300 + ? { + id: `fb-${received.length}`, + createdAt: new Date().toISOString(), + } + : { statusCode: status, message: "Too Many Requests" }, + ), + ); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return { + received, + url: `http://127.0.0.1:${server.address().port}/api/feedback`, + respondWith(next) { + status = next; + }, + close: () => new Promise((resolve) => server.close(resolve)), + }; +} + +const FEEDBACK_DIALOG = '[data-dialog="feedback"]'; +const MOD = process.platform === "darwin" ? { meta: true } : { ctrl: true }; + +function feedbackLogsSwitch(app) { + return app.execute( + `return document.querySelector('[data-slot="feedback-include-logs"]')?.dataset.state ?? null;`, + ); +} + +function feedbackMessage(app) { + return app.execute( + `return document.querySelector('[data-slot="feedback-message"]')?.value ?? null;`, + ); +} + +test("feedback goes from the More menu to the team, with the logs the person previewed", async () => { + const capture = await feedbackCaptureServer(); + try { + await withApp( + "ui-feedback", + async (app) => { + // Refused before anything leaves the machine. + assert.match( + await app.invokeError("send_feedback", { + kind: "bug", + message: " ", + includeLogs: false, + email: null, + locale: "en", + }), + /FEEDBACK_MESSAGE_EMPTY/, + ); + assert.match( + await app.invokeError("send_feedback", { + kind: "idea", + message: "More themes", + includeLogs: false, + email: "not-an-address", + locale: "en", + }), + /FEEDBACK_EMAIL_INVALID/, + ); + assert.equal(capture.received.length, 0); + await app.waitFor( + async () => + (await app.invoke("preview_feedback_logs")).includes( + "Donut started", + ), + { description: "this session's log in the excerpt" }, + ); + + await app.clickSelector(`[aria-label="${en.rail.more.label}"]`); + await app.clickSelector('[data-slot="rail-open-feedback"]'); + await app.waitFor( + () => + app.execute( + `return Boolean(document.querySelector(arguments[0]));`, + [FEEDBACK_DIALOG], + ), + { description: "the feedback dialog" }, + ); + assert.equal( + await app.execute(`return document.activeElement?.dataset?.slot;`), + "feedback-message", + "the message box has focus at once", + ); + assert.equal( + await app.execute( + `return document.querySelector('[data-slot="feedback-send"]').disabled;`, + ), + true, + "nothing to send yet", + ); + + await app.capture("feedback-open"); + + // A bug brings the logs; an idea does not, until the person decides. + assert.equal(await feedbackLogsSwitch(app), "checked"); + await app.clickSelector( + '[data-slot="feedback-kind"][data-kind="idea"]', + ); + assert.equal(await feedbackLogsSwitch(app), "unchecked"); + assert.equal( + await app.execute( + `return document.querySelector('[data-slot="feedback-message"]').placeholder;`, + ), + en.feedback.kinds.idea.placeholder, + ); + await app.clickSelector('[data-slot="feedback-kind"][data-kind="bug"]'); + assert.equal(await feedbackLogsSwitch(app), "checked"); + + await app.clickSelector('[data-slot="feedback-preview-logs"]'); + await app.waitFor( + () => + app.execute( + `return document.querySelector('[data-slot="feedback-logs-preview"]')?.value.includes("Donut started") ?? false;`, + ), + { description: "the log preview" }, + ); + + await app.fillSelector( + '[data-slot="feedback-message"]', + "The proxy check never finishes", + ); + await app.fillSelector( + '[data-slot="feedback-email"]', + "me@example.com", + ); + await app.capture("feedback-compose"); + await app.clickSelector('[data-slot="feedback-send"]'); + await app.waitFor( + () => + app.execute( + `return Boolean(document.querySelector('[data-slot="feedback-sent"]'));`, + ), + { description: "the thank-you view" }, + ); + assert.ok(await app.visibleTextIncludes(en.feedback.success.title)); + assert.ok( + await app.visibleTextIncludes( + en.feedback.success.reply.replace("{{email}}", "me@example.com"), + ), + ); + await app.capture("feedback-sent"); + + assert.equal(capture.received.length, 1); + const [sent] = capture.received; + assert.equal(sent.method, "POST"); + assert.equal(sent.url, "/api/feedback"); + assert.equal( + sent.headers.authorization, + undefined, + "signed out, so no account", + ); + assert.equal(sent.body.kind, "bug"); + assert.equal(sent.body.source, "person"); + assert.equal(sent.body.message, "The proxy check never finishes"); + assert.equal(sent.body.email, "me@example.com"); + assert.equal(sent.body.locale, "en"); + assert.equal(typeof sent.body.appVersion, "string"); + assert.ok(sent.body.logs.includes("Donut started")); + + await app.clickSelector('[data-slot="feedback-sent"] button'); + await app.waitFor( + async () => + (await app.execute( + `return Boolean(document.querySelector(arguments[0]));`, + [FEEDBACK_DIALOG], + )) === false, + { description: "the dialog closed" }, + ); + await app.waitFor( + async () => + (await app.invoke("get_app_settings")).feedback_sent_at > 0, + { description: "the sent feedback remembered for the tips" }, + ); + + // The chord opens a fresh dialog: a sent draft does not come back. + await app.pressShortcut({ ...MOD, shift: true, key: "f" }); + await app.waitFor(async () => (await feedbackMessage(app)) === "", { + description: "an empty dialog from the keyboard", + }); + + // A refusal keeps the draft and says why. + capture.respondWith(429); + await app.clickSelector( + '[data-slot="feedback-kind"][data-kind="idea"]', + ); + await app.fillSelector( + '[data-slot="feedback-message"]', + "Dark tray icon", + ); + await app.pressShortcut({ ...MOD, key: "\uE007" }); + await app.waitForText(en.backendErrors.feedbackRateLimited); + assert.equal(await feedbackMessage(app), "Dark tray icon"); + assert.equal(capture.received.length, 2); + assert.equal(capture.received[1].body.kind, "idea"); + assert.equal( + capture.received[1].body.logs, + undefined, + "an idea was sent without logs", + ); + + // Closing keeps the unsent draft. The feedback tip leads back to it. + await app.pressShortcut({ key: "Escape" }); + await app.waitFor( + async () => + (await app.execute( + `return Boolean(document.querySelector(arguments[0]));`, + [FEEDBACK_DIALOG], + )) === false, + { description: "the dialog closed with a draft" }, + ); + await openTipsFromRail(app); + await app.clickSelector( + '[data-slot="tips-list-item"][data-tip-id="feedback"]', + ); + assert.ok(await app.visibleTextIncludes(en.tips.items.feedback.title)); + await app.capture("feedback-tip"); + await app.clickSelector('[data-slot="tip-action"]'); + await app.waitFor( + async () => (await feedbackMessage(app)) === "Dark tray icon", + { description: "the draft back in the dialog from the tip" }, + ); + }, + { extraEnv: { DONUT_E2E_FEEDBACK_URL: capture.url } }, + ); + } finally { + await capture.close(); + } +}); diff --git a/src-tauri/src/agent_console.rs b/src-tauri/src/agent_console.rs index 5659a139..9e3224e6 100644 --- a/src-tauri/src/agent_console.rs +++ b/src-tauri/src/agent_console.rs @@ -87,6 +87,7 @@ pub enum ThreadKind { Progress, Joined, Left, + Feedback, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -115,10 +116,18 @@ pub struct AgentThreadItem { pub delivered_to: Vec, pub done: Option, pub total: Option, + /// Set on a `feedback` item: what the agent sent to the Donut team. + pub feedback: Option, #[serde(skip)] outcome_seen: bool, } +#[derive(Debug, Clone, Serialize)] +pub struct ThreadFeedback { + pub kind: String, + pub logs: bool, +} + #[derive(Debug, Clone, Serialize)] pub struct AgentHold { pub profile_id: String, @@ -293,6 +302,7 @@ fn thread_item(kind: ThreadKind, at: u64) -> AgentThreadItem { delivered_to: Vec::new(), done: None, total: None, + feedback: None, outcome_seen: false, } } @@ -765,6 +775,64 @@ pub fn report_progress( emit(EVENT_THREAD, &item); } +/// How many of a session's latest calls go with its feedback. +const FEEDBACK_RECENT_CALLS: usize = 20; + +/// What feedback from this session carries about the agent: its client name +/// and version, and its latest calls (tool, error code and time only, never +/// the arguments). +pub fn feedback_context(session_id: Option<&str>) -> (Option, Vec) { + let state = console(); + let client = session_id + .and_then(|sid| state.sessions.get(sid)) + .and_then(|session| { + let name = session.client_name.as_deref()?; + Some(match session.client_version.as_deref() { + Some(version) => format!("{name} {version}"), + None => name.to_string(), + }) + }); + let owner = session_id.map(str::to_string); + let mut calls: Vec = state + .activity + .iter() + .rev() + .filter(|entry| entry.session_id == owner) + .take(FEEDBACK_RECENT_CALLS) + .map(|entry| { + serde_json::json!({ + "tool": entry.tool, + "errorCode": entry.error_code, + "durationMs": entry.duration_ms, + "at": entry.at, + }) + }) + .collect(); + calls.reverse(); + (client, calls) +} + +/// Show the person that an agent sent feedback to the Donut team. +pub fn feedback_sent(session_id: Option<&str>, kind: &str, message: &str, logs: bool) { + let now = now_ms(); + let item = { + let mut state = console(); + let mut item = thread_item(ThreadKind::Feedback, now); + item.id = state.next_id(); + item.session_id = session_id.map(str::to_string); + item.text = message.chars().take(MAX_TEXT_CHARS).collect(); + item.feedback = Some(ThreadFeedback { + kind: kind.to_string(), + logs, + }); + if let Some(session) = session_id.and_then(|sid| state.sessions.get_mut(sid)) { + session.last_seen_at = now; + } + state.push_thread(item) + }; + emit(EVENT_THREAD, &item); +} + /// Everything an agent should know before its next step. pub fn human_updates(session_id: Option<&str>) -> serde_json::Value { let notes = take_notes(session_id); diff --git a/src-tauri/src/feedback.rs b/src-tauri/src/feedback.rs new file mode 100644 index 00000000..b13813ff --- /dev/null +++ b/src-tauri/src/feedback.rs @@ -0,0 +1,553 @@ +//! Feedback to the Donut team, from the person (the feedback dialog) or from an +//! agent (the `send_feedback` MCP tool). The cloud stores it; anyone may send +//! it, signed in or not. A signed-in sender is named by their access token, so +//! a reply can reach them. + +use crate::cloud_errors; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +pub const MAX_MESSAGE_CHARS: usize = 5000; +/// The cloud refuses bodies over 1 MiB; this leaves room for JSON escaping. +pub const MAX_LOG_BYTES: usize = 512 * 1024; +const MAX_EMAIL_CHARS: usize = 254; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +/// An agent caught in a loop must not flood the team's inbox. +const AGENT_SENDS_PER_HOUR: usize = 10; +const TRANSPORT_PREFIX: &str = "reach backend: "; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FeedbackKind { + Bug, + Idea, + Praise, + Other, +} + +impl FeedbackKind { + pub fn parse(value: &str) -> Option { + match value { + "bug" => Some(Self::Bug), + "idea" => Some(Self::Idea), + "praise" => Some(Self::Praise), + "other" => Some(Self::Other), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Bug => "bug", + Self::Idea => "idea", + Self::Praise => "praise", + Self::Other => "other", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FeedbackSource { + Person, + Agent, +} + +impl FeedbackSource { + fn as_str(self) -> &'static str { + match self { + Self::Person => "person", + Self::Agent => "agent", + } + } +} + +pub struct Feedback { + pub kind: FeedbackKind, + pub source: FeedbackSource, + pub message: String, + /// Where to reply, for a sender who is not signed in. + pub email: Option, + pub locale: Option, + pub logs: Option, + pub agent_client: Option, + pub context: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Payload<'a> { + kind: FeedbackKind, + source: FeedbackSource, + message: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + email: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + logs: Option<&'a str>, + app_version: String, + os: String, + arch: String, + #[serde(skip_serializing_if = "Option::is_none")] + locale: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + agent_client: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + context: Option<&'a serde_json::Value>, +} + +impl<'a> Payload<'a> { + fn of(feedback: &'a Feedback) -> Self { + let info = crate::settings_manager::get_system_info(); + Self { + kind: feedback.kind, + source: feedback.source, + message: &feedback.message, + email: feedback.email.as_deref(), + logs: feedback.logs.as_deref().filter(|logs| !logs.is_empty()), + app_version: info.app_version, + os: info.os, + arch: info.arch, + locale: feedback.locale.as_deref(), + agent_client: feedback.agent_client.as_deref(), + context: feedback.context.as_ref(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FeedbackReceipt { + pub id: String, +} + +/// The message as it is sent: trimmed, not empty, not too long. +pub fn validate_message(message: &str) -> Result { + let message = message.trim(); + if message.is_empty() { + return Err(crate::backend_error("FEEDBACK_MESSAGE_EMPTY")); + } + if message.chars().count() > MAX_MESSAGE_CHARS { + return Err( + serde_json::json!({ + "code": "FEEDBACK_MESSAGE_TOO_LONG", + "params": { "max": MAX_MESSAGE_CHARS.to_string() } + }) + .to_string(), + ); + } + Ok(message.to_string()) +} + +/// A reply address: blank is none, anything else must look like an address. +pub fn validate_email(email: Option<&str>) -> Result, String> { + let Some(email) = email.map(str::trim).filter(|email| !email.is_empty()) else { + return Ok(None); + }; + let plausible = email.len() <= MAX_EMAIL_CHARS + && !email.chars().any(char::is_whitespace) + && email.split_once('@').is_some_and(|(local, domain)| { + !local.is_empty() + && !domain.contains('@') + && domain + .split_once('.') + .is_some_and(|(host, tld)| !host.is_empty() && !tld.is_empty() && !tld.ends_with('.')) + }); + if plausible { + Ok(Some(email.to_string())) + } else { + Err(crate::backend_error("FEEDBACK_EMAIL_INVALID")) + } +} + +/// The log excerpt feedback carries: the newest lines, redacted, at most +/// `MAX_LOG_BYTES`. Empty when the app has not written a log yet. +pub async fn log_excerpt(dir: PathBuf) -> Result { + tokio::task::spawn_blocking(move || { + if !dir.exists() { + return Ok(String::new()); + } + crate::settings_manager::recent_logs(&dir, MAX_LOG_BYTES) + }) + .await + .map_err(|e| crate::backend_error_with_detail("FEEDBACK_LOGS_UNREADABLE", e))? + .map_err(|e| crate::backend_error_with_detail("FEEDBACK_LOGS_UNREADABLE", e)) +} + +/// Whether an agent may send one more piece of feedback this hour. Counts the +/// attempt when it may. +pub fn agent_send_allowed() -> bool { + static SENDS: Mutex> = Mutex::new(VecDeque::new()); + let mut sends = SENDS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + allow_send(&mut sends, Instant::now(), AGENT_SENDS_PER_HOUR) +} + +fn allow_send(sends: &mut VecDeque, now: Instant, per_hour: usize) -> bool { + let hour = Duration::from_secs(3600); + while sends + .front() + .is_some_and(|at| now.saturating_duration_since(*at) >= hour) + { + sends.pop_front(); + } + if sends.len() >= per_hour { + return false; + } + sends.push_back(now); + true +} + +/// Send feedback to the cloud. A signed-in sender's access token goes with it; +/// when the session cannot be used, the feedback is sent without one rather +/// than lost. +pub async fn send(feedback: Feedback) -> Result { + let body = serde_json::to_vec(&Payload::of(&feedback)) + .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?; + let logs_bytes = feedback.logs.as_ref().map_or(0, String::len); + let url = endpoint(); + let started = Instant::now(); + + let auth = &crate::cloud_auth::CLOUD_AUTH; + let result = if auth.is_logged_in().await { + match auth + .api_call_with_retry(|token| post(url.clone(), body.clone(), Some(token))) + .await + { + Err(error) if retry_without_account(&error) => post(url, body, None).await, + other => other, + } + } else { + post(url, body, None).await + }; + + let elapsed_ms = started.elapsed().as_millis(); + match result { + Ok(receipt) => { + log::info!( + "Feedback sent id={} source={} kind={} logs_bytes={logs_bytes} elapsed_ms={elapsed_ms}", + receipt.id, + feedback.source.as_str(), + feedback.kind.as_str(), + ); + Ok(receipt) + } + Err(error) => { + let coded = failure_code(&error); + log::warn!( + "Feedback send failed source={} kind={} err={coded} elapsed_ms={elapsed_ms}", + feedback.source.as_str(), + feedback.kind.as_str(), + ); + Err(coded) + } + } +} + +fn endpoint() -> String { + // A test build never reaches the production API: it posts to the suite's + // capture server, or to a port where nothing listens. + #[cfg(feature = "e2e")] + { + std::env::var("DONUT_E2E_FEEDBACK_URL") + .ok() + .filter(|url| !url.is_empty()) + .unwrap_or_else(|| "http://127.0.0.1:9/api/feedback".to_string()) + } + #[cfg(not(feature = "e2e"))] + { + format!("{}/api/feedback", crate::cloud_auth::CLOUD_API_URL) + } +} + +fn http() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .connect_timeout(CONNECT_TIMEOUT) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) + }) +} + +async fn post( + url: String, + body: Vec, + token: Option, +) -> Result { + let mut request = http() + .post(&url) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body); + if let Some(token) = token { + request = request.bearer_auth(token); + } + let response = request.send().await.map_err(|e| { + crate::system_proxy::explain(&e).unwrap_or_else(|| format!("{TRANSPORT_PREFIX}{e}")) + })?; + let status = response.status().as_u16(); + if !(200..300).contains(&status) { + let text = response.text().await.unwrap_or_default(); + // api_call_with_retry and cloud_errors::split_status read the status from this prefix. + return Err(format!("({status}) {text}")); + } + response + .json::() + .await + .map_err(|e| format!("({status}) decode response: {e}")) +} + +/// The account could not be used: its token was refused, or it could not be +/// refreshed before anything was sent. +fn retry_without_account(error: &str) -> bool { + match cloud_errors::split_status(error) { + Some((status, _)) => status == 401, + None => { + !error.starts_with(TRANSPORT_PREFIX) && !crate::system_proxy::is_unreachable_error(error) + } + } +} + +fn failure_code(error: &str) -> String { + if crate::system_proxy::is_unreachable_error(error) { + return error.to_string(); + } + match cloud_errors::split_status(error) { + Some((429, _)) => crate::backend_error("FEEDBACK_RATE_LIMITED"), + Some((400 | 413 | 422, body)) if body.contains("FEEDBACK_MESSAGE_EMPTY") => { + crate::backend_error("FEEDBACK_MESSAGE_EMPTY") + } + Some((400 | 413 | 422, _)) => crate::backend_error("FEEDBACK_INVALID"), + Some(_) => crate::backend_error("FEEDBACK_SEND_FAILED"), + None => crate::backend_error(cloud_errors::UNREACHABLE), + } +} + +/// Send the person's feedback from the feedback dialog. +#[tauri::command] +pub async fn send_feedback( + app_handle: tauri::AppHandle, + kind: String, + message: String, + include_logs: bool, + email: Option, + locale: Option, +) -> Result { + let kind = FeedbackKind::parse(&kind).ok_or_else(|| crate::backend_error("FEEDBACK_INVALID"))?; + let message = validate_message(&message)?; + let email = validate_email(email.as_deref())?; + let logs = if include_logs { + Some(log_excerpt(crate::app_dirs::log_dir(&app_handle)).await?) + } else { + None + }; + let receipt = send(Feedback { + kind, + source: FeedbackSource::Person, + message, + email, + locale: locale.filter(|locale| !locale.trim().is_empty()), + logs, + agent_client: None, + context: None, + }) + .await?; + tauri::async_runtime::spawn_blocking(crate::settings_manager::note_feedback_sent); + Ok(receipt) +} + +/// Exactly the log excerpt `send_feedback` would attach, so the person can read +/// it before sending. +#[tauri::command] +pub async fn preview_feedback_logs(app_handle: tauri::AppHandle) -> Result { + log_excerpt(crate::app_dirs::log_dir(&app_handle)).await +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{header, header_exists, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn code_of(error: &str) -> String { + serde_json::from_str::(error).unwrap()["code"] + .as_str() + .unwrap() + .to_string() + } + + #[test] + fn kinds_round_trip_and_unknown_ones_are_refused() { + for kind in ["bug", "idea", "praise", "other"] { + assert_eq!(FeedbackKind::parse(kind).unwrap().as_str(), kind); + } + assert_eq!(FeedbackKind::parse("Bug"), None); + assert_eq!(FeedbackKind::parse(""), None); + } + + #[test] + fn a_message_is_trimmed_and_bounded() { + assert_eq!(validate_message(" Hello \n").unwrap(), "Hello"); + assert_eq!( + code_of(&validate_message(" \n\t ").unwrap_err()), + "FEEDBACK_MESSAGE_EMPTY" + ); + let at_limit = "é".repeat(MAX_MESSAGE_CHARS); + assert_eq!(validate_message(&at_limit).unwrap(), at_limit); + let over = validate_message(&"a".repeat(MAX_MESSAGE_CHARS + 1)).unwrap_err(); + assert_eq!(code_of(&over), "FEEDBACK_MESSAGE_TOO_LONG"); + } + + #[test] + fn a_reply_address_is_optional_but_must_be_plausible() { + assert_eq!(validate_email(None).unwrap(), None); + assert_eq!(validate_email(Some(" ")).unwrap(), None); + assert_eq!( + validate_email(Some(" me@example.com ")).unwrap().as_deref(), + Some("me@example.com") + ); + for bad in [ + "me", + "me@", + "@example.com", + "me@example", + "a b@example.com", + "a@b@c.com", + "me@example.", + ] { + assert_eq!( + code_of(&validate_email(Some(bad)).unwrap_err()), + "FEEDBACK_EMAIL_INVALID", + "{bad}" + ); + } + } + + #[test] + fn an_agent_may_send_a_bounded_amount_per_hour() { + let mut sends = VecDeque::new(); + let start = Instant::now(); + for _ in 0..3 { + assert!(allow_send(&mut sends, start, 3)); + } + assert!(!allow_send(&mut sends, start + Duration::from_secs(60), 3)); + assert!(allow_send(&mut sends, start + Duration::from_secs(3600), 3)); + } + + #[test] + fn failures_become_codes_the_dialog_can_explain() { + assert_eq!( + code_of(&failure_code("(429) slow down")), + "FEEDBACK_RATE_LIMITED" + ); + assert_eq!( + code_of(&failure_code(r#"(400) {"code":"FEEDBACK_MESSAGE_EMPTY"}"#)), + "FEEDBACK_MESSAGE_EMPTY" + ); + assert_eq!(code_of(&failure_code("(400) bad")), "FEEDBACK_INVALID"); + assert_eq!( + code_of(&failure_code("(502) gateway")), + "FEEDBACK_SEND_FAILED" + ); + assert_eq!( + code_of(&failure_code("reach backend: connection refused")), + cloud_errors::UNREACHABLE + ); + let proxied = crate::system_proxy::unreachable_error("127.0.0.1:8888"); + assert_eq!(failure_code(&proxied), proxied); + } + + #[test] + fn only_an_unusable_account_is_retried_without_it() { + assert!(retry_without_account("(401) expired")); + assert!(retry_without_account("Not logged in")); + assert!(!retry_without_account("(429) slow down")); + assert!(!retry_without_account("(500) broken")); + assert!(!retry_without_account("reach backend: timed out")); + assert!(!retry_without_account( + &crate::system_proxy::unreachable_error("127.0.0.1:8888") + )); + } + + #[tokio::test] + async fn the_payload_carries_the_app_and_the_bearer_when_there_is_one() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/feedback")) + .and(header("authorization", "Bearer t0ken")) + .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({ + "id": "f-1", "createdAt": "2026-10-09T00:00:00.000Z" + }))) + .expect(1) + .mount(&server) + .await; + let feedback = Feedback { + kind: FeedbackKind::Bug, + source: FeedbackSource::Agent, + message: "navigate times out".to_string(), + email: None, + locale: Some("en".to_string()), + logs: Some("line\n".to_string()), + agent_client: Some("Claude Code 2.1.0".to_string()), + context: Some(serde_json::json!({ "recentCalls": [] })), + }; + let body = serde_json::to_vec(&Payload::of(&feedback)).unwrap(); + let receipt = post( + format!("{}/api/feedback", server.uri()), + body, + Some("t0ken".to_string()), + ) + .await + .unwrap(); + assert_eq!(receipt.id, "f-1"); + + let sent: serde_json::Value = + serde_json::from_slice(&server.received_requests().await.unwrap()[0].body).unwrap(); + assert_eq!(sent["kind"], "bug"); + assert_eq!(sent["source"], "agent"); + assert_eq!(sent["logs"], "line\n"); + assert_eq!(sent["agentClient"], "Claude Code 2.1.0"); + assert_eq!(sent["locale"], "en"); + assert!(sent["appVersion"].is_string() && sent["os"].is_string() && sent["arch"].is_string()); + assert!(sent.get("email").is_none(), "{sent}"); + } + + #[tokio::test] + async fn an_anonymous_post_has_no_bearer_and_a_refusal_keeps_its_status() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/feedback")) + .and(header_exists("content-type")) + .respond_with(ResponseTemplate::new(429)) + .mount(&server) + .await; + let feedback = Feedback { + kind: FeedbackKind::Idea, + source: FeedbackSource::Person, + message: "More themes".to_string(), + email: Some("me@example.com".to_string()), + locale: None, + logs: Some(String::new()), + agent_client: None, + context: None, + }; + let body = serde_json::to_vec(&Payload::of(&feedback)).unwrap(); + let error = post(format!("{}/api/feedback", server.uri()), body, None) + .await + .unwrap_err(); + assert_eq!(code_of(&failure_code(&error)), "FEEDBACK_RATE_LIMITED"); + + let request = &server.received_requests().await.unwrap()[0]; + assert!(request.headers.get("authorization").is_none()); + let sent: serde_json::Value = serde_json::from_slice(&request.body).unwrap(); + assert_eq!(sent["email"], "me@example.com"); + assert!( + sent.get("logs").is_none(), + "empty logs are not sent: {sent}" + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0d846950..de568e4d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -91,6 +91,7 @@ mod ephemeral_dirs; mod extension_fetch; mod extension_manager; mod extraction; +mod feedback; mod fingerprint_consistency; mod fs_secure; mod geoip_downloader; @@ -3543,6 +3544,8 @@ pub fn run_with_builder( cookie_bot::create_cookie_bot_user_template, cookie_bot::update_cookie_bot_user_template, cookie_bot::delete_cookie_bot_user_template, + feedback::send_feedback, + feedback::preview_feedback_logs, recipes::get_agent_recipes, recipes::create_agent_recipe, recipes::update_agent_recipe, diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 436dadcc..76d698b5 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -124,7 +124,7 @@ pub struct McpRequest { const PROTOCOL_VERSION: &str = "2025-11-25"; -const INITIALIZE_INSTRUCTIONS: &str = "Donut Browser MCP server. Use tools/list to discover the browser tools. A person watches your work in Donut: call get_human_updates when you start and between steps, report_progress during longer jobs, ask_human when you are unsure, and request_human_help when a page needs a person. A call refused with AGENT_PAUSED or PROFILE_HELD_BY_HUMAN means the person is in control; do not retry it until get_human_updates shows the pause or hold is gone."; +const INITIALIZE_INSTRUCTIONS: &str = "Donut Browser MCP server. Use tools/list to discover the browser tools. A person watches your work in Donut: call get_human_updates when you start and between steps, report_progress during longer jobs, ask_human when you are unsure, and request_human_help when a page needs a person. When a Donut tool fails in a way you cannot explain, or you need something it cannot do, tell the Donut team with send_feedback. A call refused with AGENT_PAUSED or PROFILE_HELD_BY_HUMAN means the person is in control; do not retry it until get_human_updates shows the pause or hold is gone."; const PLAN_WITHOUT_AUTOMATION: &str = " This account's plan does not include browser automation: tools that launch, drive or read a browser are refused with PLAN_REQUIRED. Managing profiles, groups, proxies, VPNs and extensions works."; @@ -4163,6 +4163,23 @@ impl McpServer { "required": ["message"] }), }, + McpTool { + name: "send_feedback".to_string(), + description: "Send feedback about Donut Browser to the Donut team: a tool that failed or behaved unexpectedly, a capability you needed and did not find, a confusing result, or something that worked well. Say what you tried, what happened, and what you expected. Set include_logs to attach the recent app logs, which help with bugs and never contain profile names, visited URLs or credentials. The person at this computer sees what you sent. Returns {sent: true, feedback_id}.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "message": { "type": "string", "description": "The feedback (at most 5000 characters)" }, + "kind": { + "type": "string", + "enum": ["bug", "idea", "praise", "other"], + "description": "bug for something broken, idea for something missing, praise for something that worked well (default: other)" + }, + "include_logs": { "type": "boolean", "description": "Attach the recent app logs (default: false)" } + }, + "required": ["message"] + }), + }, ] } @@ -4434,6 +4451,7 @@ impl McpServer { Self::json_content(&crate::agent_console::human_updates(caller.session)) } "report_progress" => Self::handle_report_progress(caller, arguments), + "send_feedback" => self.handle_send_feedback(caller, arguments).await, "list_profiles" => self.handle_list_profiles().await, "get_profile" => self.handle_get_profile(arguments).await, "run_profile" => { @@ -8790,6 +8808,7 @@ impl McpServer { /// Reads and the human channel keep working while agents are paused. fn is_passive_tool(tool_name: &str) -> bool { crate::agent_console::HUMAN_TOOLS.contains(&tool_name) + || tool_name == "send_feedback" || ((tool_name.starts_with("list_") || tool_name.starts_with("get_")) && !Self::is_automation_tool(tool_name)) } @@ -9004,6 +9023,111 @@ impl McpServer { Self::json_content(&serde_json::json!({ "ok": true })) } + async fn handle_send_feedback( + &self, + caller: McpCaller<'_>, + arguments: &serde_json::Value, + ) -> Result { + use crate::feedback::{FeedbackKind, FeedbackSource, MAX_MESSAGE_CHARS}; + + let message = arguments + .get("message") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|message| !message.is_empty()) + .ok_or_else(|| Self::invalid_argument("message is required"))?; + if message.chars().count() > MAX_MESSAGE_CHARS { + return Err(Self::invalid_argument(format!( + "message is longer than {MAX_MESSAGE_CHARS} characters" + ))); + } + let kind = match arguments.get("kind") { + None | Some(serde_json::Value::Null) => FeedbackKind::Other, + Some(value) => value + .as_str() + .and_then(FeedbackKind::parse) + .ok_or_else(|| Self::invalid_argument("kind must be bug, idea, praise or other"))?, + }; + let include_logs = arguments + .get("include_logs") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + + if !crate::feedback::agent_send_allowed() { + return Err(McpError { + code: -32000, + message: "Too much feedback was sent in the last hour. Try again later.".to_string(), + data: Some(serde_json::json!({ "code": "FEEDBACK_RATE_LIMITED" })), + }); + } + + let logs = if include_logs { + let app_handle = self.inner.lock().await.app_handle.clone(); + match app_handle { + Some(handle) => Some( + crate::feedback::log_excerpt(crate::app_dirs::log_dir(&handle)) + .await + .map_err(|_| McpError { + code: -32000, + message: "The app logs could not be read. Send the feedback without include_logs." + .to_string(), + data: Some(serde_json::json!({ "code": "FEEDBACK_LOGS_UNREADABLE" })), + })?, + ), + None => None, + } + } else { + None + }; + let logs_attached = logs.as_ref().is_some_and(|logs| !logs.is_empty()); + + let (agent_client, recent_calls) = crate::agent_console::feedback_context(caller.session); + let receipt = crate::feedback::send(crate::feedback::Feedback { + kind, + source: FeedbackSource::Agent, + message: message.to_string(), + email: None, + locale: None, + logs, + agent_client, + context: (!recent_calls.is_empty()) + .then(|| serde_json::json!({ "recentCalls": recent_calls })), + }) + .await + .map_err(|error| { + let code = serde_json::from_str::(&error) + .ok() + .and_then(|value| { + value + .get("code") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| "FEEDBACK_SEND_FAILED".to_string()); + let message = match code.as_str() { + "FEEDBACK_RATE_LIMITED" => { + "The Donut team's server refused more feedback for now. Try again later." + } + "CLOUD_UNREACHABLE" | "SYSTEM_PROXY_UNREACHABLE" => { + "The Donut team's server could not be reached. Try again later." + } + _ => "The feedback could not be sent. Try again later.", + }; + McpError { + code: -32000, + message: message.to_string(), + data: Some(serde_json::json!({ "code": code })), + } + })?; + + crate::agent_console::feedback_sent(caller.session, kind.as_str(), message, logs_attached); + Self::json_content(&serde_json::json!({ + "sent": true, + "feedback_id": receipt.id, + "logs_attached": logs_attached, + })) + } + pub(crate) async fn bring_profile_to_front(&self, profile_id: &str) -> Result<(), String> { let target = self .resolve_cdp_target(profile_id) @@ -9682,7 +9806,7 @@ mod tests { // tool was added or removed on purpose. assert_eq!( tools.len(), - 92, + 93, "the tool list is a published contract; update this number deliberately" ); @@ -12129,4 +12253,82 @@ mod tests { .await .unwrap(); } + + #[tokio::test] + async fn send_feedback_checks_its_arguments_and_shows_the_person_what_was_sent() { + let server = McpServer::new(); + server.mark_engine_ready_for_tests(); + let send = |session: &str, id: u64, arguments: serde_json::Value| { + let server = &server; + let session = session.to_string(); + async move { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { "name": "send_feedback", "arguments": arguments } + }) + .to_string(); + match server.handle_message(Some(&session), body.as_bytes()).await { + McpOutcome::Body { body, .. } => body, + _ => panic!("expected an answer"), + } + } + }; + + let init = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { "protocolVersion": "2025-06-18", "clientInfo": { "name": "Feedback Agent", "version": "1.2" } } + }) + .to_string(); + let McpOutcome::Body { + body: init, + new_session_id: Some(session), + } = server.handle_message(None, init.as_bytes()).await + else { + panic!("initialize opens a session"); + }; + assert!(init["result"]["instructions"] + .as_str() + .unwrap() + .contains("send_feedback")); + + for (id, arguments) in [ + (2, serde_json::json!({})), + (3, serde_json::json!({ "message": " " })), + ( + 4, + serde_json::json!({ "message": "ok", "kind": "complaint" }), + ), + ( + 5, + serde_json::json!({ "message": "x".repeat(crate::feedback::MAX_MESSAGE_CHARS + 1) }), + ), + ] { + let refused = send(&session, id, arguments).await; + assert_eq!( + refused["error"]["data"]["code"], "INVALID_ARGUMENT", + "{refused}" + ); + } + assert!(McpServer::is_passive_tool("send_feedback")); + + let (client, calls) = crate::agent_console::feedback_context(Some(&session)); + assert_eq!(client.as_deref(), Some("Feedback Agent 1.2")); + assert!(calls.iter().all(|call| call.get("arguments").is_none())); + + crate::agent_console::feedback_sent(Some(&session), "bug", "navigate hangs", true); + let thread = crate::agent_console::snapshot().await.thread; + let item = thread + .iter() + .rev() + .find(|item| item.session_id.as_deref() == Some(session.as_str())) + .expect("the person sees the feedback"); + assert_eq!(item.kind, crate::agent_console::ThreadKind::Feedback); + assert_eq!(item.text, "navigate hangs"); + let feedback = item.feedback.as_ref().unwrap(); + assert_eq!((feedback.kind.as_str(), feedback.logs), ("bug", true)); + } } diff --git a/src-tauri/src/settings_manager.rs b/src-tauri/src/settings_manager.rs index 1e08d0e3..d0ea2728 100644 --- a/src-tauri/src/settings_manager.rs +++ b/src-tauri/src/settings_manager.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashSet}; use std::fs::{self, create_dir_all}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct TableSortingSettings { @@ -217,6 +217,10 @@ pub struct AppSettings { /// same wait. #[serde(default)] pub tips_next_auto_show_at: Option, + /// Unix seconds when the person last sent feedback from the app. Only the + /// feedback command writes it; the feedback tip is not offered once set. + #[serde(default)] + pub feedback_sent_at: Option, /// Cloud user ids that have had the paid-plan welcome. #[serde(default)] pub paid_welcome_seen_for: Vec, @@ -303,6 +307,7 @@ impl Default for AppSettings { tips_seen: Vec::new(), tips_last_auto_shown_at: None, tips_next_auto_show_at: None, + feedback_sent_at: None, paid_welcome_seen_for: Vec::new(), cloud_plan_memory: std::collections::HashMap::new(), require_route_for_launch: false, @@ -692,6 +697,7 @@ pub async fn save_app_settings( settings.window_resize_warning_dismissed = current.window_resize_warning_dismissed; settings.mcp_remote_enabled = current.mcp_remote_enabled; settings.mcp_remote_key_id = current.mcp_remote_key_id; + settings.feedback_sent_at = current.feedback_sent_at; } } else { settings.mcp_remote_enabled = false; @@ -735,37 +741,27 @@ pub async fn save_app_settings( Ok(settings) } -/// Read the most recent N log files concatenated into a single string, -/// suitable for paste-into-issue-tracker. Newest entries appear LAST so the -/// reader sees fresh context at the bottom of the buffer. Capped at 5 MB to -/// keep clipboard payloads sane. -#[tauri::command] -pub async fn read_log_files(app_handle: tauri::AppHandle) -> Result { - let dir = crate::app_dirs::log_dir(&app_handle); - if !dir.exists() { - return Err("Log directory does not exist yet".to_string()); - } +const TRUNCATED_MARK: &str = "[…truncated — older content elided…]\n"; - let mut entries: Vec<(std::path::PathBuf, std::time::SystemTime)> = std::fs::read_dir(&dir) - .map_err(|e| format!("Failed to read log dir: {e}"))? - .filter_map(|r| r.ok()) - .filter_map(|e| { - let p = e.path(); - let m = e.metadata().ok()?.modified().ok()?; - let ext = p.extension().and_then(|s| s.to_str()).unwrap_or(""); - if p.is_file() && (ext == "log" || ext == "txt") { - Some((p, m)) - } else { - None - } +/// The newest log files, redacted, each under a `===== name =====` header and +/// in chronological order so the freshest lines are at the bottom. At most +/// `max_bytes`: the oldest file that only fits in part keeps its last whole +/// lines. +pub(crate) fn recent_logs(dir: &Path, max_bytes: usize) -> std::io::Result { + let mut entries: Vec<(PathBuf, std::time::SystemTime)> = std::fs::read_dir(dir)? + .filter_map(Result::ok) + .filter_map(|entry| { + let path = entry.path(); + let modified = entry.metadata().ok()?.modified().ok()?; + let ext = path.extension().and_then(|s| s.to_str()).unwrap_or(""); + (path.is_file() && (ext == "log" || ext == "txt")).then_some((path, modified)) }) .collect(); + entries.sort_by_key(|(_, modified)| std::cmp::Reverse(*modified)); - entries.sort_by_key(|(_, m)| *m); - - const MAX_BYTES: usize = 5 * 1024 * 1024; - let mut out = String::with_capacity(64 * 1024); - for (path, _) in entries.iter().rev() { + let mut sections = Vec::new(); + let mut used = 0; + for (path, _) in entries { let header = format!( "===== {} =====\n", path @@ -773,38 +769,51 @@ pub async fn read_log_files(app_handle: tauri::AppHandle) -> Result= MAX_BYTES { + let Ok(raw) = std::fs::read(&path) else { + continue; + }; + let mut content = crate::log_redaction::text(&String::from_utf8_lossy(&raw)); + if !content.ends_with('\n') { + content.push('\n'); + } + let room = max_bytes.saturating_sub(used + header.len()); + if content.len() > room { + let tail = last_lines(&content, room.saturating_sub(TRUNCATED_MARK.len())); + if !tail.is_empty() { + sections.push(format!("{header}{TRUNCATED_MARK}{tail}")); + } break; } - out.push_str(&header); - if let Ok(content) = std::fs::read_to_string(path) { - let take = MAX_BYTES.saturating_sub(out.len()); - if take == 0 { - break; - } - if content.len() > take { - // Tail truncation — keep the END of older files so newest data is preserved. - out.push_str("[…truncated — older content elided…]\n"); - out.push_str(&content[content.len() - take + 64..]); - } else { - out.push_str(&content); - } - if !out.ends_with('\n') { - out.push('\n'); - } - } + used += header.len() + content.len(); + sections.push(format!("{header}{content}")); } - - // Reverse the per-file order so chronological newest is at the bottom. - // (We pushed newest-first above to budget the tail; flip now.) - let mut sections: Vec<&str> = out.split("===== ").filter(|s| !s.is_empty()).collect(); sections.reverse(); - let final_out = sections - .into_iter() - .map(|s| format!("===== {s}")) - .collect::(); + Ok(sections.concat()) +} - Ok(crate::log_redaction::text(&final_out)) +/// The whole lines at the end of `text` that fit in `max_bytes`. +fn last_lines(text: &str, max_bytes: usize) -> &str { + if text.len() <= max_bytes { + return text; + } + let start = text.len() - max_bytes; + // A newline byte is always a character boundary, so this never splits one. + match text.as_bytes()[start..].iter().position(|&b| b == b'\n') { + Some(offset) => &text[start + offset + 1..], + None => "", + } +} + +/// Read the most recent log files concatenated into a single string, +/// suitable for paste-into-issue-tracker. Capped at 5 MB to keep clipboard +/// payloads sane. +#[tauri::command] +pub async fn read_log_files(app_handle: tauri::AppHandle) -> Result { + let dir = crate::app_dirs::log_dir(&app_handle); + if !dir.exists() { + return Err("Log directory does not exist yet".to_string()); + } + recent_logs(&dir, 5 * 1024 * 1024).map_err(|e| format!("Failed to read log dir: {e}")) } /// Reveal the log directory in the OS file manager. @@ -1118,6 +1127,22 @@ pub async fn mark_tip_seen(tip_id: String, auto: bool) -> Result Result { let _serial = TIPS_WRITE @@ -1427,6 +1452,7 @@ mod tests { tips_seen: Vec::new(), tips_last_auto_shown_at: None, tips_next_auto_show_at: None, + feedback_sent_at: None, paid_welcome_seen_for: Vec::new(), cloud_plan_memory: std::collections::HashMap::new(), require_route_for_launch: true, @@ -1617,4 +1643,65 @@ mod tests { "Sorting file should end with table_sorting.json" ); } + + fn write_log(dir: &Path, name: &str, body: &[u8], age_secs: u64) { + let path = dir.join(name); + std::fs::write(&path, body).unwrap(); + let modified = std::time::SystemTime::now() - std::time::Duration::from_secs(age_secs); + std::fs::File::options() + .write(true) + .open(&path) + .unwrap() + .set_modified(modified) + .unwrap(); + } + + #[test] + fn recent_logs_put_the_newest_file_last_and_redact_it() { + let dir = TempDir::new().unwrap(); + write_log(dir.path(), "DonutBrowser_old.log", b"old line\n", 600); + write_log( + dir.path(), + "DonutBrowser.log", + b"new line user=me@example.com", + 0, + ); + write_log(dir.path(), "notes.json", b"skipped", 0); + + let logs = recent_logs(dir.path(), 1024).unwrap(); + assert_eq!( + logs, + "===== DonutBrowser_old.log =====\nold line\n===== DonutBrowser.log =====\nnew line user=\n" + ); + } + + #[test] + fn recent_logs_keep_whole_lines_within_the_budget() { + let dir = TempDir::new().unwrap(); + let mut old = Vec::new(); + for i in 0..200 { + old.extend_from_slice(format!("older é line {i}\n").as_bytes()); + } + old.extend_from_slice(&[0xff, 0xfe, b'\n']); + write_log(dir.path(), "a.log", &old, 600); + write_log(dir.path(), "b.log", b"newest\n", 0); + + let logs = recent_logs(dir.path(), 400).unwrap(); + assert!(logs.len() <= 400, "{} bytes", logs.len()); + assert!(logs.ends_with("===== b.log =====\nnewest\n"), "{logs}"); + let older = logs + .strip_prefix("===== a.log =====\n[…truncated — older content elided…]\n") + .expect("the older file is cut, not dropped"); + assert!(older.lines().all(|line| line.starts_with("older é line ") + || line == "\u{fffd}\u{fffd}" + || line.starts_with("=====") + || line == "newest")); + } + + #[test] + fn recent_logs_of_an_empty_directory_are_empty() { + let dir = TempDir::new().unwrap(); + assert_eq!(recent_logs(dir.path(), 1024).unwrap(), ""); + assert!(recent_logs(&dir.path().join("missing"), 1024).is_err()); + } } diff --git a/src/app/page.tsx b/src/app/page.tsx index cd2c4e7e..b4995829 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -22,6 +22,7 @@ import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialo import { DeviceCodeVerifyDialog } from "@/components/device-code-verify-dialog"; import { ExtensionGroupAssignmentDialog } from "@/components/extension-group-assignment-dialog"; import { ExtensionManagementDialog } from "@/components/extension-management-dialog"; +import { FeedbackDialog } from "@/components/feedback-dialog"; import { GroupAssignmentDialog } from "@/components/group-assignment-dialog"; import { GroupManagementDialog } from "@/components/group-management-dialog"; import HomeHeader from "@/components/home-header"; @@ -529,6 +530,7 @@ export default function Home() { useState(null); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); const [aboutDialogOpen, setAboutDialogOpen] = useState(false); + const [feedbackDialogOpen, setFeedbackDialogOpen] = useState(false); // Pre-launch gate. Requests queue instead of overwriting a single resolver: // a bulk run enqueues one per profile, and every waiter must settle or the // Promise.allSettled below it never resolves and the bulk spinner sticks. @@ -666,6 +668,9 @@ export default function Home() { case "palette": setCommandPaletteOpen(true); break; + case "feedback": + setFeedbackDialogOpen(true); + break; } }, [closeTips, handleRailNavigate], @@ -680,6 +685,9 @@ export default function Home() { case "openTips": openTips(); break; + case "sendFeedback": + setFeedbackDialogOpen(true); + break; case "openShortcuts": handleRailNavigate("shortcuts"); break; @@ -2319,6 +2327,9 @@ export default function Home() { setAboutDialogOpen(true); }} onOpenTips={() => openTips()} + onOpenFeedback={() => { + setFeedbackDialogOpen(true); + }} cookieBotRunning={Object.keys(cookieBotLiveSessions).length > 0} agentRequests={agentConsole.openRequests.length} /> @@ -2602,6 +2613,14 @@ export default function Home() { }} /> + { + setFeedbackDialogOpen(false); + }} + accountEmail={cloudUser?.email ?? null} + /> + + + + {nameOf(item.session_id)} + + {t("agent.thread.feedbackSent")} + + {time} + +
+
+ + ); + } + if (item.kind === "note") { const delivery = item.state === "delivered" diff --git a/src/components/command-palette.tsx b/src/components/command-palette.tsx index 4cb064e9..1fabae50 100644 --- a/src/components/command-palette.tsx +++ b/src/components/command-palette.tsx @@ -13,6 +13,7 @@ import { LuInfo, LuKeyboard, LuLightbulb, + LuMessageSquareHeart, LuPlay, LuPlug, LuPlus, @@ -67,6 +68,7 @@ const ICONS: Record> = { openPalette: LuKeyboard, openShortcuts: LuKeyboard, openTips: LuLightbulb, + sendFeedback: LuMessageSquareHeart, importProfile: FaDownload, goProfiles: LuUser, goProxies: FiWifi, diff --git a/src/components/feedback-dialog.tsx b/src/components/feedback-dialog.tsx new file mode 100644 index 00000000..3898cf3e --- /dev/null +++ b/src/components/feedback-dialog.tsx @@ -0,0 +1,468 @@ +"use client"; + +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import { invoke } from "@tauri-apps/api/core"; +import { motion, useReducedMotion } from "motion/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + LuBug, + LuCheck, + LuChevronRight, + LuFileText, + LuHeart, + LuLightbulb, + LuMail, + LuMessageSquareMore, + LuSend, +} from "react-icons/lu"; +import { LoadingButton } from "@/components/loading-button"; +import { AnimatedDisclosureChevron } from "@/components/ui/animated-disclosure"; +import { AnimatedSwitch } from "@/components/ui/animated-switch"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { SoftFields } from "@/components/ui/field-variant"; +import { Input } from "@/components/ui/input"; +import { StepTransition } from "@/components/ui/step-transition"; +import { Textarea } from "@/components/ui/textarea"; +import { useInputModality } from "@/hooks/use-input-modality"; +import type { FeedbackKind } from "@/lib/agent-console"; +import { translateBackendError } from "@/lib/backend-errors"; +import { formatBytes } from "@/lib/format-bytes"; +import { MOTION_SPRING_POSITION } from "@/lib/motion"; +import { isMacOS } from "@/lib/platform"; +import { cn } from "@/lib/utils"; +import { RippleButton } from "./ui/ripple"; + +/** Mirrors `feedback::MAX_MESSAGE_CHARS`. */ +const MAX_MESSAGE_CHARS = 5000; +/** The counter shows once the message gets this close to the limit. */ +const COUNTER_FROM = MAX_MESSAGE_CHARS - 500; + +const KINDS: { + id: FeedbackKind; + Icon: React.ComponentType<{ className?: string }>; +}[] = [ + { id: "bug", Icon: LuBug }, + { id: "idea", Icon: LuLightbulb }, + { id: "praise", Icon: LuHeart }, + { id: "other", Icon: LuMessageSquareMore }, +]; + +interface SystemInfo { + app_version: string; + os: string; + arch: string; +} + +interface FeedbackReceipt { + id: string; +} + +interface FeedbackDialogProps { + isOpen: boolean; + onClose: () => void; + /** The signed-in account's email; replies go there. */ + accountEmail: string | null; +} + +/** + * Feedback to the Donut team. A draft survives closing the dialog and is only + * cleared once it has been sent. Logs are on by default for a bug, the one + * kind where they nearly always matter, and the person can read exactly what + * would be attached before sending. + */ +export function FeedbackDialog({ + isOpen, + onClose, + accountEmail, +}: FeedbackDialogProps) { + const { t, i18n } = useTranslation(); + const reduceMotion = useReducedMotion(); + const inputModality = useInputModality(); + const animate = !reduceMotion && inputModality === "pointer"; + + const [kind, setKind] = useState("bug"); + const [message, setMessage] = useState(""); + const [includeLogs, setIncludeLogs] = useState(true); + const [logsChosen, setLogsChosen] = useState(false); + const [email, setEmail] = useState(""); + const [previewOpen, setPreviewOpen] = useState(false); + const [logs, setLogs] = useState(null); + const [logsLoading, setLogsLoading] = useState(false); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + const [sent, setSent] = useState<{ logs: boolean; replyTo: string | null }>(); + const [systemInfo, setSystemInfo] = useState(null); + const messageRef = useRef(null); + const previewRef = useRef(null); + + useEffect(() => { + if (!isOpen || systemInfo) return; + invoke("get_system_info") + .then(setSystemInfo) + .catch((err: unknown) => { + console.warn("Failed to read system info:", err); + }); + }, [isOpen, systemInfo]); + + const chooseKind = useCallback( + (next: FeedbackKind) => { + setKind(next); + if (!logsChosen) setIncludeLogs(next === "bug"); + }, + [logsChosen], + ); + + const loadLogs = useCallback(async () => { + setLogsLoading(true); + try { + setLogs(await invoke("preview_feedback_logs")); + } catch (err) { + setError(translateBackendError(t, err)); + } finally { + setLogsLoading(false); + } + }, [t]); + + const togglePreview = useCallback(() => { + const opening = !previewOpen; + setPreviewOpen(opening); + if (opening) void loadLogs(); + }, [loadLogs, previewOpen]); + + // The newest lines are at the end; that is what the reader wants to see. + useEffect(() => { + if (!previewOpen || logs === null || !previewRef.current) return; + previewRef.current.scrollTop = previewRef.current.scrollHeight; + }, [logs, previewOpen]); + + const handleClose = useCallback(() => { + if (sent) { + setSent(undefined); + setMessage(""); + setKind("bug"); + setIncludeLogs(true); + setLogsChosen(false); + setPreviewOpen(false); + setLogs(null); + } + setError(null); + onClose(); + }, [onClose, sent]); + + const canSend = message.trim().length > 0 && !sending; + + const send = useCallback(async () => { + if (!message.trim() || sending) return; + setSending(true); + setError(null); + try { + await invoke("send_feedback", { + kind, + message, + includeLogs, + email: accountEmail ? null : email.trim() || null, + locale: i18n.language, + }); + setSent({ + logs: includeLogs, + replyTo: accountEmail ?? (email.trim() || null), + }); + } catch (err) { + console.error("Failed to send feedback:", err); + setError(translateBackendError(t, err)); + } finally { + setSending(false); + } + }, [ + accountEmail, + email, + i18n.language, + includeLogs, + kind, + message, + sending, + t, + ]); + + const modKey = isMacOS() ? "⌘" : "Ctrl"; + const logsBytes = useMemo( + () => (logs === null ? null : new Blob([logs]).size), + [logs], + ); + + return ( + { + if (!open) handleClose(); + }} + > + { + event.preventDefault(); + messageRef.current?.focus(); + }} + onKeyDown={(event) => { + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + if (!sent) void send(); + } + }} + > + + {sent ? ( +
+ + + {t("feedback.success.title")} + + {sent.logs + ? t("feedback.success.bodyWithLogs") + : t("feedback.success.body")} + + {sent.replyTo && ( +

+ {t("feedback.success.reply", { email: sent.replyTo })} +

+ )} + + {t("feedback.success.done")} + +
+ ) : ( + <> + + {t("feedback.title")} + + {t("feedback.description")} + + + + + { + chooseKind(value as FeedbackKind); + }} + aria-label={t("feedback.kindLabel")} + className="grid grid-cols-4 gap-2" + > + {KINDS.map(({ id, Icon }) => ( + + + ))} + + +
+