diff --git a/crates/desktop/src/components/onboarding-controller.tsx b/crates/desktop/src/components/onboarding-controller.tsx index 588882f7..8382a498 100644 --- a/crates/desktop/src/components/onboarding-controller.tsx +++ b/crates/desktop/src/components/onboarding-controller.tsx @@ -2,9 +2,13 @@ import { ArrowsPointingOutIcon, BookOpenIcon, FolderIcon, + PuzzlePieceIcon, ServerIcon, + ShieldCheckIcon, + SparklesIcon, } from "@heroicons/react/24/solid"; import { Button, Checkbox, Modal, Spinner } from "@heroui/react"; +import { getVersion } from "@tauri-apps/api/app"; import { type Driver, type DriveStep, driver } from "driver.js"; import "driver.js/dist/driver.css"; import { @@ -17,40 +21,110 @@ import { import { useTranslation } from "react-i18next"; import { useLocation } from "wouter"; import { useProjects } from "../hooks/use-projects"; -import { ONBOARDING_EVENT, type OnboardingCommand } from "../lib/onboarding"; import { applyAnalyticsConsent, capture } from "../lib/analytics"; +import { ONBOARDING_EVENT, type OnboardingCommand } from "../lib/onboarding"; import { + getAnalyticsConsent, + getConsentAcked, + getLastSeenWhatsNewVersion, getOnboardingProgress, setAnalyticsConsent, + setConsentAcked, + setLastSeenWhatsNewVersion, updateOnboardingProgress, } from "../lib/store"; import { cn } from "../lib/utils"; +import { + pendingWhatsNew, + type WhatsNewEntry, + type WhatsNewItem, +} from "../lib/whats-new"; type OverlayMode = "welcome" | null; const TOUR_CLASS = "aghub-tour-popover"; const TOUR_WAIT_MS = 5000; -const WIZARD_STEPS = [ +interface FeatureStep { + type: "feature"; + id: string; + icon: React.ReactNode; + titleKey: string; + descriptionKey: string; +} + +interface WhatsNewStep { + type: "whats-new"; + id: string; + entry: WhatsNewEntry; +} + +interface ConsentStep { + type: "consent"; + id: string; +} + +type WizardStep = FeatureStep | WhatsNewStep | ConsentStep; + +const WIZARD_FEATURE_STEPS: FeatureStep[] = [ { + type: "feature", id: "mcp", icon: , titleKey: "onboardingStepMcpTitle", descriptionKey: "onboardingStepMcpDescription", }, { + type: "feature", id: "skills", icon: , titleKey: "onboardingStepSkillsTitle", descriptionKey: "onboardingStepSkillsDescription", }, { + type: "feature", id: "projects", icon: , titleKey: "onboardingStepProjectsTitle", descriptionKey: "onboardingStepProjectsDescription", }, -] as const; +]; + +const WHATS_NEW_ICONS = { + sparkles: , + puzzle: , + shield: , +} as const; + +interface WizardOpenContext { + hasSeenWelcome: boolean; + consentAcked: boolean; + whatsNewEntries: WhatsNewEntry[]; +} + +/** + * Compose the active wizard sequence for this launch. Brand-new + * users get the feature wizard followed by the consent step; + * existing users upgrading get just the unseen What's New entries + * (and the consent step if they haven't already acked). + */ +function buildWizardSteps(ctx: WizardOpenContext): WizardStep[] { + const steps: WizardStep[] = []; + if (!ctx.hasSeenWelcome) { + steps.push(...WIZARD_FEATURE_STEPS); + } + for (const entry of ctx.whatsNewEntries) { + steps.push({ + type: "whats-new", + id: `whats-new-${entry.version}`, + entry, + }); + } + if (!ctx.consentAcked) { + steps.push({ type: "consent", id: "consent" }); + } + return steps; +} function waitForElement(selector: string, timeoutMs = TOUR_WAIT_MS) { return new Promise((resolve) => { @@ -86,6 +160,18 @@ export function OnboardingController() { // Pre-checked: opt-in by default for first-time users. Persisted + // applied (Rust + posthog-js) when the user dismisses the dialog. const [analyticsOptIn, setAnalyticsOptIn] = useState(true); + const [wizardSteps, setWizardSteps] = useState([]); + const [currentVersion, setCurrentVersion] = useState(null); + // Tracks whether the wizard sequence included a fresh-install + // welcome run; if it did, we kick off the product tour after + // dismiss. Otherwise (upgrade-only flow) we don't. + const includedFeatureSteps = wizardSteps.some( + (step) => step.type === "feature", + ); + const includedConsentStep = wizardSteps.some( + (step) => step.type === "consent", + ); + const activeStep = wizardSteps[currentStep]; const activeDriverRef = useRef(null); const previousProjectIdsRef = useRef([]); @@ -389,20 +475,47 @@ export function OnboardingController() { const dismissWelcome = async () => { setOverlayMode(null); setCurrentStep(0); - // Persist the user's consent choice and apply it before any - // further captures fire. The default is opt-in (state was - // initialised to true) so most users land in `granted`. - const consent = analyticsOptIn ? "granted" : "denied"; + + const consentStepWasShown = includedConsentStep; + const latestWhatsNewVersion = wizardSteps + .filter((step): step is WhatsNewStep => step.type === "whats-new") + .map((step) => step.entry.version) + .pop(); + try { - await setAnalyticsConsent(consent); - await applyAnalyticsConsent(analyticsOptIn); + // Only persist a consent decision if the user actually saw + // the consent step. Skipping the dialog without ever seeing + // the checkbox shouldn't silently flip their setting. + if (consentStepWasShown) { + const consent = analyticsOptIn ? "granted" : "denied"; + await setAnalyticsConsent(consent); + await setConsentAcked(true); + await applyAnalyticsConsent(analyticsOptIn); + } + + // Mark What's New entries as seen, watermarking with the + // highest version we displayed so the user doesn't see them + // again on next launch. + if (latestWhatsNewVersion) { + await setLastSeenWhatsNewVersion(latestWhatsNewVersion); + } else if (currentVersion) { + // First-time user finishing the welcome wizard: stamp + // the current version so they don't get a What's New + // for it on next launch. + await setLastSeenWhatsNewVersion(currentVersion); + } } catch (error) { - console.error("Failed to persist analytics consent:", error); + console.error("Failed to persist onboarding flags:", error); } + capture("onboarding completed"); await saveProgress({ hasSeenWelcome: true, }); + + // Reset the wizard's computed step list so re-opening it + // (e.g. via Settings → Show Welcome) doesn't reuse stale data. + setWizardSteps([]); }; const continueWithNewProject = useEffectEvent((projectId: string) => { @@ -412,6 +525,9 @@ export function OnboardingController() { const handleCommand = useEffectEvent((command: OnboardingCommand) => { if (command.type === "show-welcome") { destroyActiveDriver(); + // Manual replay always shows the full feature wizard, + // regardless of whether the user has seen it before. + setWizardSteps([...WIZARD_FEATURE_STEPS]); setCurrentStep(0); setOverlayMode("welcome"); return; @@ -433,16 +549,45 @@ export function OnboardingController() { useEffect(() => { let isMounted = true; - void getOnboardingProgress().then((progress) => { - if (!isMounted) { - return; + void (async () => { + const [progress, consentAcked, lastSeen, version] = + await Promise.all([ + getOnboardingProgress(), + getConsentAcked(), + getLastSeenWhatsNewVersion(), + getVersion(), + ]); + if (!isMounted) return; + + const whatsNewEntries = pendingWhatsNew(lastSeen, version); + const steps = buildWizardSteps({ + hasSeenWelcome: progress.hasSeenWelcome, + consentAcked, + whatsNewEntries, + }); + + // Sync current consent state into the checkbox so existing + // users see their actual choice reflected. + try { + const consent = await getAnalyticsConsent(); + if (isMounted) { + setAnalyticsOptIn(consent === "granted"); + } + } catch { + // keep the default true } + if (!isMounted) return; + + setCurrentVersion(version); setIsReady(true); - if (!progress.hasSeenWelcome) { + + if (steps.length > 0) { + setWizardSteps(steps); + setCurrentStep(0); setOverlayMode("welcome"); } - }); + })(); return () => { isMounted = false; @@ -504,83 +649,17 @@ export function OnboardingController() { - {/* Two-column layout */} -
- {/* Left: Feature list */} -
- {WIZARD_STEPS.map((step, index) => ( - - ))} -
- - {/* Right: Illustration */} - -
- - {/* Analytics consent — opt-in by default. */} -
- - - - - -
-

- {t("onboardingAnalyticsTitle")} -

-

- {t( - "onboardingAnalyticsDescription", - )} -

-
-
-
-
+ )}
@@ -595,7 +674,7 @@ export function OnboardingController() { {t("onboardingBack")} - {currentStep < WIZARD_STEPS.length - 1 ? ( + {currentStep < wizardSteps.length - 1 ? ( )} @@ -622,6 +709,164 @@ export function OnboardingController() { ); } +interface WizardStepBodyProps { + step: WizardStep; + steps: WizardStep[]; + currentStep: number; + onSelectStep: (index: number) => void; + analyticsOptIn: boolean; + onAnalyticsOptInChange: (value: boolean) => void; + t: (key: string, options?: Record) => string; +} + +/** + * Renders the body for whichever wizard step is currently active. + * Each step type uses a different layout: feature steps keep the + * two-column list+illustration shape, what's-new shows a single- + * column stack of release items, consent shows a centred checkbox + * card. + */ +function WizardStepBody({ + step, + steps, + currentStep, + onSelectStep, + analyticsOptIn, + onAnalyticsOptInChange, + t, +}: WizardStepBodyProps) { + if (step.type === "feature") { + const featureIndices = steps + .map((s, idx) => (s.type === "feature" ? idx : -1)) + .filter((idx) => idx !== -1); + return ( +
+
+ {featureIndices.map((idx) => { + const featureStep = steps[idx] as FeatureStep; + const active = idx === currentStep; + return ( + + ); + })} +
+ +
+ ); + } + + if (step.type === "whats-new") { + return ( +
+
+

+ {t("whatsNewSectionLabel", { + version: step.entry.version, + })} +

+

+ {t(step.entry.titleKey)} +

+

+ {t(step.entry.subtitleKey)} +

+
+
    + {step.entry.items.map((item: WhatsNewItem) => ( +
  • +
    + {WHATS_NEW_ICONS[item.iconKey]} +
    +
    +

    + {t(item.titleKey)} +

    +

    + {t(item.descriptionKey)} +

    +
    +
  • + ))} +
+
+ ); + } + + // consent step + return ( +
+
+

+ {t("onboardingConsentSectionLabel")} +

+

+ {t("onboardingAnalyticsTitle")} +

+

+ {t("onboardingAnalyticsDescription")} +

+
+
+ + + + + +

+ {t("settingsAnalyticsToggleLabel")} +

+
+
+
+
+ ); +} + const WIZARD_VIDEOS: Record = { mcp: "https://cdn.jsdelivr.net/gh/AkaraChen/aghub-docs@main/public/mcp.mp4", skills: "https://cdn.jsdelivr.net/gh/AkaraChen/aghub-docs@main/public/skills.mp4", diff --git a/crates/desktop/src/lib/locales/en.ts b/crates/desktop/src/lib/locales/en.ts index 6afce9f3..5b8e035b 100644 --- a/crates/desktop/src/lib/locales/en.ts +++ b/crates/desktop/src/lib/locales/en.ts @@ -185,10 +185,24 @@ export default { onboardingAnalyticsTitle: "Help improve aghub", onboardingAnalyticsDescription: "Share anonymous usage data and crash reports so we can fix bugs and prioritise features. You can change this any time in Settings.", + onboardingConsentSectionLabel: "Privacy", settingsAnalyticsHeading: "Usage analytics", settingsAnalyticsDescription: "Send anonymous usage data and crash reports to help improve aghub. Disabling this stops both event capture and session replay.", settingsAnalyticsToggleLabel: "Share anonymous usage data", + whatsNewSectionLabel: "What's new in {{version}}", + whatsNewV02Title: "Plugins, privacy, and polish", + whatsNewV02Subtitle: + "A grab bag of upgrades since the last release. Tap through to see what's changed.", + whatsNewV02PluginsTitle: "Claude Code plugin support", + whatsNewV02PluginsDescription: + "Browse and install Claude Code plugins from the Market tab — same flow you'd expect for skills and MCP servers.", + whatsNewV02AnalyticsTitle: "You're in control of analytics", + whatsNewV02AnalyticsDescription: + "A new privacy switch in Settings lets you opt out of usage data and session replay any time.", + whatsNewV02PolishTitle: "Sidebar and search refinements", + whatsNewV02PolishDescription: + "Cleaner navigation, faster global search, and a few quiet performance fixes throughout the app.", onboardingSkip: "Skip", onboardingBack: "Back", onboardingNext: "Next", diff --git a/crates/desktop/src/lib/locales/zh-Hans.ts b/crates/desktop/src/lib/locales/zh-Hans.ts index e94ff366..53a5a0ba 100644 --- a/crates/desktop/src/lib/locales/zh-Hans.ts +++ b/crates/desktop/src/lib/locales/zh-Hans.ts @@ -174,10 +174,23 @@ export default { onboardingAnalyticsTitle: "帮助改进 aghub", onboardingAnalyticsDescription: "分享匿名使用数据和崩溃报告,帮助我们修复 bug 并确定功能优先级。可随时在设置中更改。", + onboardingConsentSectionLabel: "隐私", settingsAnalyticsHeading: "使用统计", settingsAnalyticsDescription: "发送匿名使用数据和崩溃报告以帮助改进 aghub。关闭后将同时停止事件采集和会话回放。", settingsAnalyticsToggleLabel: "分享匿名使用数据", + whatsNewSectionLabel: "{{version}} 的新功能", + whatsNewV02Title: "插件、隐私与体验打磨", + whatsNewV02Subtitle: "上一版本以来的一系列升级,点击查看详情。", + whatsNewV02PluginsTitle: "支持 Claude Code 插件", + whatsNewV02PluginsDescription: + "在「市场」标签页中浏览并安装 Claude Code 插件 —— 与技能、MCP 服务的安装流程一致。", + whatsNewV02AnalyticsTitle: "数据采集由你做主", + whatsNewV02AnalyticsDescription: + "设置中新增隐私开关,可随时停用使用数据采集和会话回放。", + whatsNewV02PolishTitle: "侧边栏与搜索优化", + whatsNewV02PolishDescription: + "导航更清晰、全局搜索更快,并修复了多处性能问题。", onboardingSkip: "跳过", onboardingBack: "返回", onboardingNext: "下一步", diff --git a/crates/desktop/src/lib/locales/zh-Hant.ts b/crates/desktop/src/lib/locales/zh-Hant.ts index c0c774a9..75c4af5d 100644 --- a/crates/desktop/src/lib/locales/zh-Hant.ts +++ b/crates/desktop/src/lib/locales/zh-Hant.ts @@ -174,10 +174,23 @@ export default { onboardingAnalyticsTitle: "協助改進 aghub", onboardingAnalyticsDescription: "分享匿名使用資料和當機回報,協助我們修正錯誤並決定功能優先順序。可隨時在設定中更改。", + onboardingConsentSectionLabel: "隱私", settingsAnalyticsHeading: "使用統計", settingsAnalyticsDescription: "傳送匿名使用資料和當機回報以協助改進 aghub。關閉後將同時停止事件擷取和工作階段重播。", settingsAnalyticsToggleLabel: "分享匿名使用資料", + whatsNewSectionLabel: "{{version}} 的新功能", + whatsNewV02Title: "外掛、隱私與體驗精修", + whatsNewV02Subtitle: "上一個版本以來的一系列升級,點擊查看詳情。", + whatsNewV02PluginsTitle: "支援 Claude Code 外掛", + whatsNewV02PluginsDescription: + "在「市集」分頁中瀏覽並安裝 Claude Code 外掛 —— 與技能、MCP 伺服器的安裝流程一致。", + whatsNewV02AnalyticsTitle: "資料蒐集由你決定", + whatsNewV02AnalyticsDescription: + "設定中新增隱私開關,可隨時關閉使用資料蒐集與工作階段重播。", + whatsNewV02PolishTitle: "側邊欄與搜尋優化", + whatsNewV02PolishDescription: + "導覽更清晰、全域搜尋更快速,並修復了多處效能問題。", onboardingSkip: "跳過", onboardingBack: "返回", onboardingNext: "下一步", diff --git a/crates/desktop/src/lib/store.ts b/crates/desktop/src/lib/store.ts index 7aeb7b7a..3d83aade 100644 --- a/crates/desktop/src/lib/store.ts +++ b/crates/desktop/src/lib/store.ts @@ -1,4 +1,8 @@ export { disableAgent, enableAgent, getDisabledAgents } from "./store/agents"; +export { + getLastSeenWhatsNewVersion, + setLastSeenWhatsNewVersion, +} from "./store/whats-new"; export { type AnalyticsConsent, getAnalyticsConsent, diff --git a/crates/desktop/src/lib/store/whats-new.ts b/crates/desktop/src/lib/store/whats-new.ts new file mode 100644 index 00000000..96c9a6ad --- /dev/null +++ b/crates/desktop/src/lib/store/whats-new.ts @@ -0,0 +1,24 @@ +import { getStore } from "."; + +const STORE_KEY = "lastSeenWhatsNewVersion"; + +/** + * The highest app version for which the user has acknowledged the + * What's New step in the upgrade wizard. We only show entries + * strictly newer than this. `null` means the user has never + * acknowledged any version (a brand-new install or pre-feature + * upgrade). + */ +export async function getLastSeenWhatsNewVersion(): Promise { + const store = await getStore(); + const value = await store.get(STORE_KEY); + return typeof value === "string" && value.length > 0 ? value : null; +} + +export async function setLastSeenWhatsNewVersion( + version: string, +): Promise { + const store = await getStore(); + await store.set(STORE_KEY, version); + await store.save(); +} diff --git a/crates/desktop/src/lib/whats-new.ts b/crates/desktop/src/lib/whats-new.ts new file mode 100644 index 00000000..481c2feb --- /dev/null +++ b/crates/desktop/src/lib/whats-new.ts @@ -0,0 +1,97 @@ +/** + * Hand-authored release notes used by the upgrade wizard. Entries + * are ordered newest first. The wizard surfaces every entry whose + * `version` is strictly greater than the user's + * `lastSeenWhatsNewVersion` from the Tauri store. + * + * Entries reference i18n keys instead of inlined strings so we can + * translate them through the same pipeline the rest of the app + * uses. `iconKey` is a string the wizard maps to a heroicon — see + * the renderer in onboarding-controller.tsx for the supported set. + * + * For now this is a hand-curated mock list. If the cadence outgrows + * a TypeScript array we can swap to a markdown-bundled or + * remote-fetched source without changing the wizard's rendering. + */ + +export interface WhatsNewItem { + titleKey: string; + descriptionKey: string; + iconKey: "sparkles" | "puzzle" | "shield"; +} + +export interface WhatsNewEntry { + version: string; + titleKey: string; + subtitleKey: string; + items: WhatsNewItem[]; +} + +export const WHATS_NEW_ENTRIES: readonly WhatsNewEntry[] = [ + { + version: "0.2.0", + titleKey: "whatsNewV02Title", + subtitleKey: "whatsNewV02Subtitle", + items: [ + { + titleKey: "whatsNewV02PluginsTitle", + descriptionKey: "whatsNewV02PluginsDescription", + iconKey: "puzzle", + }, + { + titleKey: "whatsNewV02AnalyticsTitle", + descriptionKey: "whatsNewV02AnalyticsDescription", + iconKey: "shield", + }, + { + titleKey: "whatsNewV02PolishTitle", + descriptionKey: "whatsNewV02PolishDescription", + iconKey: "sparkles", + }, + ], + }, +] as const; + +/** + * Compare two semver-ish strings without pulling in a dep. Handles + * the "x.y.z" shape we use; falls back to lexicographic comparison + * for anything weirder so we never crash on a malformed entry. + */ +function compareVersions(a: string, b: string): number { + const aParts = a.split(".").map(Number); + const bParts = b.split(".").map(Number); + if (aParts.some(Number.isNaN) || bParts.some(Number.isNaN)) { + return a.localeCompare(b); + } + const len = Math.max(aParts.length, bParts.length); + for (let i = 0; i < len; i++) { + const ai = aParts[i] ?? 0; + const bi = bParts[i] ?? 0; + if (ai !== bi) return ai - bi; + } + return 0; +} + +/** + * Returns release entries the user hasn't acknowledged yet. When + * `lastSeen` is null we treat the user as "first-time-seeing-notes" + * and only surface the most recent entry — long-time users + * upgrading shouldn't be blasted with months of historical notes. + */ +export function pendingWhatsNew( + lastSeen: string | null, + currentVersion: string, +): WhatsNewEntry[] { + const sorted = [...WHATS_NEW_ENTRIES].sort((a, b) => + compareVersions(a.version, b.version), + ); + if (lastSeen === null) { + const newest = sorted[sorted.length - 1]; + return newest ? [newest] : []; + } + return sorted.filter( + (entry) => + compareVersions(entry.version, lastSeen) > 0 && + compareVersions(entry.version, currentVersion) <= 0, + ); +}