feat: feedback profile picker

This commit is contained in:
zhom committed 2026-11-03 21:45:19 +04:00
1 parent 9cee1188f3
commit 2c66a3d5aa
17 files changed
+254 -3

No files matched your search

+36
View File
@@ -7365,6 +7365,17 @@ test("feedback goes from the More menu to the team, with the logs the person pre
}),
/FEEDBACK_EMAIL_INVALID/,
);
assert.match(
await app.invokeError("send_feedback", {
kind: "bug",
message: "Hangs",
includeLogs: false,
email: null,
locale: "en",
profileId: "00000000-0000-4000-8000-000000000000",
}),
/PROFILE_NOT_FOUND/,
);
assert.equal(capture.received.length, 0);
await app.waitFor(
async () =>
@@ -7459,6 +7470,24 @@ test("feedback goes from the More menu to the team, with the logs the person pre
'[data-slot="feedback-email"]',
"me@example.com",
);
// The report may name one profile; only its id leaves the app.
assert.equal(
await app.execute(
`return document.querySelector('#feedback-profile')?.innerText.trim();`,
),
en.agent.composer.noProfile,
);
await app.clickSelector("#feedback-profile");
await app.clickSelector(
`[data-slot="agent-profile-option"][data-profile-id="${profile.id}"]`,
);
await app.waitFor(
async () =>
(await app.execute(
`return document.querySelector('#feedback-profile')?.innerText.trim();`,
)) === "Secret Shop Account",
{ description: "the chosen profile on the picker" },
);
await app.capture("feedback-compose");
await app.clickSelector('[data-slot="feedback-send"]');
await app.waitFor(
@@ -7492,6 +7521,8 @@ test("feedback goes from the More menu to the team, with the logs the person pre
assert.equal(sent.body.locale, "en");
assert.equal(typeof sent.body.appVersion, "string");
assert.ok(sent.body.logs.includes("Donut started"));
assert.equal(sent.body.profileId, profile.id);
assert.ok(!JSON.stringify(sent.body).includes("Secret Shop Account"));
await app.clickSelector('[data-slot="feedback-sent"] button');
await app.waitFor(
@@ -7528,6 +7559,11 @@ test("feedback goes from the More menu to the team, with the logs the person pre
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.profileId,
undefined,
"a sent draft does not keep its profile",
);
assert.equal(
capture.received[1].body.logs,
undefined,
+82
View File
@@ -77,6 +77,8 @@ pub struct Feedback {
pub logs: Option<String>,
pub agent_client: Option<String>,
pub context: Option<serde_json::Value>,
/// The profile the report is about. Only its id leaves the app.
pub profile_id: Option<uuid::Uuid>,
}
#[derive(Debug, Serialize)]
@@ -98,6 +100,8 @@ struct Payload<'a> {
agent_client: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
context: Option<&'a serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
profile_id: Option<uuid::Uuid>,
}
impl<'a> Payload<'a> {
@@ -118,6 +122,7 @@ impl<'a> Payload<'a> {
locale: feedback.locale.as_deref(),
agent_client: feedback.agent_client.as_deref(),
context: feedback.context.as_ref(),
profile_id: feedback.profile_id,
}
}
}
@@ -172,6 +177,32 @@ pub fn validate_email(email: Option<&str>) -> Result<Option<String>, String> {
}
}
/// The profile a report is about: blank is none, anything else must be the id
/// of an existing profile.
pub fn validate_profile_id(
profile_id: Option<&str>,
exists: impl FnOnce(&uuid::Uuid) -> Result<bool, String>,
) -> Result<Option<uuid::Uuid>, String> {
let Some(profile_id) = profile_id.map(str::trim).filter(|id| !id.is_empty()) else {
return Ok(None);
};
let not_found = || crate::backend_error("PROFILE_NOT_FOUND");
let profile_id = uuid::Uuid::parse_str(profile_id).map_err(|_| not_found())?;
if exists(&profile_id)? {
Ok(Some(profile_id))
} else {
Err(not_found())
}
}
/// Whether a profile with this id is on this device.
pub fn profile_exists(profile_id: &uuid::Uuid) -> Result<bool, String> {
crate::profile::ProfileManager::instance()
.lookup_profile(profile_id)
.map(|profile| profile.is_some())
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))
}
/// The log excerpt feedback carries: anonymous facts, then the newest app and
/// proxy worker log lines, redacted, at most `MAX_LOG_BYTES`
/// (`diagnostics::excerpt`).
@@ -342,10 +373,12 @@ pub async fn send_feedback(
include_logs: bool,
email: Option<String>,
locale: Option<String>,
profile_id: 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 profile_id = validate_profile_id(profile_id.as_deref(), profile_exists)?;
let logs = if include_logs {
Some(
log_excerpt(
@@ -366,6 +399,7 @@ pub async fn send_feedback(
logs,
agent_client: None,
context: None,
profile_id,
})
.await?;
tauri::async_runtime::spawn_blocking(crate::settings_manager::note_feedback_sent);
@@ -443,6 +477,51 @@ mod tests {
}
}
#[test]
fn a_profile_is_optional_but_must_exist() {
let id = uuid::Uuid::new_v4();
let never = |_: &uuid::Uuid| -> Result<bool, String> { panic!("not looked up") };
assert_eq!(validate_profile_id(None, never).unwrap(), None);
assert_eq!(validate_profile_id(Some(" "), never).unwrap(), None);
assert_eq!(
validate_profile_id(Some(&format!(" {id} ")), |seen| Ok(*seen == id)).unwrap(),
Some(id)
);
assert_eq!(
code_of(&validate_profile_id(Some("not-a-uuid"), never).unwrap_err()),
"PROFILE_NOT_FOUND"
);
assert_eq!(
code_of(&validate_profile_id(Some(&id.to_string()), |_| Ok(false)).unwrap_err()),
"PROFILE_NOT_FOUND"
);
let failed = validate_profile_id(Some(&id.to_string()), |_| {
Err(crate::backend_error("INTERNAL_ERROR"))
});
assert_eq!(code_of(&failed.unwrap_err()), "INTERNAL_ERROR");
}
#[test]
fn the_payload_names_a_profile_by_id_only() {
let id = uuid::Uuid::new_v4();
let mut feedback = Feedback {
kind: FeedbackKind::Bug,
source: FeedbackSource::Person,
message: "the proxy check hangs".to_string(),
email: None,
locale: None,
logs: None,
agent_client: None,
context: None,
profile_id: Some(id),
};
let sent = serde_json::to_value(Payload::of(&feedback)).unwrap();
assert_eq!(sent["profileId"], id.to_string());
feedback.profile_id = None;
let sent = serde_json::to_value(Payload::of(&feedback)).unwrap();
assert!(sent.get("profileId").is_none(), "{sent}");
}
#[test]
fn an_agent_may_send_a_bounded_amount_per_hour() {
let mut sends = VecDeque::new();
@@ -510,6 +589,7 @@ mod tests {
logs: Some("line\n".to_string()),
agent_client: None,
context: None,
profile_id: None,
};
let body = serde_json::to_vec(&Payload::of(&feedback)).unwrap();
let receipt = post(
@@ -544,6 +624,7 @@ mod tests {
logs: None,
agent_client: Some("Claude Code 2.1.0".to_string()),
context: Some(serde_json::json!({ "recentCalls": [] })),
profile_id: None,
};
let sent = serde_json::to_value(Payload::of(&feedback)).unwrap();
assert_eq!(sent["source"], "agent");
@@ -569,6 +650,7 @@ mod tests {
logs: Some(String::new()),
agent_client: None,
context: None,
profile_id: None,
};
let body = serde_json::to_vec(&Payload::of(&feedback)).unwrap();
let error = post(format!("{}/api/feedback", server.uri()), body, None)
+18 -1
View File
@@ -6998,7 +6998,8 @@ impl McpServer {
"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)" }
"include_logs": { "type": "boolean", "description": "Attach the recent app logs (default: false)" },
"profile_id": { "type": "string", "description": "The id of the profile the feedback is about, if any. Only the id is sent, never the profile's name or data" }
},
"required": ["message"]
}),
@@ -12814,6 +12815,16 @@ impl McpServer {
.get("include_logs")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let profile_id = match arguments.get("profile_id") {
None | Some(serde_json::Value::Null) => None,
Some(value) => {
let id = value
.as_str()
.ok_or_else(|| Self::invalid_argument("profile_id must be a string"))?;
crate::feedback::validate_profile_id(Some(id), crate::feedback::profile_exists)
.map_err(|_| Self::invalid_argument("profile_id is not the id of a profile"))?
}
};
if !crate::feedback::agent_send_allowed() {
return Err(McpError {
@@ -12857,6 +12868,7 @@ impl McpServer {
agent_client,
context: (!recent_calls.is_empty())
.then(|| serde_json::json!({ "recentCalls": recent_calls })),
profile_id,
})
.await
.map_err(|error| {
@@ -17166,6 +17178,11 @@ mod tests {
5,
serde_json::json!({ "message": "x".repeat(crate::feedback::MAX_MESSAGE_CHARS + 1) }),
),
(
6,
serde_json::json!({ "message": "ok", "profile_id": "not-a-uuid" }),
),
(7, serde_json::json!({ "message": "ok", "profile_id": 7 })),
] {
let refused = send(&session, id, arguments).await;
assert_eq!(
+1
View File
@@ -3357,6 +3357,7 @@ export default function Home() {
setFeedbackDialogOpen(false);
}}
accountEmail={cloudUser?.email ?? null}
profiles={profiles}
/>
<PreLaunchGateDialog
+28 -2
View File
@@ -190,7 +190,10 @@ export function TaskStatusLabel({
);
}
/** Choose any number of profiles, or exactly one, with search. */
/**
* Choose any number of profiles, or exactly one, with search. `noneLabel`
* makes a single choice optional: it names the empty choice and lists it first.
*/
export function ProfilePicker({
id,
profiles,
@@ -198,6 +201,7 @@ export function ProfilePicker({
onChange,
disabled,
single = false,
noneLabel,
className,
}: {
id?: string;
@@ -206,6 +210,7 @@ export function ProfilePicker({
onChange: (ids: string[]) => void;
disabled?: boolean;
single?: boolean;
noneLabel?: string;
className?: string;
}) {
const { t } = useTranslation();
@@ -214,7 +219,8 @@ export function ProfilePicker({
const known = profiles.filter((profile) => chosen.has(profile.id));
const label =
known.length === 0
? t(single ? "agent.chat.chooseProfile" : "agent.tasks.chooseProfiles")
? (noneLabel ??
t(single ? "agent.chat.chooseProfile" : "agent.tasks.chooseProfiles"))
: known.length === 1
? known[0].name
: t("agent.tasks.profileCount", { count: known.length });
@@ -258,6 +264,26 @@ export function ProfilePicker({
<CommandList className="max-h-64">
<CommandEmpty>{t("agent.tasks.noProfileMatch")}</CommandEmpty>
<CommandGroup>
{single && noneLabel && (
<CommandItem
value="__none__"
data-slot="agent-profile-none"
onSelect={() => {
onChange([]);
setOpen(false);
}}
className="text-muted-foreground"
>
<LuCheck
className={cn(
"size-3.5",
known.length === 0 ? "opacity-100" : "opacity-0",
)}
aria-hidden="true"
/>
{noneLabel}
</CommandItem>
)}
{!single && (
<CommandItem
value="__all__"
+45
View File
@@ -18,6 +18,7 @@ import {
LuMessageSquareMore,
LuSend,
} from "react-icons/lu";
import { ProfilePicker } from "@/components/agent-tasks/parts";
import { LoadingButton } from "@/components/loading-button";
import { AnimatedDisclosureChevron } from "@/components/ui/animated-disclosure";
import { AnimatedSwitch } from "@/components/ui/animated-switch";
@@ -41,6 +42,7 @@ import { MOTION_SPRING_POSITION } from "@/lib/motion";
import { isMacOS } from "@/lib/platform";
import { showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { BrowserProfile } from "@/types";
import { RippleButton } from "./ui/ripple";
/** Mirrors `feedback::MAX_MESSAGE_CHARS`. */
@@ -76,6 +78,8 @@ interface FeedbackDialogProps {
onClose: () => void;
/** The signed-in account's email; replies go there. */
accountEmail: string | null;
/** The profiles the report may be about; only the chosen id is sent. */
profiles: BrowserProfile[];
}
/**
@@ -88,6 +92,7 @@ export function FeedbackDialog({
isOpen,
onClose,
accountEmail,
profiles,
}: FeedbackDialogProps) {
const { t, i18n } = useTranslation();
const animate = useMotionAllowed();
@@ -97,6 +102,7 @@ export function FeedbackDialog({
const [includeLogs, setIncludeLogs] = useState(true);
const [logsChosen, setLogsChosen] = useState(false);
const [email, setEmail] = useState("");
const [profileId, setProfileId] = useState<string | null>(null);
const [previewOpen, setPreviewOpen] = useState(false);
const [logs, setLogs] = useState<string | null>(null);
const [logsLoading, setLogsLoading] = useState(false);
@@ -173,11 +179,18 @@ export function FeedbackDialog({
setLogsChosen(false);
setPreviewOpen(false);
setLogs(null);
setProfileId(null);
}
setError(null);
onClose();
}, [onClose, sent]);
useEffect(() => {
if (profileId && !profiles.some((profile) => profile.id === profileId)) {
setProfileId(null);
}
}, [profileId, profiles]);
const canSend = message.trim().length > 0 && !sending;
const send = useCallback(async () => {
@@ -191,6 +204,7 @@ export function FeedbackDialog({
includeLogs,
email: accountEmail ? null : email.trim() || null,
locale: i18n.language,
profileId,
});
setSent({
logs: includeLogs,
@@ -209,6 +223,7 @@ export function FeedbackDialog({
includeLogs,
kind,
message,
profileId,
sending,
t,
]);
@@ -437,6 +452,36 @@ export function FeedbackDialog({
</div>
)}
{profiles.length > 0 && (
<div className="grid gap-1.5">
<label
htmlFor="feedback-profile"
className="flex items-baseline gap-1.5 text-sm font-medium"
>
{t("feedback.profile.label")}
<span className="text-xs font-normal text-muted-foreground">
{t("feedback.email.optional")}
</span>
</label>
<div className="flex min-w-0 items-center gap-3">
<ProfilePicker
id="feedback-profile"
single
profiles={profiles}
selected={profileId ? [profileId] : []}
onChange={(ids) => {
setProfileId(ids[0] ?? null);
}}
noneLabel={t("agent.composer.noProfile")}
className="shrink-0"
/>
<p className="min-w-0 text-xs text-muted-foreground">
{t("feedback.profile.idOnly")}
</p>
</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" />
+4
View File
@@ -4716,6 +4716,10 @@
"prompt": "Nutze die MCP-Tools von Donut Browser, um herauszufinden, warum Donut Browser nicht wie erwartet funktioniert. Folge dem Triage-Leitfaden unter {{url}}: Frag mich, was schiefgelaufen ist, rufe get_diagnostics auf, suche nach einer neueren Version und bekannten Problemen und hilf mir, das Problem zu beheben. Zeig mir den Bericht, bevor du ihn mit send_feedback sendest.",
"problem": "Was ich gesehen habe: {{message}}"
},
"profile": {
"label": "Betroffenes Profil",
"idOnly": "Nur die Profil-ID wird gesendet."
},
"email": {
"label": "E-Mail für eine Antwort",
"optional": "Optional",
+4
View File
@@ -4716,6 +4716,10 @@
"prompt": "Use the Donut Browser MCP tools to find out why Donut Browser does not work as I expect. Follow the triage playbook at {{url}}: ask me what went wrong, call get_diagnostics, look for a newer version and known issues, and help me fix the problem. Show me the report before you send it with send_feedback.",
"problem": "What I saw: {{message}}"
},
"profile": {
"label": "Profile this is about",
"idOnly": "Only the profile ID is sent."
},
"email": {
"label": "Email for a reply",
"optional": "Optional",
+4
View File
@@ -4769,6 +4769,10 @@
"prompt": "Usa las herramientas MCP de Donut Browser para averiguar por qué Donut Browser no funciona como espero. Sigue la guía de diagnóstico en {{url}}: pregúntame qué salió mal, llama a get_diagnostics, busca una versión más nueva y problemas conocidos, y ayúdame a resolver el problema. Muéstrame el informe antes de enviarlo con send_feedback.",
"problem": "Lo que vi: {{message}}"
},
"profile": {
"label": "Perfil afectado",
"idOnly": "Solo se envía el ID del perfil."
},
"email": {
"label": "Correo para responderte",
"optional": "Opcional",
+4
View File
@@ -4769,6 +4769,10 @@
"prompt": "Utilise les outils MCP de Donut Browser pour trouver pourquoi Donut Browser ne fonctionne pas comme prévu. Suis le guide de diagnostic à l'adresse {{url}} : demande-moi ce qui ne va pas, appelle get_diagnostics, cherche une version plus récente et les problèmes connus, et aide-moi à corriger le problème. Montre-moi le rapport avant de l'envoyer avec send_feedback.",
"problem": "Ce que j'ai vu : {{message}}"
},
"profile": {
"label": "Profil concerné",
"idOnly": "Seul l'identifiant du profil est envoyé."
},
"email": {
"label": "E-mail pour la réponse",
"optional": "Facultatif",
+4
View File
@@ -4716,6 +4716,10 @@
"prompt": "Donut Browser の MCP ツールを使って、Donut Browser が期待どおりに動かない原因を調べてください。{{url}} のトリアージ手順に従ってください。何が起きたかを私に聞き、get_diagnostics を呼び出し、新しいバージョンと既知の問題を確認し、問題の解決を手伝ってください。send_feedback で送信する前に、レポートを私に見せてください。",
"problem": "私が見たこと: {{message}}"
},
"profile": {
"label": "対象のプロファイル",
"idOnly": "送信されるのはプロファイル ID のみです。"
},
"email": {
"label": "返信用メールアドレス",
"optional": "任意",
+4
View File
@@ -4716,6 +4716,10 @@
"prompt": "Donut Browser MCP 도구를 사용해 Donut Browser가 예상대로 동작하지 않는 이유를 찾아 주세요. {{url}}의 분류 가이드를 따르세요. 무엇이 잘못됐는지 저에게 묻고, get_diagnostics를 호출하고, 새 버전과 알려진 문제를 확인한 뒤 문제 해결을 도와주세요. send_feedback으로 보내기 전에 보고서를 저에게 보여 주세요.",
"problem": "제가 본 내용: {{message}}"
},
"profile": {
"label": "관련 프로필",
"idOnly": "프로필 ID만 전송됩니다."
},
"email": {
"label": "답장 받을 이메일",
"optional": "선택 사항",
+4
View File
@@ -4769,6 +4769,10 @@
"prompt": "Use as ferramentas MCP do Donut Browser para descobrir por que o Donut Browser não funciona como eu espero. Siga o guia de triagem em {{url}}: pergunte o que deu errado, chame get_diagnostics, procure uma versão mais nova e problemas conhecidos e me ajude a resolver o problema. Mostre o relatório antes de enviá-lo com send_feedback.",
"problem": "O que eu vi: {{message}}"
},
"profile": {
"label": "Perfil relacionado",
"idOnly": "Apenas o ID do perfil é enviado."
},
"email": {
"label": "E-mail para resposta",
"optional": "Opcional",
+4
View File
@@ -4824,6 +4824,10 @@
"prompt": "Используй MCP-инструменты Donut Browser, чтобы выяснить, почему Donut Browser работает не так, как я ожидаю. Следуй руководству по диагностике по адресу {{url}}: спроси меня, что пошло не так, вызови get_diagnostics, проверь, есть ли новая версия и известные проблемы, и помоги мне исправить проблему. Покажи мне отчёт, прежде чем отправить его через send_feedback.",
"problem": "Что я увидел: {{message}}"
},
"profile": {
"label": "Профиль, о котором речь",
"idOnly": "Отправляется только ID профиля."
},
"email": {
"label": "Почта для ответа",
"optional": "Необязательно",
+4
View File
@@ -4716,6 +4716,10 @@
"prompt": "Donut Browser'ın neden beklediğim gibi çalışmadığını bulmak için Donut Browser MCP araçlarını kullan. {{url}} adresindeki sorun giderme kılavuzunu izle: bana neyin yanlış gittiğini sor, get_diagnostics çağır, daha yeni bir sürüm ve bilinen sorunlar ara ve sorunu çözmeme yardım et. Raporu send_feedback ile göndermeden önce bana göster.",
"problem": "Gördüğüm: {{message}}"
},
"profile": {
"label": "İlgili profil",
"idOnly": "Yalnızca profil kimliği gönderilir."
},
"email": {
"label": "Yanıt için e-posta",
"optional": "İsteğe bağlı",
+4
View File
@@ -4716,6 +4716,10 @@
"prompt": "Hãy dùng các công cụ MCP của Donut Browser để tìm hiểu vì sao Donut Browser không hoạt động như tôi mong đợi. Làm theo hướng dẫn phân loại tại {{url}}: hỏi tôi chuyện gì đã xảy ra, gọi get_diagnostics, tìm phiên bản mới hơn và các sự cố đã biết, rồi giúp tôi khắc phục. Cho tôi xem báo cáo trước khi gửi bằng send_feedback.",
"problem": "Những gì tôi thấy: {{message}}"
},
"profile": {
"label": "Hồ sơ liên quan",
"idOnly": "Chỉ gửi ID của hồ sơ."
},
"email": {
"label": "Email để nhận phản hồi",
"optional": "Không bắt buộc",
+4
View File
@@ -4716,6 +4716,10 @@
"prompt": "请使用 Donut Browser 的 MCP 工具,查明 Donut Browser 为什么没有按我预期的方式工作。按照 {{url}} 上的排查手册操作:问我出了什么问题,调用 get_diagnostics,检查是否有新版本和已知问题,并帮我解决问题。用 send_feedback 发送报告之前,先给我看。",
"problem": "我看到的情况:{{message}}"
},
"profile": {
"label": "相关配置文件",
"idOnly": "只发送配置文件 ID。"
},
"email": {
"label": "用于回复的邮箱",
"optional": "可选",