From b1f1363502f0fb1060edd04fdb07d8392fc993e7 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sat, 4 Jul 2026 15:06:21 -0700 Subject: [PATCH] Add in-app micro-feedback prompts for new features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show a one-time toast asking how a feature is working after its third successful use, with an optional comment sent anonymously to the Yaak API (feature key, text, app version, and OS only — nothing identifying, and nothing is sent unless the user clicks Send). - New cmd_send_feedback Tauri command posts fire-and-forget via the shared API client (localhost in dev) - Feature keys registry (cookie-editor, response-history, sse-summary, git-sync) with per-feature use counting in the key-value store - "Never ask for feedback" setting to disable prompts entirely - Toast gains dynamicHeight and hideDismiss props for richer content - Fix missing vertical padding on xs/2xs multiline inputs - Fix unused-import and dead-code warnings in yaak-system-appearance on non-Linux builds Co-Authored-By: Claude Fable 5 --- apps/yaak-client/components/CookieDialog.tsx | 2 + apps/yaak-client/components/FeedbackToast.tsx | 65 +++++++++++++++++ .../RecentHttpResponsesDropdown.tsx | 6 +- .../components/Settings/SettingsGeneral.tsx | 7 ++ apps/yaak-client/components/core/Input.tsx | 1 + apps/yaak-client/components/core/Toast.tsx | 44 +++++++++--- .../components/git/GitCommitDialog.tsx | 3 + .../responseViewers/EventStreamViewer.tsx | 13 +++- apps/yaak-client/lib/featureFeedback.tsx | 72 +++++++++++++++++++ apps/yaak-client/lib/tauri.ts | 1 + apps/yaak-client/lib/toast.tsx | 5 ++ crates-tauri/yaak-app-client/src/feedback.rs | 47 ++++++++++++ crates-tauri/yaak-app-client/src/lib.rs | 12 ++++ .../yaak-system-appearance/src/lib.rs | 9 ++- crates/yaak-models/bindings/gen_models.ts | 1 + .../20260704000000_hide-feedback-prompts.sql | 3 + crates/yaak-models/src/models.rs | 4 ++ crates/yaak-models/src/queries/settings.rs | 1 + 18 files changed, 283 insertions(+), 13 deletions(-) create mode 100644 apps/yaak-client/components/FeedbackToast.tsx create mode 100644 apps/yaak-client/lib/featureFeedback.tsx create mode 100644 crates-tauri/yaak-app-client/src/feedback.rs create mode 100644 crates/yaak-models/migrations/20260704000000_hide-feedback-prompts.sql diff --git a/apps/yaak-client/components/CookieDialog.tsx b/apps/yaak-client/components/CookieDialog.tsx index 31c90913..c3bd57be 100644 --- a/apps/yaak-client/components/CookieDialog.tsx +++ b/apps/yaak-client/components/CookieDialog.tsx @@ -13,6 +13,7 @@ import { useState, } from "react"; import { showDialog } from "../lib/dialog"; +import { trackFeatureUsage } from "../lib/featureFeedback"; import { jotaiStore } from "../lib/jotai"; import { cookieDomain } from "../lib/model_util"; import { @@ -131,6 +132,7 @@ export const CookieDialog = ({ cookieJarId }: Props) => { }); void patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] }); + trackFeatureUsage("cookie-editor"); setSelectedCookieKey(nextCookieKey); setEditingCookieKey(null); setDraftCookie(null); diff --git a/apps/yaak-client/components/FeedbackToast.tsx b/apps/yaak-client/components/FeedbackToast.tsx new file mode 100644 index 00000000..f1019785 --- /dev/null +++ b/apps/yaak-client/components/FeedbackToast.tsx @@ -0,0 +1,65 @@ +import { HStack, VStack } from "@yaakapp-internal/ui"; +import { useState } from "react"; +import type { FeedbackFeature } from "../lib/featureFeedback"; +import { FEEDBACK_FEATURES } from "../lib/featureFeedback"; +import { invokeCmd } from "../lib/tauri"; +import { hideToastById, showToast } from "../lib/toast"; +import { Button } from "./core/Button"; +import { Input } from "./core/Input"; + +interface Props { + feature: FeedbackFeature; +} + +export function FeedbackToast({ feature }: Props) { + const [text, setText] = useState(""); + + const handleDismiss = () => { + hideToastById(`feature-feedback-${feature}`); + }; + + const handleSend = () => { + // Fire-and-forget; failures are intentionally ignored + invokeCmd("cmd_send_feedback", { feature, text: text.trim() }).catch(() => {}); + showToast({ + id: `feature-feedback-${feature}`, + timeout: 3000, + color: "success", + message: "Thanks for the feedback!", + }); + }; + + return ( + +

{FEEDBACK_FEATURES[feature]}

+
+ +
+ + + + +
+ ); +} diff --git a/apps/yaak-client/components/RecentHttpResponsesDropdown.tsx b/apps/yaak-client/components/RecentHttpResponsesDropdown.tsx index 6978a26b..b24d5a2b 100644 --- a/apps/yaak-client/components/RecentHttpResponsesDropdown.tsx +++ b/apps/yaak-client/components/RecentHttpResponsesDropdown.tsx @@ -10,6 +10,7 @@ import { } from "date-fns"; import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses"; import { useKeyValue } from "../hooks/useKeyValue"; +import { trackFeatureUsage } from "../lib/featureFeedback"; import { DismissibleBanner } from "./core/DismissibleBanner"; import { Dropdown, type DropdownItem } from "./core/Dropdown"; import { formatMillis } from "./core/HttpResponseDurationTag"; @@ -92,7 +93,10 @@ export const RecentHttpResponsesDropdown = function ResponsePane({ ), leftSlot: activeResponse?.id === r.id ? : , - onSelect: () => onPinnedResponseId(r.id), + onSelect: () => { + if (r.id !== latestResponseId) trackFeatureUsage("response-history"); + onPinnedResponseId(r.id); + }, }); } diff --git a/apps/yaak-client/components/Settings/SettingsGeneral.tsx b/apps/yaak-client/components/Settings/SettingsGeneral.tsx index 4ec6804a..9fec0b95 100644 --- a/apps/yaak-client/components/Settings/SettingsGeneral.tsx +++ b/apps/yaak-client/components/Settings/SettingsGeneral.tsx @@ -102,6 +102,13 @@ export function SettingsGeneral() { description="Periodically ping Yaak servers to check for relevant notifications." /> + + void }) => ReactNode; icon?: ShowToastRequest["icon"] | null; color?: ShowToastRequest["color"]; + // Grow with the content (up to the viewport) instead of scrolling internally + // past the default max height + dynamicHeight?: boolean; + // Hide the close button, for toasts that render their own dismiss action. + // Escape still closes the toast + hideDismiss?: boolean; } const ICONS: Record, IconProps["icon"] | null> = { @@ -28,7 +34,17 @@ const ICONS: Record, IconProps["icon warning: "alert_triangle", }; -export function Toast({ children, open, onClose, timeout, action, icon, color }: ToastProps) { +export function Toast({ + children, + open, + onClose, + timeout, + action, + icon, + color, + dynamicHeight, + hideDismiss, +}: ToastProps) { useKey( "Escape", () => { @@ -57,7 +73,13 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }: "border border-border shadow-lg w-100", )} > -
+
{toastIcon && }
{children}
@@ -65,14 +87,16 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
- + {!hideDismiss && ( + + )} {timeout != null && (
diff --git a/apps/yaak-client/components/git/GitCommitDialog.tsx b/apps/yaak-client/components/git/GitCommitDialog.tsx index 799ad98f..bd3ee9b4 100644 --- a/apps/yaak-client/components/git/GitCommitDialog.tsx +++ b/apps/yaak-client/components/git/GitCommitDialog.tsx @@ -12,6 +12,7 @@ import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal import classNames from "classnames"; import { useCallback, useMemo, useState } from "react"; import { modelToYaml } from "../../lib/diffYaml"; +import { trackFeatureUsage } from "../../lib/featureFeedback"; import { resolvedModelName } from "../../lib/resolvedModelName"; import { showConfirm } from "../../lib/confirm"; import { showErrorToast } from "../../lib/toast"; @@ -55,6 +56,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) { setCommitError(null); try { await commit.mutateAsync({ message }); + trackFeatureUsage("git-sync"); onDone(); } catch (err) { setCommitError(String(err)); @@ -66,6 +68,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) { try { const r = await commitAndPush.mutateAsync({ message }); handlePushResult(r); + trackFeatureUsage("git-sync"); onDone(); } catch (err) { showErrorToast({ diff --git a/apps/yaak-client/components/responseViewers/EventStreamViewer.tsx b/apps/yaak-client/components/responseViewers/EventStreamViewer.tsx index 7a19e951..4693880a 100644 --- a/apps/yaak-client/components/responseViewers/EventStreamViewer.tsx +++ b/apps/yaak-client/components/responseViewers/EventStreamViewer.tsx @@ -3,7 +3,7 @@ import { extractSseValueAtPath, type ServerSentEvent } from "@yaakapp-internal/s import { HStack, Icon, InlineCode, SplitLayout, VStack } from "@yaakapp-internal/ui"; import classNames from "classnames"; import type { CSSProperties, ReactNode } from "react"; -import { Fragment, useMemo, useState } from "react"; +import { Fragment, useEffect, useMemo, useState } from "react"; import { useKeyValue } from "../../hooks/useKeyValue"; import { useFormatText } from "../../hooks/useFormatText"; import { useResponseBodyEventSource } from "../../hooks/useResponseBodyEventSource"; @@ -13,6 +13,7 @@ import { useSseSummaryResultKeyPath, } from "../../hooks/useSseSummaryResultKeyPath"; import { isJSON } from "../../lib/contentType"; +import { trackFeatureUsage } from "../../lib/featureFeedback"; import { EmptyStateText } from "../EmptyStateText"; import { Markdown } from "../Markdown"; import { Button } from "../core/Button"; @@ -71,6 +72,16 @@ function ActualEventStreamViewer({ response }: Props) { summary.data.fragmentCount === 0 && !summary.isFetching && summary.error == null; + // The component remounts per response (keyed above), so this counts at most + // one use for each response that successfully extracts summary text + const hasSummaryFragments = showExtractedText && (summary.data?.fragmentCount ?? 0) > 0; + const [trackedSummaryUse, setTrackedSummaryUse] = useState(false); + useEffect(() => { + if (!hasSummaryFragments || trackedSummaryUse) return; + setTrackedSummaryUse(true); + trackFeatureUsage("sse-summary"); + }, [hasSummaryFragments, trackedSummaryUse]); + const filterEventPreviews = showExtractedText && filterEventPreviewsSetting.value === true; const applyToDetails = showExtractedText && applyToDetailsSetting.value === true; const renderMarkdown = showExtractedText && renderMarkdownSetting.value === true; diff --git a/apps/yaak-client/lib/featureFeedback.tsx b/apps/yaak-client/lib/featureFeedback.tsx new file mode 100644 index 00000000..686b47b9 --- /dev/null +++ b/apps/yaak-client/lib/featureFeedback.tsx @@ -0,0 +1,72 @@ +import { settingsAtom } from "@yaakapp-internal/models"; +import { FeedbackToast } from "../components/FeedbackToast"; +import { jotaiStore } from "./jotai"; +import { getKeyValue, setKeyValue } from "./keyValueStore"; +import { showToast } from "./toast"; + +// Feature keys are sent to the server and used to group feedback for analysis. +// NEVER rename a key once it has shipped, or historical feedback will be split +// across the old and new names. +export const FEEDBACK_FEATURES = { + "cookie-editor": "How is cookie editing working for you?", + "response-history": "How is the new response history menu working for you?", + "sse-summary": "How is extracted text for event streams working for you?", + "git-sync": "How is Git sync working for you?", +} as const; + +export type FeedbackFeature = keyof typeof FEEDBACK_FEATURES; + +interface FeatureFeedbackState { + uses: number; + done: boolean; +} + +// Ask once the user has used a feature enough times to have formed an opinion +const PROMPT_AFTER_USES = 3; + +// Show at most one feedback prompt per app session to stay unobtrusive +let promptedThisSession = false; + +const kvArgs = (feature: FeedbackFeature) => ({ + namespace: "global", + key: ["feature-feedback", feature], +}); + +function getFeatureFeedbackState(feature: FeedbackFeature): FeatureFeedbackState { + return getKeyValue({ + ...kvArgs(feature), + fallback: { uses: 0, done: false }, + }); +} + +function patchFeatureFeedbackState(feature: FeedbackFeature, patch: Partial) { + const value = { ...getFeatureFeedbackState(feature), ...patch }; + setKeyValue({ ...kvArgs(feature), value }).catch(console.error); +} + +// Record a successful use of a feature, and prompt for feedback on the Nth use. +// Nothing is ever sent to the server from here; showing the toast is local-only +// and a submission only happens when the user clicks Send in it. +export function trackFeatureUsage(feature: FeedbackFeature) { + if (jotaiStore.get(settingsAtom).hideFeedbackPrompts) return; + + const state = getFeatureFeedbackState(feature); + if (state.done) return; + + const uses = state.uses + 1; + const shouldPrompt = uses >= PROMPT_AFTER_USES && !promptedThisSession; + + // Mark done when prompting so the toast can only ever appear once, even if + // the app quits before the user interacts with it + patchFeatureFeedbackState(feature, { uses, done: shouldPrompt }); + if (!shouldPrompt) return; + + promptedThisSession = true; + showToast({ + id: `feature-feedback-${feature}`, + timeout: null, + dynamicHeight: true, + hideDismiss: true, + message: , + }); +} diff --git a/apps/yaak-client/lib/tauri.ts b/apps/yaak-client/lib/tauri.ts index dc875b37..df9ad069 100644 --- a/apps/yaak-client/lib/tauri.ts +++ b/apps/yaak-client/lib/tauri.ts @@ -48,6 +48,7 @@ type TauriCmd = | "cmd_save_response" | "cmd_secure_template" | "cmd_send_ephemeral_request" + | "cmd_send_feedback" | "cmd_send_http_request" | "cmd_template_function_summaries" | "cmd_template_function_config" diff --git a/apps/yaak-client/lib/toast.tsx b/apps/yaak-client/lib/toast.tsx index e02d86b3..1188fd6b 100644 --- a/apps/yaak-client/lib/toast.tsx +++ b/apps/yaak-client/lib/toast.tsx @@ -37,6 +37,11 @@ export function showToast({ return id; } +export function hideToastById(id: string) { + const toast = jotaiStore.get(toastsAtom).find((t) => t.id === id); + if (toast) hideToast(toast); +} + export function hideToast(toHide: ToastInstance) { jotaiStore.set(toastsAtom, (all) => { const t = all.find((t) => t.uniqueKey === toHide.uniqueKey); diff --git a/crates-tauri/yaak-app-client/src/feedback.rs b/crates-tauri/yaak-app-client/src/feedback.rs new file mode 100644 index 00000000..2e30d79e --- /dev/null +++ b/crates-tauri/yaak-app-client/src/feedback.rs @@ -0,0 +1,47 @@ +use log::debug; +use serde::Serialize; +use tauri::{AppHandle, Runtime, is_dev}; +use yaak_api::{ApiClientKind, yaak_api_client}; +use yaak_common::platform::get_os_str; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct FeedbackPayload { + feature: String, + text: String, + app_version: String, + os: String, +} + +/// Send explicit user feedback for a feature. Fire-and-forget: errors are +/// logged and swallowed so a failed send never surfaces to the user. +pub async fn send_feedback(app_handle: &AppHandle, feature: String, text: String) { + let app_version = app_handle.package_info().version.to_string(); + let payload = FeedbackPayload { + feature, + text, + app_version: app_version.clone(), + os: get_os_str().to_string(), + }; + + let client = match yaak_api_client(ApiClientKind::App, &app_version) { + Ok(c) => c, + Err(e) => { + debug!("Failed to build feedback client: {e:?}"); + return; + } + }; + + match client.post(build_url("/app-feedback")).json(&payload).send().await { + Ok(resp) => debug!("Sent feedback with status {}", resp.status()), + Err(e) => debug!("Failed to send feedback: {e:?}"), + } +} + +fn build_url(path: &str) -> String { + if is_dev() { + format!("http://localhost:9444/api/v1{path}") + } else { + format!("https://api.yaak.app/api/v1{path}") + } +} diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs index 87bb07eb..ea43bced 100644 --- a/crates-tauri/yaak-app-client/src/lib.rs +++ b/crates-tauri/yaak-app-client/src/lib.rs @@ -65,6 +65,7 @@ use yaak_tls::find_client_certificate; mod commands; mod encoding; mod error; +mod feedback; mod git_ext; mod git_watcher; mod grpc; @@ -292,6 +293,16 @@ async fn cmd_render_template( Ok(result) } +#[tauri::command] +async fn cmd_send_feedback( + app_handle: AppHandle, + feature: String, + text: String, +) -> YaakResult<()> { + feedback::send_feedback(&app_handle, feature, text).await; + Ok(()) +} + #[tauri::command] async fn cmd_dismiss_notification( window: WebviewWindow, @@ -1819,6 +1830,7 @@ pub fn run() { cmd_delete_send_history, cmd_dismiss_notification, cmd_export_data, + cmd_send_feedback, cmd_http_request_body, cmd_http_response_body, cmd_format_json, diff --git a/crates-tauri/yaak-system-appearance/src/lib.rs b/crates-tauri/yaak-system-appearance/src/lib.rs index cd644390..b6f7466e 100644 --- a/crates-tauri/yaak-system-appearance/src/lib.rs +++ b/crates-tauri/yaak-system-appearance/src/lib.rs @@ -1,13 +1,18 @@ use std::sync::{Arc, Mutex}; +#[cfg(target_os = "linux")] use std::time::Duration; +#[cfg(target_os = "linux")] use log::{debug, warn}; -use tauri::{AppHandle, Emitter, Runtime}; +#[cfg(target_os = "linux")] +use tauri::Emitter; +use tauri::{AppHandle, Runtime}; pub const INITIAL_APPEARANCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE__"; pub const INITIAL_APPEARANCE_SOURCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE_SOURCE__"; pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change"; +#[cfg(target_os = "linux")] const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1); #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -42,6 +47,8 @@ impl InitialAppearanceSource { #[derive(Clone)] pub struct SystemAppearanceState { + // Only read by the Linux polling thread + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] last_appearance: Arc>>, } diff --git a/crates/yaak-models/bindings/gen_models.ts b/crates/yaak-models/bindings/gen_models.ts index 287e9b4e..eaabf751 100644 --- a/crates/yaak-models/bindings/gen_models.ts +++ b/crates/yaak-models/bindings/gen_models.ts @@ -402,6 +402,7 @@ export type Settings = { themeLight: string; updateChannel: string; hideLicenseBadge: boolean; + hideFeedbackPrompts: boolean; autoupdate: boolean; autoDownloadUpdates: boolean; checkNotifications: boolean; diff --git a/crates/yaak-models/migrations/20260704000000_hide-feedback-prompts.sql b/crates/yaak-models/migrations/20260704000000_hide-feedback-prompts.sql new file mode 100644 index 00000000..1b0aed8f --- /dev/null +++ b/crates/yaak-models/migrations/20260704000000_hide-feedback-prompts.sql @@ -0,0 +1,3 @@ +-- Add a setting to disable in-app feature feedback prompts +ALTER TABLE settings + ADD COLUMN hide_feedback_prompts BOOLEAN DEFAULT FALSE NOT NULL; diff --git a/crates/yaak-models/src/models.rs b/crates/yaak-models/src/models.rs index 4dd59248..cda82e1b 100644 --- a/crates/yaak-models/src/models.rs +++ b/crates/yaak-models/src/models.rs @@ -246,6 +246,7 @@ pub struct Settings { pub theme_light: String, pub update_channel: String, pub hide_license_badge: bool, + pub hide_feedback_prompts: bool, pub autoupdate: bool, pub auto_download_updates: bool, pub check_notifications: bool, @@ -303,6 +304,7 @@ impl UpsertModelInfo for Settings { (ThemeLight, self.theme_light.as_str().into()), (UpdateChannel, self.update_channel.into()), (HideLicenseBadge, self.hide_license_badge.into()), + (HideFeedbackPrompts, self.hide_feedback_prompts.into()), (Autoupdate, self.autoupdate.into()), (AutoDownloadUpdates, self.auto_download_updates.into()), (ColoredMethods, self.colored_methods.into()), @@ -332,6 +334,7 @@ impl UpsertModelInfo for Settings { SettingsIden::ThemeLight, SettingsIden::UpdateChannel, SettingsIden::HideLicenseBadge, + SettingsIden::HideFeedbackPrompts, SettingsIden::Autoupdate, SettingsIden::AutoDownloadUpdates, SettingsIden::ColoredMethods, @@ -372,6 +375,7 @@ impl UpsertModelInfo for Settings { autoupdate: row.get("autoupdate")?, auto_download_updates: row.get("auto_download_updates")?, hide_license_badge: row.get("hide_license_badge")?, + hide_feedback_prompts: row.get("hide_feedback_prompts")?, colored_methods: row.get("colored_methods")?, check_notifications: row.get("check_notifications")?, hotkeys: serde_json::from_str(&hotkeys).unwrap_or_default(), diff --git a/crates/yaak-models/src/queries/settings.rs b/crates/yaak-models/src/queries/settings.rs index 660e6e42..c369e699 100644 --- a/crates/yaak-models/src/queries/settings.rs +++ b/crates/yaak-models/src/queries/settings.rs @@ -38,6 +38,7 @@ impl<'a> ClientDb<'a> { autoupdate: true, colored_methods: false, hide_license_badge: false, + hide_feedback_prompts: false, auto_download_updates: true, check_notifications: true, hotkeys: HashMap::new(),