Compare commits

..

5 Commits

Author SHA1 Message Date
Gregory Schier b1f1363502 Add in-app micro-feedback prompts for new features
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 <noreply@anthropic.com>
2026-07-04 15:06:21 -07:00
Gregory Schier e52853cc2d Always render commercial use banner placeholder 2026-07-04 14:44:49 -07:00
Gregory Schier 851d0a26f0 Enable release Tauri features in config 2026-07-04 14:09:41 -07:00
Gregory Schier 78de83c754 Track @yaakapp/cli via the latest dist-tag
The CLI is now version-locked to app releases, so the devDependency
follows the latest stable instead of a range that would strand on the
old 0.x line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:54:19 -07:00
Gregory Schier 9f3fd46d49 Publish CLI on app release tags (version-locked)
The CLI now publishes to npm on every v* tag at the app's version,
instead of its own yaak-cli-* tag namespace. Stables go to the latest
dist-tag, prereleases to beta/alpha. workflow_dispatch remains for
manual publishes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:47:42 -07:00
23 changed files with 293 additions and 19 deletions
+3 -3
View File
@@ -2,7 +2,7 @@ name: Release CLI to NPM
on:
push:
tags: [yaak-cli-*]
tags: [v*]
workflow_dispatch:
inputs:
version:
@@ -118,7 +118,7 @@ jobs:
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$WORKFLOW_VERSION"
else
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
VERSION="${GITHUB_REF_NAME}"
fi
VERSION="${VERSION#v}"
echo "Building yaak version: $VERSION"
@@ -175,7 +175,7 @@ jobs:
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$WORKFLOW_VERSION"
else
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
VERSION="${GITHUB_REF_NAME}"
fi
VERSION="${VERSION#v}"
if [[ "$VERSION" == *-* ]]; then
@@ -10,6 +10,7 @@ import { DismissibleBanner } from "./core/DismissibleBanner";
const COMMERCIAL_USE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
const COMMERCIAL_USE_BANNER_MESSAGE =
"Personal use of Yaak is free. If youre using Yaak at work, please purchase a license.";
const hiddenBanner = <span aria-hidden className="block h-0 w-0 shrink-0 overflow-hidden" />;
export function CommercialUseBanner({
source,
@@ -55,7 +56,7 @@ export function CommercialUseBanner({
}, [setSnoozedAt, snoozed, source]);
if (!visible || isSnoozeLoading || (snoozed && !snoozeStartedRef.current)) {
return null;
return hiddenBanner;
}
return (
@@ -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);
@@ -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<string>("");
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 (
<VStack space={2}>
<p className="text-sm font-semibold">{FEEDBACK_FEATURES[feature]}</p>
<div className="h-20">
<Input
size="xs"
// The editor forces its mono font on the scroller, so the override
// has to target it directly
className="[&_.cm-scroller]:font-sans! [&_.cm-scroller]:text-sm!"
label="Feedback"
hideLabel
stateKey={null}
multiLine
fullHeight
placeholder="Your thoughts..."
onChange={setText}
/>
</div>
<HStack space={1.5} justifyContent="end">
<Button size="xs" color="secondary" variant="border" onClick={handleDismiss}>
Dismiss
</Button>
<Button
size="xs"
color="primary"
disabled={text.trim().length === 0}
onClick={handleSend}
>
Send
</Button>
</HStack>
</VStack>
);
}
@@ -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({
</HStack>
),
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedResponseId(r.id),
onSelect: () => {
if (r.id !== latestResponseId) trackFeatureUsage("response-history");
onPinnedResponseId(r.id);
},
});
}
@@ -102,6 +102,13 @@ export function SettingsGeneral() {
description="Periodically ping Yaak servers to check for relevant notifications."
/>
<ModelSettingRowBoolean
model={settings}
modelKey="hideFeedbackPrompts"
title="Never ask for feedback"
description="Hide the rare one-time prompts asking how new features are working."
/>
<SettingRowBoolean
title="Send anonymous usage statistics"
description="Yaak is local-first and does not collect analytics or usage data."
@@ -318,6 +318,7 @@ function BaseInput({
editorClassName,
multiLine && size === "md" && "py-1.5",
multiLine && size === "sm" && "py-1",
multiLine && (size === "xs" || size === "2xs") && "py-0.5",
)}
onFocus={handleFocus}
onBlur={handleBlur}
+34 -10
View File
@@ -15,6 +15,12 @@ export interface ToastProps {
action?: (args: { hide: () => 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<NonNullable<ToastProps["color"] | "custom">, IconProps["icon"] | null> = {
@@ -28,7 +34,17 @@ const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, 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",
)}
>
<div className="pl-3 py-3 pr-10 flex items-start gap-2 w-full max-h-44 overflow-auto">
<div
className={classNames(
"pl-3 py-3 flex items-start gap-2 w-full overflow-auto",
hideDismiss ? "pr-3" : "pr-10",
dynamicHeight ? "max-h-[80vh]" : "max-h-44",
)}
>
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 shrink-0" />}
<VStack space={2} className="w-full min-w-0">
<div className="select-auto">{children}</div>
@@ -65,14 +87,16 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
</VStack>
</div>
<IconButton
color={color}
variant="border"
className="opacity-60 border-0 absolute! top-2 right-2"
title="Dismiss"
icon="x"
onClick={onClose}
/>
{!hideDismiss && (
<IconButton
color={color}
variant="border"
className="opacity-60 border-0 absolute! top-2 right-2"
title="Dismiss"
icon="x"
onClick={onClose}
/>
)}
{timeout != null && (
<div className="w-full absolute bottom-0 left-0 right-0">
@@ -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({
@@ -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<boolean>(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;
+72
View File
@@ -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<FeatureFeedbackState>({
...kvArgs(feature),
fallback: { uses: 0, done: false },
});
}
function patchFeatureFeedbackState(feature: FeedbackFeature, patch: Partial<FeatureFeedbackState>) {
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: <FeedbackToast feature={feature} />,
});
}
+1
View File
@@ -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"
+5
View File
@@ -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);
@@ -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<R: Runtime>(app_handle: &AppHandle<R>, 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}")
}
}
+12
View File
@@ -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<R: Runtime>(
Ok(result)
}
#[tauri::command]
async fn cmd_send_feedback<R: Runtime>(
app_handle: AppHandle<R>,
feature: String,
text: String,
) -> YaakResult<()> {
feedback::send_feedback(&app_handle, feature, text).await;
Ok(())
}
#[tauri::command]
async fn cmd_dismiss_notification<R: Runtime>(
window: WebviewWindow<R>,
@@ -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,
@@ -1,4 +1,7 @@
{
"build": {
"features": ["updater", "license", "wry"]
},
"app": {
"security": {
"capabilities": [
@@ -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<Mutex<Option<Appearance>>>,
}
+1
View File
@@ -402,6 +402,7 @@ export type Settings = {
themeLight: string;
updateChannel: string;
hideLicenseBadge: boolean;
hideFeedbackPrompts: boolean;
autoupdate: boolean;
autoDownloadUpdates: boolean;
checkNotifications: boolean;
@@ -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;
+4
View File
@@ -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(),
@@ -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(),
+1 -1
View File
@@ -83,7 +83,7 @@
"@tauri-apps/cli": "npm:@tauri-apps/cli-cef@3.0.0-alpha.6",
"@types/babel__core": "^7.20.5",
"@vitejs/plugin-react": "^6.0.1",
"@yaakapp/cli": "^0.5.1",
"@yaakapp/cli": "latest",
"babel-plugin-react-compiler": "^1.0.0",
"dotenv-cli": "^11.0.0",
"nodejs-file-downloader": "^4.13.0",
+1 -1
View File
@@ -116,7 +116,7 @@
"@tauri-apps/cli": "npm:@tauri-apps/cli-cef@3.0.0-alpha.6",
"@types/babel__core": "^7.20.5",
"@vitejs/plugin-react": "^6.0.1",
"@yaakapp/cli": "^0.5.1",
"@yaakapp/cli": "latest",
"babel-plugin-react-compiler": "^1.0.0",
"dotenv-cli": "^11.0.0",
"nodejs-file-downloader": "^4.13.0",