feat: add ability to provide feedback

This commit is contained in:
zhom committed 2026-10-10 01:11:04 +04:00
1 parent bf625fac2c
commit 18a891f709
32 files changed
+2693 -106

No files matched your search

+3 -2
View File
@@ -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
+5
View File
@@ -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",
+254
View File
@@ -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();
}
});
+68
View File
@@ -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<String>,
pub done: Option<u64>,
pub total: Option<u64>,
/// Set on a `feedback` item: what the agent sent to the Donut team.
pub feedback: Option<ThreadFeedback>,
#[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<String>, Vec<serde_json::Value>) {
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<serde_json::Value> = 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);
+553
View File
@@ -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<Self> {
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<String>,
pub locale: Option<String>,
pub logs: Option<String>,
pub agent_client: Option<String>,
pub context: Option<serde_json::Value>,
}
#[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<String, String> {
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<Option<String>, 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<String, String> {
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<VecDeque<Instant>> = 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<Instant>, 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<FeedbackReceipt, String> {
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<reqwest::Client> = 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<u8>,
token: Option<String>,
) -> Result<FeedbackReceipt, String> {
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::<FeedbackReceipt>()
.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<String>,
locale: Option<String>,
) -> Result<FeedbackReceipt, String> {
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<String, String> {
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::<serde_json::Value>(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}"
);
}
}
+3
View File
@@ -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,
+204 -2
View File
@@ -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<serde_json::Value, McpError> {
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::<serde_json::Value>(&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));
}
}
+142 -55
View File
@@ -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<u64>,
/// 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<u64>,
/// Cloud user ids that have had the paid-plan welcome.
#[serde(default)]
pub paid_welcome_seen_for: Vec<String>,
@@ -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<String, String> {
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<String> {
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<String, Stri
.and_then(|name| name.to_str())
.unwrap_or("log")
);
if out.len() + header.len() >= 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::<String>();
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<String, String> {
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<TipsState, Stri
Ok(TipsState::of(&settings, now))
}
/// Remember that the person has sent feedback, so the feedback tip is not
/// offered again.
pub(crate) fn note_feedback_sent() {
let _serial = TIPS_WRITE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let manager = SettingsManager::instance();
let saved = manager.load_settings().and_then(|mut settings| {
settings.feedback_sent_at = Some(unix_now());
manager.save_settings(&settings)
});
if let Err(e) = saved {
log::warn!("Feedback sent time not saved err=\"{e}\"");
}
}
#[tauri::command]
pub async fn set_tips_auto_show(enabled: bool) -> Result<TipsState, String> {
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=<redacted-email>\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());
}
}
+19
View File
@@ -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<BrowserProfile | null>(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() {
}}
/>
<FeedbackDialog
isOpen={feedbackDialogOpen}
onClose={() => {
setFeedbackDialogOpen(false);
}}
accountEmail={cloudUser?.email ?? null}
/>
<PreLaunchGateDialog
isOpen={gateState !== null}
profileName={gateState?.req.profile.name ?? ""}
+40
View File
@@ -9,6 +9,8 @@ import {
LuChevronsUpDown,
LuHand,
LuMessageCircleQuestion,
LuMessageSquareHeart,
LuPaperclip,
LuSend,
LuUndo2,
} from "react-icons/lu";
@@ -500,6 +502,44 @@ function ThreadEntry({
);
}
if (item.kind === "feedback") {
return (
<div className="flex max-w-[85%] flex-col gap-1">
<span className="flex flex-wrap items-center gap-x-1.5 text-[11px] text-muted-foreground">
<span className="font-medium text-foreground">
{nameOf(item.session_id)}
</span>
<span>{t("agent.thread.feedbackSent")}</span>
<span aria-hidden="true">·</span>
{time}
</span>
<div className="flex items-start gap-2 rounded-xl bg-foreground/5 px-3 py-2 text-sm text-foreground">
<LuMessageSquareHeart
className="mt-0.5 size-3.5 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<div className="flex min-w-0 flex-col gap-1">
<p className="break-words whitespace-pre-wrap">{item.text}</p>
{item.feedback && (
<span className="flex flex-wrap items-center gap-x-1.5 text-[11px] text-muted-foreground">
<span>{t(`feedback.kinds.${item.feedback.kind}.label`)}</span>
{item.feedback.logs && (
<>
<span aria-hidden="true">·</span>
<span className="inline-flex items-center gap-1">
<LuPaperclip className="size-3" aria-hidden="true" />
{t("agent.thread.feedbackLogs")}
</span>
</>
)}
</span>
)}
</div>
</div>
</div>
);
}
if (item.kind === "note") {
const delivery =
item.state === "delivered"
+2
View File
@@ -13,6 +13,7 @@ import {
LuInfo,
LuKeyboard,
LuLightbulb,
LuMessageSquareHeart,
LuPlay,
LuPlug,
LuPlus,
@@ -67,6 +68,7 @@ const ICONS: Record<ShortcutId, React.ComponentType<{ className?: string }>> = {
openPalette: LuKeyboard,
openShortcuts: LuKeyboard,
openTips: LuLightbulb,
sendFeedback: LuMessageSquareHeart,
importProfile: FaDownload,
goProfiles: LuUser,
goProxies: FiWifi,
+468
View File
@@ -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<FeedbackKind>("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<string | null>(null);
const [logsLoading, setLogsLoading] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sent, setSent] = useState<{ logs: boolean; replyTo: string | null }>();
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
const messageRef = useRef<HTMLTextAreaElement>(null);
const previewRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (!isOpen || systemInfo) return;
invoke<SystemInfo>("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<string>("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<FeedbackReceipt>("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 (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) handleClose();
}}
>
<DialogContent
className="max-w-lg"
data-dialog="feedback"
onOpenAutoFocus={(event) => {
event.preventDefault();
messageRef.current?.focus();
}}
onKeyDown={(event) => {
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
if (!sent) void send();
}
}}
>
<StepTransition
transitionKey={sent ? "sent" : "compose"}
direction={sent ? 1 : -1}
className="grid min-w-0 gap-4"
>
{sent ? (
<div
data-slot="feedback-sent"
className="grid justify-items-center gap-2 py-6 text-center"
>
<motion.div
initial={animate ? { scale: 0.6 } : false}
animate={{ scale: 1 }}
transition={animate ? MOTION_SPRING_POSITION : { duration: 0 }}
className="mb-2 grid size-14 place-items-center rounded-full bg-success/15 text-success-text"
>
<LuCheck className="size-7" aria-hidden="true" />
</motion.div>
<DialogTitle>{t("feedback.success.title")}</DialogTitle>
<DialogDescription className="max-w-sm">
{sent.logs
? t("feedback.success.bodyWithLogs")
: t("feedback.success.body")}
</DialogDescription>
{sent.replyTo && (
<p className="max-w-sm text-xs break-words text-muted-foreground">
{t("feedback.success.reply", { email: sent.replyTo })}
</p>
)}
<RippleButton
autoFocus
className="mt-4 min-w-28"
onClick={handleClose}
>
{t("feedback.success.done")}
</RippleButton>
</div>
) : (
<>
<DialogHeader>
<DialogTitle>{t("feedback.title")}</DialogTitle>
<DialogDescription>
{t("feedback.description")}
</DialogDescription>
</DialogHeader>
<SoftFields>
<RadioGroupPrimitive.Root
value={kind}
onValueChange={(value) => {
chooseKind(value as FeedbackKind);
}}
aria-label={t("feedback.kindLabel")}
className="grid grid-cols-4 gap-2"
>
{KINDS.map(({ id, Icon }) => (
<RadioGroupPrimitive.Item
key={id}
value={id}
data-slot="feedback-kind"
data-kind={id}
className={cn(
"flex cursor-pointer flex-col items-center gap-1.5 rounded-lg px-2 py-2.5 text-xs font-medium transition-[color,background-color,box-shadow] duration-150 outline-none",
"bg-foreground/5 text-muted-foreground hover:bg-foreground/8 hover:text-foreground",
"focus-visible:ring-2 focus-visible:ring-ring",
"data-[state=checked]:bg-primary/10 data-[state=checked]:text-foreground data-[state=checked]:inset-ring data-[state=checked]:inset-ring-primary/50",
)}
>
<Icon className="size-4" aria-hidden="true" />
<span className="max-w-full truncate">
{t(`feedback.kinds.${id}.label`)}
</span>
</RadioGroupPrimitive.Item>
))}
</RadioGroupPrimitive.Root>
<div className="grid gap-1">
<Textarea
ref={messageRef}
data-slot="feedback-message"
aria-label={t("feedback.messageLabel")}
placeholder={t(`feedback.kinds.${kind}.placeholder`)}
value={message}
maxLength={MAX_MESSAGE_CHARS}
rows={6}
className="min-h-32 resize-none"
onChange={(event) => {
setMessage(event.target.value);
}}
/>
{message.length >= COUNTER_FROM && (
<p className="text-right text-[11px] text-muted-foreground tabular-nums">
{t("feedback.counter", {
count: message.length,
max: MAX_MESSAGE_CHARS,
})}
</p>
)}
</div>
<div className="grid gap-2 rounded-lg bg-foreground/5 p-3">
<div className="flex items-start gap-3">
<LuFileText
className="mt-0.5 size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<div className="grid min-w-0 flex-1 gap-0.5">
<label
htmlFor="feedback-include-logs"
className="cursor-pointer text-sm font-medium"
>
{t("feedback.logs.label")}
</label>
<p className="text-xs text-muted-foreground">
{t("feedback.logs.description")}
</p>
</div>
<AnimatedSwitch
id="feedback-include-logs"
data-slot="feedback-include-logs"
checked={includeLogs}
onCheckedChange={(checked) => {
setIncludeLogs(checked);
setLogsChosen(true);
}}
/>
</div>
{includeLogs && (
<div className="pl-7">
<button
type="button"
data-slot="feedback-preview-logs"
aria-expanded={previewOpen}
onClick={togglePreview}
className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<AnimatedDisclosureChevron open={previewOpen}>
<LuChevronRight className="size-3" />
</AnimatedDisclosureChevron>
{t("feedback.logs.preview")}
{logsBytes !== null && logsBytes > 0 && (
<span className="tabular-nums">
· {formatBytes(logsBytes)}
</span>
)}
</button>
{previewOpen && (
<textarea
ref={previewRef}
readOnly
data-slot="feedback-logs-preview"
aria-label={t("feedback.logs.preview")}
rows={10}
spellCheck={false}
value={
logsLoading && logs === null
? t("feedback.logs.loading")
: logs
? logs
: t("feedback.logs.empty")
}
className="mt-2 block h-44 w-full resize-none overflow-auto rounded-md bg-background p-2 font-mono text-[10px] leading-relaxed break-all whitespace-pre-wrap text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
/>
)}
</div>
)}
</div>
{accountEmail ? (
<p className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<LuMail className="size-3.5 shrink-0" aria-hidden="true" />
<span className="min-w-0 truncate">
{t("feedback.email.signedIn", { email: accountEmail })}
</span>
</p>
) : (
<div className="grid gap-1.5">
<label
htmlFor="feedback-email"
className="flex items-baseline gap-1.5 text-sm font-medium"
>
{t("feedback.email.label")}
<span className="text-xs font-normal text-muted-foreground">
{t("feedback.email.optional")}
</span>
</label>
<Input
id="feedback-email"
data-slot="feedback-email"
type="email"
autoComplete="email"
placeholder={t("feedback.email.placeholder")}
value={email}
onChange={(event) => {
setEmail(event.target.value);
}}
/>
</div>
)}
{error && (
<p
role="alert"
className="rounded-md bg-destructive/10 p-3 text-sm break-words text-destructive-text"
>
{error}
</p>
)}
</SoftFields>
<DialogFooter className="items-center">
{systemInfo && (
<p className="mr-auto min-w-0 truncate text-[11px] text-muted-foreground">
{t("feedback.included", {
version: systemInfo.app_version,
os: systemInfo.os,
arch: systemInfo.arch,
})}
</p>
)}
<RippleButton
variant="outline"
onClick={handleClose}
disabled={sending}
>
{t("common.buttons.cancel")}
</RippleButton>
<LoadingButton
data-slot="feedback-send"
isLoading={sending}
disabled={!canSend}
onClick={() => void send()}
>
<LuSend className="size-3.5" aria-hidden="true" />
{t("feedback.send")}
<kbd className="ml-1 hidden rounded bg-primary-foreground/15 px-1 font-sans text-[10px] leading-4 sm:inline">
{modKey}↵
</kbd>
</LoadingButton>
</DialogFooter>
</>
)}
</StepTransition>
</DialogContent>
</Dialog>
);
}
+25
View File
@@ -13,6 +13,7 @@ import {
LuInfo,
LuKeyboard,
LuLightbulb,
LuMessageSquareHeart,
LuPlug,
LuPuzzle,
LuTrash2,
@@ -221,6 +222,7 @@ interface RailNavProps {
onOpenAbout: () => void;
/** Opens the feature tips catalog. */
onOpenTips: () => void;
onOpenFeedback: () => void;
/**
* A remote session is running right now. The Cookie Bot item carries a dot so
* the state is legible from every other page — an overnight job you cannot
@@ -297,6 +299,7 @@ export function RailNav({
onNavigate,
onOpenAbout,
onOpenTips,
onOpenFeedback,
cookieBotRunning = false,
agentRequests = 0,
}: RailNavProps) {
@@ -537,6 +540,28 @@ export function RailNav({
</span>
</span>
</button>
<button
type="button"
role="menuitem"
data-slot="rail-open-feedback"
onClick={() => {
setMoreOpen(false);
onOpenFeedback();
}}
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-100 hover:bg-accent hover:text-accent-foreground"
>
<span className="grid size-5 shrink-0 place-items-center text-muted-foreground">
<LuMessageSquareHeart className="size-3" />
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate text-xs font-medium text-foreground">
{t("rail.more.feedback")}
</span>
<span className="truncate text-[10px] text-muted-foreground">
{t("rail.more.feedbackHint")}
</span>
</span>
</button>
<button
type="button"
role="menuitem"
+3
View File
@@ -7,6 +7,7 @@ import {
ConsistencyScene,
DnsScene,
ExtensionsScene,
FeedbackScene,
GroupsScene,
ImportScene,
LinkRouteScene,
@@ -55,6 +56,8 @@ export function TipScene({ id }: { id: TipId }) {
return <ApiScene variant="api" />;
case "importProfiles":
return <ImportScene />;
case "feedback":
return <FeedbackScene />;
case "cloudBackup":
return <CloudSyncScene />;
case "cookieBot":
+85
View File
@@ -743,3 +743,88 @@ export function ImportScene() {
</Scene>
);
}
const FEEDBACK_ARC = travel(
bezier(
{ x: 116, y: 64 },
{ x: 150, y: 10 },
{ x: 214, y: 10 },
{ x: 250, y: 62 },
16,
),
0.5,
0.78,
);
/** A message is written, the logs clip on, and it flies to the Donut team. */
export function FeedbackScene() {
const { kf, tr } = useScene(5);
return (
<Scene>
<Window x={16} y={30} width={104} height={100} lines={0} />
<path
d="M30 60 h72 a6 6 0 0 1 6 6 v26 a6 6 0 0 1 -6 6 h-52 l-10 8 v-8 h-10 a6 6 0 0 1 -6 -6 v-26 a6 6 0 0 1 6 -6 z"
className="text-foreground"
/>
{[52, 40, 28].map((width, index) => (
<motion.path
key={width}
d={`M36 ${70 + index * 9} h${width}`}
strokeWidth={2}
className="text-foreground"
initial={false}
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, 0.06 + index * 0.1, 0.15 + index * 0.1, 1], {
ease: "linear",
})}
/>
))}
<motion.g
initial={false}
animate={{ opacity: kf([0, 0, 1, 1]), y: kf([8, 8, 0, 0]) }}
transition={tr([0, 0.36, 0.46, 1])}
>
<rect
x={92}
y={88}
width={22}
height={28}
rx={3}
className="fill-background"
/>
<path d="M97 96 h12 M97 102 h12 M97 108 h7" />
</motion.g>
<path d="M126 58 C154 14 210 14 238 56" strokeDasharray="2 5" />
<motion.g
initial={false}
animate={{ opacity: kf([0, 0, 1, 1, 0, 0]) }}
transition={tr([0, 0.48, 0.5, 0.76, 0.8, 1])}
>
<motion.g
className="text-foreground"
initial={false}
animate={{ x: kf(FEEDBACK_ARC.cx), y: kf(FEEDBACK_ARC.cy) }}
transition={tr(FEEDBACK_ARC.times, { ease: "linear" })}
>
<path d="M-7 1 L8 -6 L3 8 L0 2 Z M0 2 L8 -6" />
</motion.g>
</motion.g>
<motion.g
className="text-foreground"
initial={false}
animate={{ scale: kf([1, 1, 1.12, 1, 1]) }}
transition={tr([0, 0.78, 0.84, 0.9, 1])}
>
<circle cx={263} cy={84} r={18} />
<circle cx={263} cy={84} r={7} />
</motion.g>
<Check
x={286}
y={50}
className="text-success-text"
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, 0.82, 0.92, 1])}
/>
</Scene>
);
}
+10 -1
View File
@@ -43,6 +43,7 @@ interface UsageSettings {
api_enabled?: boolean;
mcp_remote_enabled?: boolean;
fingerprint_gate_disabled?: boolean;
feedback_sent_at?: number | null;
}
/** A slow read must not hold the tip back for long. */
@@ -96,7 +97,13 @@ async function loadFeatureUsage(
const wants = (...ids: TipId[]) => ids.some((id) => candidates.has(id));
const [settings, proxyChecked, isDefaultBrowser, trashUsed, agentUsed] =
await Promise.all([
wants("localApi", "automation", "remoteControl", "fingerprintGate")
wants(
"localApi",
"automation",
"remoteControl",
"fingerprintGate",
"feedback",
)
? known(invoke<UsageSettings>("get_app_settings"))
: undefined,
wants("proxyCheck") ? known(anyProxyChecked(source.proxyIds)) : undefined,
@@ -131,6 +138,8 @@ async function loadFeatureUsage(
isDefaultBrowser,
trashUsed,
agentUsed,
feedbackSent:
settings === undefined ? undefined : settings.feedback_sent_at != null,
};
}
+69 -4
View File
@@ -2419,7 +2419,14 @@
"wayfernLaunchArgsLimit": "Verwende höchstens {{count}} Startargumente mit jeweils bis zu {{length}} Zeichen.",
"tablePreferencesInvalid": "Diese Tabelleneinstellungen sind ungültig.",
"tablePreferencesLoadFailed": "Die Tabelleneinstellungen konnten nicht geladen werden. Versuchen Sie es erneut.",
"tablePreferencesSaveFailed": "Die Tabelleneinstellungen konnten nicht gespeichert werden. Versuchen Sie es erneut."
"tablePreferencesSaveFailed": "Die Tabelleneinstellungen konnten nicht gespeichert werden. Versuchen Sie es erneut.",
"feedbackMessageEmpty": "Schreiben Sie zuerst eine Nachricht.",
"feedbackMessageTooLong": "Die Nachricht ist zu lang. Das Limit liegt bei {{max}} Zeichen.",
"feedbackEmailInvalid": "Diese E-Mail-Adresse sieht nicht richtig aus.",
"feedbackInvalid": "Das Feedback wurde nicht angenommen. Prüfen Sie die Nachricht und versuchen Sie es erneut.",
"feedbackRateLimited": "Sie haben in kurzer Zeit viel Feedback gesendet. Versuchen Sie es in ein paar Minuten erneut.",
"feedbackSendFailed": "Das Feedback konnte nicht gesendet werden. Versuchen Sie es später erneut.",
"feedbackLogsUnreadable": "Die Protokolle konnten nicht gelesen werden. Schalten Sie „Aktuelle Protokolle anhängen“ aus und senden Sie erneut."
},
"rail": {
"profiles": "Profile",
@@ -2438,7 +2445,9 @@
"trash": "Papierkorb",
"trashHint": "Gelöschte Profile wiederherstellen",
"tips": "Tipps",
"tipsHint": "Funktionen Schritt für Schritt"
"tipsHint": "Funktionen Schritt für Schritt",
"feedback": "Feedback senden",
"feedbackHint": "Fehler melden oder Idee teilen"
},
"network": "Netzwerk",
"integrations": "Integrationen",
@@ -2557,7 +2566,8 @@
"goCookieBot": "Cookie Bot",
"goTrash": "Zum Papierkorb wechseln",
"goAgent": "Zu „Agent“ wechseln",
"openTips": "Tipps öffnen"
"openTips": "Tipps öffnen",
"sendFeedback": "Feedback senden"
},
"closeConfirm": {
"title": "Donut Browser schließen?",
@@ -3290,7 +3300,9 @@
"noteSeenAt": "Gesehen um {{time}}",
"noteSeen": "Gesehen",
"joined": "{{agent}} ist beigetreten",
"left": "{{agent}} hat die Sitzung verlassen"
"left": "{{agent}} hat die Sitzung verlassen",
"feedbackSent": "hat dem Donut-Team Feedback gesendet",
"feedbackLogs": "Protokolle angehängt"
},
"composer": {
"label": "Notiz für Ihren Agenten",
@@ -3570,6 +3582,12 @@
"title": "Diesen Desktop von donutbrowser.com aus steuern",
"body": "Bei aktivierter Fernsteuerung erreichen Agenten auf der Website die Profile dieses Rechners über eine ausgehende Verbindung. Aktivieren Sie sie unter „Integrationen“.",
"action": "Integrationen öffnen"
},
"feedback": {
"label": "Feedback",
"title": "Sagen Sie dem Team, was als Nächstes besser werden soll",
"body": "Funktioniert etwas nicht oder fehlt etwas? Senden Sie Feedback über das Menü „Mehr“. Ein Schalter hängt Ihre Protokolle an, damit das Team genau sieht, was schiefging.",
"action": "Feedback senden"
}
}
},
@@ -3613,5 +3631,52 @@
"noTags": "Keine Tags",
"noProxy": "Kein Proxy",
"encrypted": "Verschlüsselt"
},
"feedback": {
"title": "Feedback senden",
"description": "Sagen Sie dem Donut-Team, was Sie beschäftigt. Wir lesen jede Nachricht.",
"kindLabel": "Art des Feedbacks",
"kinds": {
"bug": {
"label": "Fehler",
"placeholder": "Was ist passiert, und was haben Sie erwartet? Schritte zum Nachstellen helfen sehr."
},
"idea": {
"label": "Idee",
"placeholder": "Was würde Donut für Sie besser machen?"
},
"praise": {
"label": "Lob",
"placeholder": "Was gefällt Ihnen? Das Team freut sich darüber."
},
"other": {
"label": "Sonstiges",
"placeholder": "Schreiben Sie uns, was Sie möchten."
}
},
"messageLabel": "Nachricht",
"counter": "{{count}} / {{max}}",
"logs": {
"label": "Aktuelle Protokolle anhängen",
"description": "Protokolle helfen uns, Fehler zu finden. E-Mail-Adressen, IP-Adressen, Webadressen und Geheimnisse werden vorher entfernt.",
"preview": "Protokolle ansehen",
"loading": "Protokolle werden gelesen…",
"empty": "Es gibt noch keine Protokolle."
},
"email": {
"label": "E-Mail für eine Antwort",
"optional": "Optional",
"placeholder": "sie@example.com",
"signedIn": "Antworten gehen an {{email}}."
},
"included": "Wird mitgesendet: Donut {{version}}, {{os}} {{arch}}",
"send": "Senden",
"success": {
"title": "Vielen Dank!",
"body": "Ihr Feedback ist beim Donut-Team angekommen.",
"bodyWithLogs": "Ihr Feedback und Ihre Protokolle sind beim Donut-Team angekommen.",
"reply": "Wenn wir mehr Details brauchen, schreiben wir an {{email}}.",
"done": "Fertig"
}
}
}
+69 -4
View File
@@ -2419,7 +2419,14 @@
"wayfernLaunchArgsLimit": "Use at most {{count}} launch arguments, each up to {{length}} characters.",
"tablePreferencesInvalid": "These table settings are invalid.",
"tablePreferencesLoadFailed": "Could not load the table settings. Try again.",
"tablePreferencesSaveFailed": "Could not save the table settings. Try again."
"tablePreferencesSaveFailed": "Could not save the table settings. Try again.",
"feedbackMessageEmpty": "Write a message first.",
"feedbackMessageTooLong": "The message is too long. The limit is {{max}} characters.",
"feedbackEmailInvalid": "This email address does not look right.",
"feedbackInvalid": "The feedback was not accepted. Check the message and try again.",
"feedbackRateLimited": "You sent a lot of feedback in a short time. Try again in a few minutes.",
"feedbackSendFailed": "The feedback could not be sent. Try again later.",
"feedbackLogsUnreadable": "The logs could not be read. Turn off \"Include recent logs\" and send again."
},
"rail": {
"profiles": "Profiles",
@@ -2438,7 +2445,9 @@
"trash": "Trash",
"trashHint": "Restore deleted profiles",
"tips": "Tips",
"tipsHint": "Feature walkthroughs"
"tipsHint": "Feature walkthroughs",
"feedback": "Send feedback",
"feedbackHint": "Report a bug or share an idea"
},
"network": "Network",
"integrations": "Integrations",
@@ -2557,7 +2566,8 @@
"goCookieBot": "Cookie Bot",
"goTrash": "Go to Trash",
"goAgent": "Go to Agent",
"openTips": "Open tips"
"openTips": "Open tips",
"sendFeedback": "Send feedback"
},
"closeConfirm": {
"title": "Close Donut Browser?",
@@ -3290,7 +3300,9 @@
"noteSeenAt": "Seen at {{time}}",
"noteSeen": "Seen",
"joined": "{{agent}} joined",
"left": "{{agent}} left"
"left": "{{agent}} left",
"feedbackSent": "sent feedback to the Donut team",
"feedbackLogs": "Logs attached"
},
"composer": {
"label": "Note for your agent",
@@ -3570,6 +3582,12 @@
"title": "Drive this desktop from donutbrowser.com",
"body": "With remote control on, agents on the website reach this machine's profiles over an outbound bridge. Turn it on under Integrations.",
"action": "Open Integrations"
},
"feedback": {
"label": "Feedback",
"title": "Tell the team what to fix next",
"body": "Something broken or missing? Send feedback from the More menu. One switch attaches your logs, so the team sees exactly what went wrong.",
"action": "Send feedback"
}
}
},
@@ -3613,5 +3631,52 @@
"noTags": "No tags",
"noProxy": "No proxy",
"encrypted": "Encrypted"
},
"feedback": {
"title": "Send feedback",
"description": "Tell the Donut team what is on your mind. We read every message.",
"kindLabel": "Type of feedback",
"kinds": {
"bug": {
"label": "Bug",
"placeholder": "What happened, and what did you expect? Steps to repeat it help a lot."
},
"idea": {
"label": "Idea",
"placeholder": "What would make Donut better for you?"
},
"praise": {
"label": "Praise",
"placeholder": "What do you like? The team will be glad to hear it."
},
"other": {
"label": "Other",
"placeholder": "Tell us anything."
}
},
"messageLabel": "Message",
"counter": "{{count}} / {{max}}",
"logs": {
"label": "Include recent logs",
"description": "Logs help us find bugs. Email addresses, IP addresses, web addresses and secrets are removed first.",
"preview": "Preview logs",
"loading": "Reading logs…",
"empty": "There are no logs yet."
},
"email": {
"label": "Email for a reply",
"optional": "Optional",
"placeholder": "you@example.com",
"signedIn": "Replies go to {{email}}."
},
"included": "Also sent: Donut {{version}}, {{os}} {{arch}}",
"send": "Send",
"success": {
"title": "Thank you!",
"body": "Your feedback is with the Donut team.",
"bodyWithLogs": "Your feedback and logs are with the Donut team.",
"reply": "If we need more details, we will write to {{email}}.",
"done": "Done"
}
}
}
+69 -4
View File
@@ -2429,7 +2429,14 @@
"wayfernLaunchArgsLimit": "Usa como máximo {{count}} argumentos de inicio, de hasta {{length}} caracteres cada uno.",
"tablePreferencesInvalid": "La configuración de la tabla no es válida.",
"tablePreferencesLoadFailed": "No se pudo cargar la configuración de la tabla. Inténtalo de nuevo.",
"tablePreferencesSaveFailed": "No se pudo guardar la configuración de la tabla. Inténtalo de nuevo."
"tablePreferencesSaveFailed": "No se pudo guardar la configuración de la tabla. Inténtalo de nuevo.",
"feedbackMessageEmpty": "Escribe un mensaje primero.",
"feedbackMessageTooLong": "El mensaje es demasiado largo. El límite es de {{max}} caracteres.",
"feedbackEmailInvalid": "Esta dirección de correo no parece correcta.",
"feedbackInvalid": "Los comentarios no se aceptaron. Revisa el mensaje e inténtalo de nuevo.",
"feedbackRateLimited": "Enviaste muchos comentarios en poco tiempo. Inténtalo de nuevo en unos minutos.",
"feedbackSendFailed": "No se pudieron enviar los comentarios. Inténtalo más tarde.",
"feedbackLogsUnreadable": "No se pudieron leer los registros. Desactiva «Incluir registros recientes» y envía de nuevo."
},
"rail": {
"profiles": "Perfiles",
@@ -2448,7 +2455,9 @@
"trash": "Papelera",
"trashHint": "Restaurar perfiles eliminados",
"tips": "Consejos",
"tipsHint": "Recorridos por las funciones"
"tipsHint": "Recorridos por las funciones",
"feedback": "Enviar comentarios",
"feedbackHint": "Informa de un error o comparte una idea"
},
"network": "Red",
"integrations": "Integraciones",
@@ -2567,7 +2576,8 @@
"goCookieBot": "Cookie Bot",
"goTrash": "Ir a la Papelera",
"goAgent": "Ir a Agente",
"openTips": "Abrir consejos"
"openTips": "Abrir consejos",
"sendFeedback": "Enviar comentarios"
},
"closeConfirm": {
"title": "¿Cerrar Donut Browser?",
@@ -3323,7 +3333,9 @@
"noteSeenAt": "Vista a las {{time}}",
"noteSeen": "Vista",
"joined": "{{agent}} se unió",
"left": "{{agent}} se fue"
"left": "{{agent}} se fue",
"feedbackSent": "envió comentarios al equipo de Donut",
"feedbackLogs": "Registros adjuntos"
},
"composer": {
"label": "Nota para tu agente",
@@ -3603,6 +3615,12 @@
"title": "Controla este equipo desde donutbrowser.com",
"body": "Con el control remoto activado, los agentes del sitio web llegan a los perfiles de esta máquina a través de un puente saliente. Actívalo en Integraciones.",
"action": "Abrir Integraciones"
},
"feedback": {
"label": "Comentarios",
"title": "Dile al equipo qué arreglar después",
"body": "¿Algo falla o echas algo en falta? Envía comentarios desde el menú Más. Un interruptor adjunta tus registros para que el equipo vea exactamente qué salió mal.",
"action": "Enviar comentarios"
}
}
},
@@ -3646,5 +3664,52 @@
"noTags": "Sin etiquetas",
"noProxy": "Sin proxy",
"encrypted": "Cifrado"
},
"feedback": {
"title": "Enviar comentarios",
"description": "Cuéntale al equipo de Donut lo que piensas. Leemos todos los mensajes.",
"kindLabel": "Tipo de comentario",
"kinds": {
"bug": {
"label": "Error",
"placeholder": "¿Qué pasó y qué esperabas? Los pasos para reproducirlo ayudan mucho."
},
"idea": {
"label": "Idea",
"placeholder": "¿Qué haría que Donut fuera mejor para ti?"
},
"praise": {
"label": "Elogio",
"placeholder": "¿Qué te gusta? Al equipo le encantará leerlo."
},
"other": {
"label": "Otro",
"placeholder": "Cuéntanos lo que quieras."
}
},
"messageLabel": "Mensaje",
"counter": "{{count}} / {{max}}",
"logs": {
"label": "Incluir registros recientes",
"description": "Los registros nos ayudan a encontrar errores. Antes se eliminan las direcciones de correo, las direcciones IP, las direcciones web y los secretos.",
"preview": "Ver registros",
"loading": "Leyendo registros…",
"empty": "Todavía no hay registros."
},
"email": {
"label": "Correo para responderte",
"optional": "Opcional",
"placeholder": "tu@example.com",
"signedIn": "Las respuestas irán a {{email}}."
},
"included": "También se envía: Donut {{version}}, {{os}} {{arch}}",
"send": "Enviar",
"success": {
"title": "¡Gracias!",
"body": "Tus comentarios ya están con el equipo de Donut.",
"bodyWithLogs": "Tus comentarios y registros ya están con el equipo de Donut.",
"reply": "Si necesitamos más detalles, te escribiremos a {{email}}.",
"done": "Listo"
}
}
}
+69 -4
View File
@@ -2429,7 +2429,14 @@
"wayfernLaunchArgsLimit": "Utilisez au maximum {{count}} arguments de lancement, de {{length}} caractères chacun au plus.",
"tablePreferencesInvalid": "Ces paramètres du tableau sont invalides.",
"tablePreferencesLoadFailed": "Impossible de charger les paramètres du tableau. Réessayez.",
"tablePreferencesSaveFailed": "Impossible d’enregistrer les paramètres du tableau. Réessayez."
"tablePreferencesSaveFailed": "Impossible d’enregistrer les paramètres du tableau. Réessayez.",
"feedbackMessageEmpty": "Écrivez d'abord un message.",
"feedbackMessageTooLong": "Le message est trop long. La limite est de {{max}} caractères.",
"feedbackEmailInvalid": "Cette adresse e-mail ne semble pas correcte.",
"feedbackInvalid": "L'avis n'a pas été accepté. Vérifiez le message et réessayez.",
"feedbackRateLimited": "Vous avez envoyé beaucoup d'avis en peu de temps. Réessayez dans quelques minutes.",
"feedbackSendFailed": "L'avis n'a pas pu être envoyé. Réessayez plus tard.",
"feedbackLogsUnreadable": "Les journaux n'ont pas pu être lus. Désactivez « Joindre les journaux récents » et renvoyez."
},
"rail": {
"profiles": "Profils",
@@ -2448,7 +2455,9 @@
"trash": "Corbeille",
"trashHint": "Restaurer des profils supprimés",
"tips": "Astuces",
"tipsHint": "Découverte des fonctions"
"tipsHint": "Découverte des fonctions",
"feedback": "Envoyer un avis",
"feedbackHint": "Signaler un bug ou partager une idée"
},
"network": "Réseau",
"integrations": "Intégrations",
@@ -2567,7 +2576,8 @@
"goCookieBot": "Cookie Bot",
"goTrash": "Aller à la Corbeille",
"goAgent": "Aller à Agent",
"openTips": "Ouvrir les astuces"
"openTips": "Ouvrir les astuces",
"sendFeedback": "Envoyer un avis"
},
"closeConfirm": {
"title": "Fermer Donut Browser ?",
@@ -3323,7 +3333,9 @@
"noteSeenAt": "Vue à {{time}}",
"noteSeen": "Vue",
"joined": "{{agent}} a rejoint la conversation",
"left": "{{agent}} est parti"
"left": "{{agent}} est parti",
"feedbackSent": "a envoyé un avis à l'équipe Donut",
"feedbackLogs": "Journaux joints"
},
"composer": {
"label": "Note pour votre agent",
@@ -3603,6 +3615,12 @@
"title": "Pilotez ce poste depuis donutbrowser.com",
"body": "Avec le contrôle à distance activé, les agents du site web atteignent les profils de cette machine par un pont sortant. Activez-le dans Intégrations.",
"action": "Ouvrir Intégrations"
},
"feedback": {
"label": "Avis",
"title": "Dites à l'équipe quoi corriger ensuite",
"body": "Quelque chose ne marche pas ou vous manque ? Envoyez un avis depuis le menu Plus. Un interrupteur joint vos journaux, pour que l'équipe voie exactement ce qui s'est passé.",
"action": "Envoyer un avis"
}
}
},
@@ -3646,5 +3664,52 @@
"noTags": "Aucune étiquette",
"noProxy": "Aucun proxy",
"encrypted": "Chiffré"
},
"feedback": {
"title": "Envoyer un avis",
"description": "Dites à l'équipe Donut ce que vous en pensez. Nous lisons chaque message.",
"kindLabel": "Type d'avis",
"kinds": {
"bug": {
"label": "Bug",
"placeholder": "Que s'est-il passé, et à quoi vous attendiez-vous ? Les étapes pour le reproduire aident beaucoup."
},
"idea": {
"label": "Idée",
"placeholder": "Qu'est-ce qui rendrait Donut meilleur pour vous ?"
},
"praise": {
"label": "Compliment",
"placeholder": "Qu'est-ce qui vous plaît ? L'équipe sera ravie de le lire."
},
"other": {
"label": "Autre",
"placeholder": "Dites-nous ce que vous voulez."
}
},
"messageLabel": "Message",
"counter": "{{count}} / {{max}}",
"logs": {
"label": "Joindre les journaux récents",
"description": "Les journaux nous aident à trouver les bugs. Les adresses e-mail, adresses IP, adresses web et secrets sont retirés avant l'envoi.",
"preview": "Voir les journaux",
"loading": "Lecture des journaux…",
"empty": "Il n'y a pas encore de journaux."
},
"email": {
"label": "E-mail pour la réponse",
"optional": "Facultatif",
"placeholder": "vous@example.com",
"signedIn": "Les réponses iront à {{email}}."
},
"included": "Envoyé aussi : Donut {{version}}, {{os}} {{arch}}",
"send": "Envoyer",
"success": {
"title": "Merci !",
"body": "Votre avis est bien arrivé chez l'équipe Donut.",
"bodyWithLogs": "Votre avis et vos journaux sont bien arrivés chez l'équipe Donut.",
"reply": "Si nous avons besoin de détails, nous vous écrirons à {{email}}.",
"done": "Terminé"
}
}
}
+69 -4
View File
@@ -2419,7 +2419,14 @@
"wayfernLaunchArgsLimit": "起動引数は {{count}} 個まで、1 つあたり {{length}} 文字までにしてください。",
"tablePreferencesInvalid": "テーブル設定が無効です。",
"tablePreferencesLoadFailed": "テーブル設定を読み込めませんでした。再試行してください。",
"tablePreferencesSaveFailed": "テーブル設定を保存できませんでした。再試行してください。"
"tablePreferencesSaveFailed": "テーブル設定を保存できませんでした。再試行してください。",
"feedbackMessageEmpty": "先にメッセージを入力してください。",
"feedbackMessageTooLong": "メッセージが長すぎます。上限は {{max}} 文字です。",
"feedbackEmailInvalid": "このメールアドレスは正しくないようです。",
"feedbackInvalid": "フィードバックを受け付けられませんでした。メッセージを確認して、もう一度お試しください。",
"feedbackRateLimited": "短時間に多くのフィードバックが送信されました。数分後にもう一度お試しください。",
"feedbackSendFailed": "フィードバックを送信できませんでした。しばらくしてからもう一度お試しください。",
"feedbackLogsUnreadable": "ログを読み込めませんでした。「最近のログを添付」をオフにして、もう一度送信してください。"
},
"rail": {
"profiles": "プロファイル",
@@ -2438,7 +2445,9 @@
"trash": "ゴミ箱",
"trashHint": "削除したプロファイルを復元",
"tips": "ヒント",
"tipsHint": "機能の使い方"
"tipsHint": "機能の使い方",
"feedback": "フィードバックを送信",
"feedbackHint": "不具合の報告やアイデアの共有"
},
"network": "ネットワーク",
"integrations": "連携",
@@ -2557,7 +2566,8 @@
"goCookieBot": "Cookie Bot",
"goTrash": "ゴミ箱へ移動",
"goAgent": "エージェントへ移動",
"openTips": "ヒントを開く"
"openTips": "ヒントを開く",
"sendFeedback": "フィードバックを送信"
},
"closeConfirm": {
"title": "Donut Browser を閉じますか?",
@@ -3290,7 +3300,9 @@
"noteSeenAt": "{{time}} に既読",
"noteSeen": "既読",
"joined": "{{agent}} が参加しました",
"left": "{{agent}} が退出しました"
"left": "{{agent}} が退出しました",
"feedbackSent": "が Donut チームにフィードバックを送信しました",
"feedbackLogs": "ログ添付あり"
},
"composer": {
"label": "エージェントへのメモ",
@@ -3570,6 +3582,12 @@
"title": "donutbrowser.com からこのデスクトップを操作",
"body": "リモートコントロールを有効にすると、ウェブサイト上のエージェントが送信方向のブリッジ経由でこのマシンのプロファイルにアクセスできます。「連携」で有効にしてください。",
"action": "連携を開く"
},
"feedback": {
"label": "フィードバック",
"title": "次に直すべきことをチームに伝える",
"body": "不具合や足りない機能はありませんか?「その他」メニューからフィードバックを送れます。スイッチ 1 つでログを添付でき、何が起きたかをチームが正確に把握できます。",
"action": "フィードバックを送信"
}
}
},
@@ -3613,5 +3631,52 @@
"noTags": "タグなし",
"noProxy": "プロキシなし",
"encrypted": "暗号化"
},
"feedback": {
"title": "フィードバックを送信",
"description": "Donut チームにご意見をお聞かせください。すべてのメッセージに目を通しています。",
"kindLabel": "フィードバックの種類",
"kinds": {
"bug": {
"label": "不具合",
"placeholder": "何が起きて、本来はどうなるべきでしたか?再現手順があると助かります。"
},
"idea": {
"label": "アイデア",
"placeholder": "Donut がどうなれば、もっと使いやすくなりますか?"
},
"praise": {
"label": "称賛",
"placeholder": "気に入っている点を教えてください。チームの励みになります。"
},
"other": {
"label": "その他",
"placeholder": "どんなことでもお聞かせください。"
}
},
"messageLabel": "メッセージ",
"counter": "{{count}} / {{max}}",
"logs": {
"label": "最近のログを添付",
"description": "ログは不具合の調査に役立ちます。メールアドレス、IP アドレス、Web アドレス、シークレットは事前に削除されます。",
"preview": "ログを確認",
"loading": "ログを読み込んでいます…",
"empty": "まだログはありません。"
},
"email": {
"label": "返信用メールアドレス",
"optional": "任意",
"placeholder": "you@example.com",
"signedIn": "返信は {{email}} に届きます。"
},
"included": "あわせて送信: Donut {{version}}、{{os}} {{arch}}",
"send": "送信",
"success": {
"title": "ありがとうございます!",
"body": "フィードバックは Donut チームに届きました。",
"bodyWithLogs": "フィードバックとログは Donut チームに届きました。",
"reply": "詳細が必要な場合は {{email}} にご連絡します。",
"done": "完了"
}
}
}
+69 -4
View File
@@ -2419,7 +2419,14 @@
"wayfernLaunchArgsLimit": "실행 인수는 최대 {{count}}개, 각각 {{length}}자 이하로 입력하세요.",
"tablePreferencesInvalid": "테이블 설정이 올바르지 않습니다.",
"tablePreferencesLoadFailed": "테이블 설정을 불러올 수 없습니다. 다시 시도하세요.",
"tablePreferencesSaveFailed": "테이블 설정을 저장할 수 없습니다. 다시 시도하세요."
"tablePreferencesSaveFailed": "테이블 설정을 저장할 수 없습니다. 다시 시도하세요.",
"feedbackMessageEmpty": "먼저 메시지를 입력하세요.",
"feedbackMessageTooLong": "메시지가 너무 깁니다. 최대 {{max}}자까지 입력할 수 있습니다.",
"feedbackEmailInvalid": "이메일 주소가 올바르지 않은 것 같습니다.",
"feedbackInvalid": "피드백이 접수되지 않았습니다. 메시지를 확인하고 다시 시도하세요.",
"feedbackRateLimited": "짧은 시간에 피드백을 너무 많이 보냈습니다. 몇 분 후에 다시 시도하세요.",
"feedbackSendFailed": "피드백을 보낼 수 없습니다. 나중에 다시 시도하세요.",
"feedbackLogsUnreadable": "로그를 읽을 수 없습니다. '최근 로그 첨부'를 끄고 다시 보내세요."
},
"rail": {
"profiles": "프로필",
@@ -2438,7 +2445,9 @@
"trash": "휴지통",
"trashHint": "삭제한 프로필 복원",
"tips": "팁",
"tipsHint": "기능 안내"
"tipsHint": "기능 안내",
"feedback": "피드백 보내기",
"feedbackHint": "버그 신고 또는 아이디어 공유"
},
"network": "네트워크",
"integrations": "통합",
@@ -2557,7 +2566,8 @@
"goCookieBot": "Cookie Bot",
"goTrash": "휴지통으로 이동",
"goAgent": "에이전트로 이동",
"openTips": "팁 열기"
"openTips": "팁 열기",
"sendFeedback": "피드백 보내기"
},
"closeConfirm": {
"title": "Donut Browser를 닫으시겠습니까?",
@@ -3290,7 +3300,9 @@
"noteSeenAt": "{{time}}에 확인함",
"noteSeen": "확인함",
"joined": "{{agent}} 참가",
"left": "{{agent}} 나감"
"left": "{{agent}} 나감",
"feedbackSent": "Donut 팀에 피드백을 보냈습니다",
"feedbackLogs": "로그 첨부됨"
},
"composer": {
"label": "에이전트에게 보낼 메모",
@@ -3570,6 +3582,12 @@
"title": "donutbrowser.com에서 이 데스크톱 제어",
"body": "원격 제어를 켜면 웹사이트의 에이전트가 아웃바운드 브리지를 통해 이 컴퓨터의 프로필에 접근합니다. 통합에서 켜세요.",
"action": "통합 열기"
},
"feedback": {
"label": "피드백",
"title": "다음에 고칠 점을 팀에 알려 주세요",
"body": "고장 난 기능이나 아쉬운 점이 있나요? 더 보기 메뉴에서 피드백을 보내세요. 스위치 하나로 로그를 첨부하면 팀이 무엇이 잘못되었는지 정확히 알 수 있습니다.",
"action": "피드백 보내기"
}
}
},
@@ -3613,5 +3631,52 @@
"noTags": "태그 없음",
"noProxy": "프록시 없음",
"encrypted": "암호화됨"
},
"feedback": {
"title": "피드백 보내기",
"description": "Donut 팀에 의견을 들려주세요. 모든 메시지를 읽습니다.",
"kindLabel": "피드백 유형",
"kinds": {
"bug": {
"label": "버그",
"placeholder": "무슨 일이 있었고, 원래는 어떻게 되어야 했나요? 재현 단계가 있으면 큰 도움이 됩니다."
},
"idea": {
"label": "아이디어",
"placeholder": "Donut이 어떻게 바뀌면 더 좋을까요?"
},
"praise": {
"label": "칭찬",
"placeholder": "어떤 점이 마음에 드시나요? 팀에게 큰 힘이 됩니다."
},
"other": {
"label": "기타",
"placeholder": "무엇이든 알려 주세요."
}
},
"messageLabel": "메시지",
"counter": "{{count}} / {{max}}",
"logs": {
"label": "최근 로그 첨부",
"description": "로그는 버그를 찾는 데 도움이 됩니다. 이메일 주소, IP 주소, 웹 주소, 비밀 정보는 먼저 제거됩니다.",
"preview": "로그 미리 보기",
"loading": "로그를 읽는 중…",
"empty": "아직 로그가 없습니다."
},
"email": {
"label": "답장 받을 이메일",
"optional": "선택 사항",
"placeholder": "you@example.com",
"signedIn": "답장은 {{email}}(으)로 보내 드립니다."
},
"included": "함께 전송: Donut {{version}}, {{os}} {{arch}}",
"send": "보내기",
"success": {
"title": "감사합니다!",
"body": "피드백이 Donut 팀에 전달되었습니다.",
"bodyWithLogs": "피드백과 로그가 Donut 팀에 전달되었습니다.",
"reply": "더 자세한 내용이 필요하면 {{email}}(으)로 연락드리겠습니다.",
"done": "완료"
}
}
}
+69 -4
View File
@@ -2429,7 +2429,14 @@
"wayfernLaunchArgsLimit": "Use no máximo {{count}} argumentos de inicialização, com até {{length}} caracteres cada.",
"tablePreferencesInvalid": "Estas configurações da tabela são inválidas.",
"tablePreferencesLoadFailed": "Não foi possível carregar as configurações da tabela. Tente novamente.",
"tablePreferencesSaveFailed": "Não foi possível salvar as configurações da tabela. Tente novamente."
"tablePreferencesSaveFailed": "Não foi possível salvar as configurações da tabela. Tente novamente.",
"feedbackMessageEmpty": "Escreva uma mensagem primeiro.",
"feedbackMessageTooLong": "A mensagem é longa demais. O limite é de {{max}} caracteres.",
"feedbackEmailInvalid": "Este endereço de e-mail não parece correto.",
"feedbackInvalid": "O feedback não foi aceito. Verifique a mensagem e tente novamente.",
"feedbackRateLimited": "Você enviou muito feedback em pouco tempo. Tente novamente em alguns minutos.",
"feedbackSendFailed": "Não foi possível enviar o feedback. Tente novamente mais tarde.",
"feedbackLogsUnreadable": "Não foi possível ler os logs. Desative \"Incluir logs recentes\" e envie novamente."
},
"rail": {
"profiles": "Perfis",
@@ -2448,7 +2455,9 @@
"trash": "Lixeira",
"trashHint": "Restaurar perfis excluídos",
"tips": "Dicas",
"tipsHint": "Passo a passo dos recursos"
"tipsHint": "Passo a passo dos recursos",
"feedback": "Enviar feedback",
"feedbackHint": "Relatar um erro ou compartilhar uma ideia"
},
"network": "Rede",
"integrations": "Integrações",
@@ -2567,7 +2576,8 @@
"goCookieBot": "Cookie Bot",
"goTrash": "Ir para a Lixeira",
"goAgent": "Ir para Agente",
"openTips": "Abrir dicas"
"openTips": "Abrir dicas",
"sendFeedback": "Enviar feedback"
},
"closeConfirm": {
"title": "Fechar Donut Browser?",
@@ -3323,7 +3333,9 @@
"noteSeenAt": "Vista às {{time}}",
"noteSeen": "Vista",
"joined": "{{agent}} entrou",
"left": "{{agent}} saiu"
"left": "{{agent}} saiu",
"feedbackSent": "enviou feedback à equipe do Donut",
"feedbackLogs": "Logs anexados"
},
"composer": {
"label": "Nota para o seu agente",
@@ -3603,6 +3615,12 @@
"title": "Controle este computador pelo donutbrowser.com",
"body": "Com o controle remoto ativado, os agentes do site alcançam os perfis desta máquina por uma ponte de saída. Ative-o em Integrações.",
"action": "Abrir Integrações"
},
"feedback": {
"label": "Feedback",
"title": "Diga à equipe o que corrigir a seguir",
"body": "Algo quebrado ou faltando? Envie feedback pelo menu Mais. Um botão anexa seus logs, para a equipe ver exatamente o que deu errado.",
"action": "Enviar feedback"
}
}
},
@@ -3646,5 +3664,52 @@
"noTags": "Sem etiquetas",
"noProxy": "Sem proxy",
"encrypted": "Criptografado"
},
"feedback": {
"title": "Enviar feedback",
"description": "Conte à equipe do Donut o que você pensa. Lemos todas as mensagens.",
"kindLabel": "Tipo de feedback",
"kinds": {
"bug": {
"label": "Erro",
"placeholder": "O que aconteceu e o que você esperava? Os passos para reproduzir ajudam muito."
},
"idea": {
"label": "Ideia",
"placeholder": "O que deixaria o Donut melhor para você?"
},
"praise": {
"label": "Elogio",
"placeholder": "Do que você gosta? A equipe vai adorar saber."
},
"other": {
"label": "Outro",
"placeholder": "Conte o que quiser."
}
},
"messageLabel": "Mensagem",
"counter": "{{count}} / {{max}}",
"logs": {
"label": "Incluir logs recentes",
"description": "Os logs nos ajudam a encontrar erros. Endereços de e-mail, endereços IP, endereços web e segredos são removidos antes.",
"preview": "Ver logs",
"loading": "Lendo os logs…",
"empty": "Ainda não há logs."
},
"email": {
"label": "E-mail para resposta",
"optional": "Opcional",
"placeholder": "voce@example.com",
"signedIn": "As respostas vão para {{email}}."
},
"included": "Também enviado: Donut {{version}}, {{os}} {{arch}}",
"send": "Enviar",
"success": {
"title": "Obrigado!",
"body": "Seu feedback chegou à equipe do Donut.",
"bodyWithLogs": "Seu feedback e seus logs chegaram à equipe do Donut.",
"reply": "Se precisarmos de mais detalhes, escreveremos para {{email}}.",
"done": "Concluir"
}
}
}
+69 -4
View File
@@ -2439,7 +2439,14 @@
"wayfernLaunchArgsLimit": "Используйте не более {{count}} аргументов запуска, каждый не длиннее {{length}} символов.",
"tablePreferencesInvalid": "Недопустимые настройки таблицы.",
"tablePreferencesLoadFailed": "Не удалось загрузить настройки таблицы. Повторите попытку.",
"tablePreferencesSaveFailed": "Не удалось сохранить настройки таблицы. Повторите попытку."
"tablePreferencesSaveFailed": "Не удалось сохранить настройки таблицы. Повторите попытку.",
"feedbackMessageEmpty": "Сначала напишите сообщение.",
"feedbackMessageTooLong": "Сообщение слишком длинное. Максимум — {{max}} символов.",
"feedbackEmailInvalid": "Похоже, адрес почты указан неверно.",
"feedbackInvalid": "Отзыв не принят. Проверьте сообщение и попробуйте снова.",
"feedbackRateLimited": "Вы отправили много отзывов за короткое время. Попробуйте снова через несколько минут.",
"feedbackSendFailed": "Не удалось отправить отзыв. Попробуйте позже.",
"feedbackLogsUnreadable": "Не удалось прочитать логи. Выключите «Приложить последние логи» и отправьте снова."
},
"rail": {
"profiles": "Профили",
@@ -2458,7 +2465,9 @@
"trash": "Корзина",
"trashHint": "Восстановить удалённые профили",
"tips": "Подсказки",
"tipsHint": "Обзор возможностей"
"tipsHint": "Обзор возможностей",
"feedback": "Отправить отзыв",
"feedbackHint": "Сообщить об ошибке или предложить идею"
},
"network": "Сеть",
"integrations": "Интеграции",
@@ -2577,7 +2586,8 @@
"goCookieBot": "Cookie Bot",
"goTrash": "Перейти в Корзину",
"goAgent": "Перейти к агенту",
"openTips": "Открыть подсказки"
"openTips": "Открыть подсказки",
"sendFeedback": "Отправить отзыв"
},
"closeConfirm": {
"title": "Закрыть Donut Browser?",
@@ -3356,7 +3366,9 @@
"noteSeenAt": "Прочитано в {{time}}",
"noteSeen": "Прочитано",
"joined": "{{agent}}: подключился",
"left": "{{agent}}: отключился"
"left": "{{agent}}: отключился",
"feedbackSent": "отправил отзыв команде Donut",
"feedbackLogs": "Логи приложены"
},
"composer": {
"label": "Заметка для агента",
@@ -3636,6 +3648,12 @@
"title": "Управляйте этим компьютером с donutbrowser.com",
"body": "При включённом удалённом управлении агенты на сайте получают доступ к профилям этой машины через исходящий мост. Включите его в разделе «Интеграции».",
"action": "Открыть Интеграции"
},
"feedback": {
"label": "Отзыв",
"title": "Подскажите команде, что исправить дальше",
"body": "Что-то сломалось или чего-то не хватает? Отправьте отзыв из меню «Ещё». Один переключатель прикладывает логи, и команда точно увидит, что пошло не так.",
"action": "Отправить отзыв"
}
}
},
@@ -3679,5 +3697,52 @@
"noTags": "Без тегов",
"noProxy": "Без прокси",
"encrypted": "Зашифровано"
},
"feedback": {
"title": "Отправить отзыв",
"description": "Расскажите команде Donut, что вы думаете. Мы читаем каждое сообщение.",
"kindLabel": "Тип отзыва",
"kinds": {
"bug": {
"label": "Ошибка",
"placeholder": "Что произошло и чего вы ожидали? Шаги для воспроизведения очень помогают."
},
"idea": {
"label": "Идея",
"placeholder": "Что сделало бы Donut удобнее для вас?"
},
"praise": {
"label": "Похвала",
"placeholder": "Что вам нравится? Команде будет приятно это прочитать."
},
"other": {
"label": "Другое",
"placeholder": "Напишите что угодно."
}
},
"messageLabel": "Сообщение",
"counter": "{{count}} / {{max}}",
"logs": {
"label": "Приложить последние логи",
"description": "Логи помогают нам находить ошибки. Адреса почты, IP-адреса, веб-адреса и секреты удаляются заранее.",
"preview": "Посмотреть логи",
"loading": "Читаем логи…",
"empty": "Логов пока нет."
},
"email": {
"label": "Почта для ответа",
"optional": "Необязательно",
"placeholder": "you@example.com",
"signedIn": "Ответы придут на {{email}}."
},
"included": "Также отправляется: Donut {{version}}, {{os}} {{arch}}",
"send": "Отправить",
"success": {
"title": "Спасибо!",
"body": "Ваш отзыв получен командой Donut.",
"bodyWithLogs": "Ваш отзыв и логи получены командой Donut.",
"reply": "Если понадобятся подробности, мы напишем на {{email}}.",
"done": "Готово"
}
}
}
+69 -4
View File
@@ -2419,7 +2419,14 @@
"wayfernLaunchArgsLimit": "En fazla {{count}} başlatma argümanı kullanın; her biri en fazla {{length}} karakter olsun.",
"tablePreferencesInvalid": "Bu tablo ayarları geçersiz.",
"tablePreferencesLoadFailed": "Tablo ayarları yüklenemedi. Tekrar deneyin.",
"tablePreferencesSaveFailed": "Tablo ayarları kaydedilemedi. Tekrar deneyin."
"tablePreferencesSaveFailed": "Tablo ayarları kaydedilemedi. Tekrar deneyin.",
"feedbackMessageEmpty": "Önce bir mesaj yazın.",
"feedbackMessageTooLong": "Mesaj çok uzun. Sınır {{max}} karakter.",
"feedbackEmailInvalid": "Bu e-posta adresi doğru görünmüyor.",
"feedbackInvalid": "Geri bildirim kabul edilmedi. Mesajı kontrol edip tekrar deneyin.",
"feedbackRateLimited": "Kısa sürede çok fazla geri bildirim gönderdiniz. Birkaç dakika sonra tekrar deneyin.",
"feedbackSendFailed": "Geri bildirim gönderilemedi. Daha sonra tekrar deneyin.",
"feedbackLogsUnreadable": "Günlükler okunamadı. \"Son günlükleri ekle\" seçeneğini kapatıp tekrar gönderin."
},
"rail": {
"profiles": "Profiller",
@@ -2438,7 +2445,9 @@
"trash": "Çöp Kutusu",
"trashHint": "Silinen profilleri geri yükle",
"tips": "İpuçları",
"tipsHint": "Özellik turları"
"tipsHint": "Özellik turları",
"feedback": "Geri bildirim gönder",
"feedbackHint": "Hata bildirin veya fikir paylaşın"
},
"network": "Ağ",
"integrations": "Entegrasyonlar",
@@ -2557,7 +2566,8 @@
"goCookieBot": "Cookie Bot",
"goTrash": "Çöp Kutusuna git",
"goAgent": "Ajan'a git",
"openTips": "İpuçlarını aç"
"openTips": "İpuçlarını aç",
"sendFeedback": "Geri bildirim gönder"
},
"closeConfirm": {
"title": "Donut Browser kapatılsın mı?",
@@ -3290,7 +3300,9 @@
"noteSeenAt": "{{time}} saatinde görüldü",
"noteSeen": "Görüldü",
"joined": "{{agent}} katıldı",
"left": "{{agent}} ayrıldı"
"left": "{{agent}} ayrıldı",
"feedbackSent": "Donut ekibine geri bildirim gönderdi",
"feedbackLogs": "Günlükler eklendi"
},
"composer": {
"label": "Ajanınız için not",
@@ -3570,6 +3582,12 @@
"title": "Bu masaüstünü donutbrowser.com'dan yönetin",
"body": "Uzaktan denetim açıkken web sitesindeki ajanlar bu makinenin profillerine giden yönlü bir köprü üzerinden ulaşır. Entegrasyonlar'dan açın.",
"action": "Entegrasyonları aç"
},
"feedback": {
"label": "Geri bildirim",
"title": "Ekibe sırada neyin düzeltileceğini söyleyin",
"body": "Bir şey bozuk mu ya da eksik mi? Diğer menüsünden geri bildirim gönderin. Tek bir anahtar günlüklerinizi ekler, böylece ekip neyin ters gittiğini tam olarak görür.",
"action": "Geri bildirim gönder"
}
}
},
@@ -3613,5 +3631,52 @@
"noTags": "Etiket yok",
"noProxy": "Proxy yok",
"encrypted": "Şifreli"
},
"feedback": {
"title": "Geri bildirim gönder",
"description": "Donut ekibine düşüncelerinizi anlatın. Her mesajı okuyoruz.",
"kindLabel": "Geri bildirim türü",
"kinds": {
"bug": {
"label": "Hata",
"placeholder": "Ne oldu ve ne bekliyordunuz? Tekrarlama adımları çok yardımcı olur."
},
"idea": {
"label": "Fikir",
"placeholder": "Donut'u sizin için ne daha iyi yapardı?"
},
"praise": {
"label": "Övgü",
"placeholder": "Neyi seviyorsunuz? Ekip bunu duymaktan mutlu olur."
},
"other": {
"label": "Diğer",
"placeholder": "Bize her şeyi yazabilirsiniz."
}
},
"messageLabel": "Mesaj",
"counter": "{{count}} / {{max}}",
"logs": {
"label": "Son günlükleri ekle",
"description": "Günlükler hataları bulmamıza yardım eder. E-posta adresleri, IP adresleri, web adresleri ve gizli bilgiler önceden kaldırılır.",
"preview": "Günlükleri gör",
"loading": "Günlükler okunuyor…",
"empty": "Henüz günlük yok."
},
"email": {
"label": "Yanıt için e-posta",
"optional": "İsteğe bağlı",
"placeholder": "siz@example.com",
"signedIn": "Yanıtlar {{email}} adresine gider."
},
"included": "Ayrıca gönderilir: Donut {{version}}, {{os}} {{arch}}",
"send": "Gönder",
"success": {
"title": "Teşekkürler!",
"body": "Geri bildiriminiz Donut ekibine ulaştı.",
"bodyWithLogs": "Geri bildiriminiz ve günlükleriniz Donut ekibine ulaştı.",
"reply": "Daha fazla ayrıntıya ihtiyacımız olursa {{email}} adresine yazarız.",
"done": "Tamam"
}
}
}
+69 -4
View File
@@ -2419,7 +2419,14 @@
"wayfernLaunchArgsLimit": "Dùng tối đa {{count}} tham số khởi chạy, mỗi tham số tối đa {{length}} ký tự.",
"tablePreferencesInvalid": "Cài đặt bảng không hợp lệ.",
"tablePreferencesLoadFailed": "Không thể tải cài đặt bảng. Hãy thử lại.",
"tablePreferencesSaveFailed": "Không thể lưu cài đặt bảng. Hãy thử lại."
"tablePreferencesSaveFailed": "Không thể lưu cài đặt bảng. Hãy thử lại.",
"feedbackMessageEmpty": "Hãy viết tin nhắn trước.",
"feedbackMessageTooLong": "Tin nhắn quá dài. Giới hạn là {{max}} ký tự.",
"feedbackEmailInvalid": "Địa chỉ email này có vẻ không đúng.",
"feedbackInvalid": "Phản hồi không được chấp nhận. Hãy kiểm tra tin nhắn và thử lại.",
"feedbackRateLimited": "Bạn đã gửi nhiều phản hồi trong thời gian ngắn. Hãy thử lại sau vài phút.",
"feedbackSendFailed": "Không thể gửi phản hồi. Hãy thử lại sau.",
"feedbackLogsUnreadable": "Không thể đọc nhật ký. Hãy tắt \"Đính kèm nhật ký gần đây\" rồi gửi lại."
},
"rail": {
"profiles": "Profile",
@@ -2438,7 +2445,9 @@
"trash": "Thùng rác",
"trashHint": "Khôi phục hồ sơ đã xóa",
"tips": "Mẹo",
"tipsHint": "Hướng dẫn tính năng"
"tipsHint": "Hướng dẫn tính năng",
"feedback": "Gửi phản hồi",
"feedbackHint": "Báo lỗi hoặc chia sẻ ý tưởng"
},
"network": "Mạng",
"integrations": "Tích hợp",
@@ -2557,7 +2566,8 @@
"goCookieBot": "Cookie Bot",
"goTrash": "Đi tới Thùng rác",
"goAgent": "Đến Tác nhân",
"openTips": "Mở mẹo"
"openTips": "Mở mẹo",
"sendFeedback": "Gửi phản hồi"
},
"closeConfirm": {
"title": "Đóng Donut Browser?",
@@ -3290,7 +3300,9 @@
"noteSeenAt": "Đã xem lúc {{time}}",
"noteSeen": "Đã xem",
"joined": "{{agent}} đã tham gia",
"left": "{{agent}} đã rời đi"
"left": "{{agent}} đã rời đi",
"feedbackSent": "đã gửi phản hồi cho đội ngũ Donut",
"feedbackLogs": "Đã đính kèm nhật ký"
},
"composer": {
"label": "Ghi chú cho tác nhân",
@@ -3570,6 +3582,12 @@
"title": "Điều khiển máy này từ donutbrowser.com",
"body": "Khi bật điều khiển từ xa, các tác nhân trên trang web tiếp cận hồ sơ của máy này qua một cầu nối đi ra. Bật trong Tích hợp.",
"action": "Mở Tích hợp"
},
"feedback": {
"label": "Phản hồi",
"title": "Cho đội ngũ biết nên sửa gì tiếp theo",
"body": "Có gì bị hỏng hoặc còn thiếu? Gửi phản hồi từ menu Thêm. Chỉ một công tắc là đính kèm nhật ký, để đội ngũ thấy chính xác điều gì đã sai.",
"action": "Gửi phản hồi"
}
}
},
@@ -3613,5 +3631,52 @@
"noTags": "Không có thẻ",
"noProxy": "Không có proxy",
"encrypted": "Đã mã hóa"
},
"feedback": {
"title": "Gửi phản hồi",
"description": "Hãy cho đội ngũ Donut biết bạn nghĩ gì. Chúng tôi đọc mọi tin nhắn.",
"kindLabel": "Loại phản hồi",
"kinds": {
"bug": {
"label": "Lỗi",
"placeholder": "Chuyện gì đã xảy ra, và bạn mong đợi điều gì? Các bước tái hiện lỗi sẽ giúp ích rất nhiều."
},
"idea": {
"label": "Ý tưởng",
"placeholder": "Điều gì sẽ giúp Donut tốt hơn cho bạn?"
},
"praise": {
"label": "Lời khen",
"placeholder": "Bạn thích điều gì? Đội ngũ sẽ rất vui khi đọc được."
},
"other": {
"label": "Khác",
"placeholder": "Hãy cho chúng tôi biết bất cứ điều gì."
}
},
"messageLabel": "Tin nhắn",
"counter": "{{count}} / {{max}}",
"logs": {
"label": "Đính kèm nhật ký gần đây",
"description": "Nhật ký giúp chúng tôi tìm lỗi. Địa chỉ email, địa chỉ IP, địa chỉ web và thông tin bí mật được xóa trước.",
"preview": "Xem nhật ký",
"loading": "Đang đọc nhật ký…",
"empty": "Chưa có nhật ký nào."
},
"email": {
"label": "Email để nhận phản hồi",
"optional": "Không bắt buộc",
"placeholder": "ban@example.com",
"signedIn": "Phản hồi sẽ được gửi tới {{email}}."
},
"included": "Cũng được gửi: Donut {{version}}, {{os}} {{arch}}",
"send": "Gửi",
"success": {
"title": "Cảm ơn bạn!",
"body": "Phản hồi của bạn đã đến đội ngũ Donut.",
"bodyWithLogs": "Phản hồi và nhật ký của bạn đã đến đội ngũ Donut.",
"reply": "Nếu cần thêm chi tiết, chúng tôi sẽ viết thư tới {{email}}.",
"done": "Xong"
}
}
}
+69 -4
View File
@@ -2419,7 +2419,14 @@
"wayfernLaunchArgsLimit": "最多使用 {{count}} 个启动参数,每个最多 {{length}} 个字符。",
"tablePreferencesInvalid": "这些表格设置无效。",
"tablePreferencesLoadFailed": "无法加载表格设置。请重试。",
"tablePreferencesSaveFailed": "无法保存表格设置。请重试。"
"tablePreferencesSaveFailed": "无法保存表格设置。请重试。",
"feedbackMessageEmpty": "请先写一条消息。",
"feedbackMessageTooLong": "消息太长。上限为 {{max}} 个字符。",
"feedbackEmailInvalid": "这个邮箱地址似乎不正确。",
"feedbackInvalid": "反馈未被接受。请检查消息后重试。",
"feedbackRateLimited": "你在短时间内发送了大量反馈。请几分钟后再试。",
"feedbackSendFailed": "无法发送反馈。请稍后再试。",
"feedbackLogsUnreadable": "无法读取日志。请关闭“附上最近的日志”后重新发送。"
},
"rail": {
"profiles": "配置文件",
@@ -2438,7 +2445,9 @@
"trash": "回收站",
"trashHint": "恢复已删除的配置文件",
"tips": "小贴士",
"tipsHint": "功能演示"
"tipsHint": "功能演示",
"feedback": "发送反馈",
"feedbackHint": "报告问题或分享想法"
},
"network": "网络",
"integrations": "集成",
@@ -2557,7 +2566,8 @@
"goCookieBot": "Cookie Bot",
"goTrash": "前往回收站",
"goAgent": "前往智能体",
"openTips": "打开小贴士"
"openTips": "打开小贴士",
"sendFeedback": "发送反馈"
},
"closeConfirm": {
"title": "关闭 Donut Browser?",
@@ -3290,7 +3300,9 @@
"noteSeenAt": "已于 {{time}} 查看",
"noteSeen": "已查看",
"joined": "{{agent}} 已加入",
"left": "{{agent}} 已离开"
"left": "{{agent}} 已离开",
"feedbackSent": "向 Donut 团队发送了反馈",
"feedbackLogs": "已附日志"
},
"composer": {
"label": "给智能体的备注",
@@ -3570,6 +3582,12 @@
"title": "从 donutbrowser.com 控制这台电脑",
"body": "开启远程控制后,网站上的智能体可通过出站桥接访问这台电脑的配置文件。在“集成”中开启。",
"action": "打开集成"
},
"feedback": {
"label": "反馈",
"title": "告诉团队下一步该修什么",
"body": "有东西坏了或缺少功能?从“更多”菜单发送反馈。只需一个开关即可附上日志,让团队准确看到出了什么问题。",
"action": "发送反馈"
}
}
},
@@ -3613,5 +3631,52 @@
"noTags": "无标签",
"noProxy": "无代理",
"encrypted": "已加密"
},
"feedback": {
"title": "发送反馈",
"description": "告诉 Donut 团队你的想法。我们会阅读每一条消息。",
"kindLabel": "反馈类型",
"kinds": {
"bug": {
"label": "问题",
"placeholder": "发生了什么?你原本期望的结果是什么?提供复现步骤会很有帮助。"
},
"idea": {
"label": "建议",
"placeholder": "怎样能让 Donut 更好用?"
},
"praise": {
"label": "称赞",
"placeholder": "你喜欢哪些地方?团队会很高兴听到。"
},
"other": {
"label": "其他",
"placeholder": "想说什么都可以。"
}
},
"messageLabel": "消息",
"counter": "{{count}} / {{max}}",
"logs": {
"label": "附上最近的日志",
"description": "日志可以帮助我们找到问题。发送前会移除电子邮件地址、IP 地址、网址和密钥。",
"preview": "查看日志",
"loading": "正在读取日志…",
"empty": "暂无日志。"
},
"email": {
"label": "用于回复的邮箱",
"optional": "可选",
"placeholder": "you@example.com",
"signedIn": "回复将发送到 {{email}}。"
},
"included": "同时发送:Donut {{version}},{{os}} {{arch}}",
"send": "发送",
"success": {
"title": "谢谢!",
"body": "你的反馈已送达 Donut 团队。",
"bodyWithLogs": "你的反馈和日志已送达 Donut 团队。",
"reply": "如需更多细节,我们会写信到 {{email}}。",
"done": "完成"
}
}
}
+6 -1
View File
@@ -45,7 +45,8 @@ export type AgentThreadKind =
| "note"
| "progress"
| "joined"
| "left";
| "left"
| "feedback";
export type AgentThreadState =
| "open"
@@ -70,8 +71,12 @@ export interface AgentThreadItem {
delivered_to: string[];
done: number | null;
total: number | null;
/** Set on a `feedback` item: what the agent sent to the Donut team. */
feedback: { kind: FeedbackKind; logs: boolean } | null;
}
export type FeedbackKind = "bug" | "idea" | "praise" | "other";
export interface AgentHold {
profile_id: string;
since: number;
+23
View File
@@ -199,6 +199,13 @@ export type BackendErrorCode =
| "AGENT_NOTE_EMPTY"
| "AGENT_TEXT_TOO_LONG"
| "AGENT_SESSION_NOT_FOUND"
| "FEEDBACK_MESSAGE_EMPTY"
| "FEEDBACK_MESSAGE_TOO_LONG"
| "FEEDBACK_EMAIL_INVALID"
| "FEEDBACK_INVALID"
| "FEEDBACK_RATE_LIMITED"
| "FEEDBACK_SEND_FAILED"
| "FEEDBACK_LOGS_UNREADABLE"
| "FINGERPRINT_EXIT_MISMATCH"
// The launch refuses instead of opening a window on an unmanaged device: a
// silent fallback would leave the user browsing a random fingerprint while
@@ -746,6 +753,22 @@ export function translateBackendError(t: TFunction, err: unknown): string {
});
case "AGENT_SESSION_NOT_FOUND":
return t("backendErrors.agentSessionNotFound");
case "FEEDBACK_MESSAGE_EMPTY":
return t("backendErrors.feedbackMessageEmpty");
case "FEEDBACK_MESSAGE_TOO_LONG":
return t("backendErrors.feedbackMessageTooLong", {
max: parsed.params?.max ?? "5000",
});
case "FEEDBACK_EMAIL_INVALID":
return t("backendErrors.feedbackEmailInvalid");
case "FEEDBACK_INVALID":
return t("backendErrors.feedbackInvalid");
case "FEEDBACK_RATE_LIMITED":
return t("backendErrors.feedbackRateLimited");
case "FEEDBACK_SEND_FAILED":
return t("backendErrors.feedbackSendFailed");
case "FEEDBACK_LOGS_UNREADABLE":
return t("backendErrors.feedbackLogsUnreadable");
// The launch gate's block. The dialog renders the mismatch detail from
// `params` itself; this string is the fallback for anywhere that only has
// room for one sentence.
+10
View File
@@ -32,6 +32,7 @@ export type ShortcutId =
| "openPalette"
| "openShortcuts"
| "openTips"
| "sendFeedback"
| "importProfile"
| "goProfiles"
| "goProxies"
@@ -69,6 +70,15 @@ export const SHORTCUTS: ShortcutDef[] = [
mod: true,
shift: true,
},
{
// Mod+Shift+F, "feedback". Plain Mod+F stays free for find.
id: "sendFeedback",
labelKey: "shortcuts.sendFeedback",
group: "actions",
key: "f",
mod: true,
shift: true,
},
{
id: "importProfile",
labelKey: "shortcuts.importProfile",
+1
View File
@@ -192,6 +192,7 @@ test("a tip for a feature already in use is skipped", () => {
"defaultBrowser",
]);
assert.deepEqual(skipped({ ...IDLE, trashUsed: true }), ["trash"]);
assert.deepEqual(skipped({ ...IDLE, feedbackSent: true }), ["feedback"]);
assert.deepEqual(skipped({ ...IDLE, apiEnabled: true }), [
"localApi",
"automation",
+10 -1
View File
@@ -20,7 +20,8 @@ export type TipRequirement = Extract<
export type TipAction =
| { kind: "page"; page: AppPage }
| { kind: "settings"; section: string }
| { kind: "palette" };
| { kind: "palette" }
| { kind: "feedback" };
export type TipId =
| "dnsBlocklist"
@@ -36,6 +37,7 @@ export type TipId =
| "trash"
| "localApi"
| "importProfiles"
| "feedback"
| "cloudBackup"
| "cookieBot"
| "crossOs"
@@ -80,6 +82,8 @@ export interface FeatureUsage {
trashUsed?: boolean;
/** An agent has connected over remote control during this app session. */
agentUsed?: boolean;
/** The person has sent feedback from the app. */
feedbackSent?: boolean;
}
export interface TipDefinition {
@@ -181,6 +185,11 @@ export const TIPS: readonly TipDefinition[] = [
inUse: (usage) => usage.apiEnabled === true,
},
{ id: "importProfiles", action: { kind: "page", page: "import" } },
{
id: "feedback",
action: { kind: "feedback" },
inUse: (usage) => usage.feedbackSent === true,
},
{
id: "cloudBackup",
action: { kind: "page", page: "account" },