From cc566017c23def9fd5c375f42f1aa02c2220c92c Mon Sep 17 00:00:00 2001 From: John Date: Mon, 16 Mar 2026 09:21:47 +0000 Subject: [PATCH 1/3] feat: add PostHog custom survey modal on second app launch - Add AppOpenCount and SurveyDismissed store keys in Rust backend - Add increment_app_open_count, get/set_survey_dismissed Tauri commands - Create SurveyModal component with 4 select/multi-select questions - Show survey on second app open, capture 'survey sent' PostHog event - Questions: discovery source, why Char, role, current note-taking Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/desktop/src-tauri/src/commands.rs | 25 ++ apps/desktop/src-tauri/src/ext.rs | 45 +++ apps/desktop/src-tauri/src/lib.rs | 3 + apps/desktop/src-tauri/src/store.rs | 2 + apps/desktop/src/services/event-listeners.tsx | 3 +- apps/desktop/src/survey/survey-modal.tsx | 293 ++++++++++++++++++ apps/desktop/src/types/tauri.gen.ts | 24 ++ 7 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/survey/survey-modal.tsx diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 1f754b3731..2e10eed990 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -179,6 +179,31 @@ pub async fn set_recently_opened_sessions( app.set_recently_opened_sessions(v) } +#[tauri::command] +#[specta::specta] +pub async fn increment_app_open_count( + app: tauri::AppHandle, +) -> Result { + app.increment_app_open_count() +} + +#[tauri::command] +#[specta::specta] +pub async fn get_survey_dismissed( + app: tauri::AppHandle, +) -> Result { + app.get_survey_dismissed() +} + +#[tauri::command] +#[specta::specta] +pub async fn set_survey_dismissed( + app: tauri::AppHandle, + v: bool, +) -> Result<(), String> { + app.set_survey_dismissed(v) +} + #[tauri::command] #[specta::specta] pub async fn list_plugins( diff --git a/apps/desktop/src-tauri/src/ext.rs b/apps/desktop/src-tauri/src/ext.rs index 00c4823463..a096d19206 100644 --- a/apps/desktop/src-tauri/src/ext.rs +++ b/apps/desktop/src-tauri/src/ext.rs @@ -17,6 +17,12 @@ pub trait AppExt { fn get_recently_opened_sessions(&self) -> Result, String>; fn set_recently_opened_sessions(&self, v: String) -> Result<(), String>; + + fn get_app_open_count(&self) -> Result; + fn increment_app_open_count(&self) -> Result; + + fn get_survey_dismissed(&self) -> Result; + fn set_survey_dismissed(&self, v: bool) -> Result<(), String>; } impl> AppExt for T { @@ -111,4 +117,43 @@ impl> AppExt for T { .map_err(|e| e.to_string())?; store.save().map_err(|e| e.to_string()) } + + #[tracing::instrument(skip_all)] + fn get_app_open_count(&self) -> Result { + let store = self.desktop_store()?; + store + .get(StoreKey::AppOpenCount) + .map(|opt: Option| opt.unwrap_or(0)) + .map_err(|e| e.to_string()) + } + + #[tracing::instrument(skip_all)] + fn increment_app_open_count(&self) -> Result { + let current = self.get_app_open_count()?; + let new_count = current + 1; + let store = self.desktop_store()?; + store + .set(StoreKey::AppOpenCount, new_count) + .map_err(|e| e.to_string())?; + store.save().map_err(|e| e.to_string())?; + Ok(new_count) + } + + #[tracing::instrument(skip_all)] + fn get_survey_dismissed(&self) -> Result { + let store = self.desktop_store()?; + store + .get(StoreKey::SurveyDismissed) + .map(|opt| opt.unwrap_or(false)) + .map_err(|e| e.to_string()) + } + + #[tracing::instrument(skip_all)] + fn set_survey_dismissed(&self, v: bool) -> Result<(), String> { + let store = self.desktop_store()?; + store + .set(StoreKey::SurveyDismissed, v) + .map_err(|e| e.to_string())?; + store.save().map_err(|e| e.to_string()) + } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 38a73853e1..7c76c6ac2e 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -357,6 +357,9 @@ fn make_specta_builder() -> tauri_specta::Builder { commands::set_pinned_tabs::, commands::get_recently_opened_sessions::, commands::set_recently_opened_sessions::, + commands::increment_app_open_count::, + commands::get_survey_dismissed::, + commands::set_survey_dismissed::, commands::list_plugins::, ]) .error_handling(tauri_specta::ErrorHandlingMode::Result) diff --git a/apps/desktop/src-tauri/src/store.rs b/apps/desktop/src-tauri/src/store.rs index 74cb455d8b..554b133a32 100644 --- a/apps/desktop/src-tauri/src/store.rs +++ b/apps/desktop/src-tauri/src/store.rs @@ -8,6 +8,8 @@ pub enum StoreKey { TinybaseValues, PinnedTabs, RecentlyOpenedSessions, + AppOpenCount, + SurveyDismissed, } impl ScopedStoreKey for StoreKey {} diff --git a/apps/desktop/src/services/event-listeners.tsx b/apps/desktop/src/services/event-listeners.tsx index 069fa18c17..9853aafba2 100644 --- a/apps/desktop/src/services/event-listeners.tsx +++ b/apps/desktop/src/services/event-listeners.tsx @@ -9,6 +9,7 @@ import { import { getCurrentWebviewWindowLabel } from "@hypr/plugin-windows"; import * as main from "~/store/tinybase/store/main"; +import { SurveyModal } from "~/survey/survey-modal"; import { createSession, getOrCreateSessionForEventId, @@ -145,5 +146,5 @@ export function EventListeners() { useUpdaterEvents(); useNotificationEvents(); - return null; + return ; } diff --git a/apps/desktop/src/survey/survey-modal.tsx b/apps/desktop/src/survey/survey-modal.tsx new file mode 100644 index 0000000000..c35438cde9 --- /dev/null +++ b/apps/desktop/src/survey/survey-modal.tsx @@ -0,0 +1,293 @@ +import { isTauri } from "@tauri-apps/api/core"; +import { CheckIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { commands as analyticsCommands } from "@hypr/plugin-analytics"; +import { Button } from "@hypr/ui/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@hypr/ui/components/ui/dialog"; +import { cn } from "@hypr/utils"; + +import { commands } from "~/types/tauri.gen"; + +const SURVEY_ID = "onboarding_survey_v1"; + +interface SurveyQuestion { + id: string; + question: string; + options: string[]; + multiSelect: boolean; +} + +const SURVEY_QUESTIONS: SurveyQuestion[] = [ + { + id: "how_found_us", + question: "How did you find us?", + options: [ + "Google search", + "GitHub", + "AI search (ChatGPT, Perplexity, etc.)", + "Friend / colleague", + "Other", + ], + multiSelect: false, + }, + { + id: "why_char", + question: "Why did you choose Char over other options?", + options: [ + "I want my data stored locally / privacy matters", + "I want to choose my own AI provider", + "It's open source", + "I was looking for a free AI meeting tool", + "Other", + ], + multiSelect: true, + }, + { + id: "role", + question: "What's your role?", + options: [ + "Engineer / Developer", + "Founder / Technical leader", + "Legal / Healthcare / Finance", + "Product / Design / Ops", + "Other", + ], + multiSelect: false, + }, + { + id: "current_note_taking", + question: "How are you currently taking notes?", + options: [ + "I'm not / pen & paper", + "Manually in an app (Apple Notes, Notion, Google Docs, etc.)", + "AI tool that joins the call (Otter, Fireflies, etc.)", + "AI tool without a bot (Granola, Jamie, etc.)", + "Other", + ], + multiSelect: true, + }, +]; + +function OptionButton({ + label, + selected, + onClick, +}: { + label: string; + selected: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function QuestionStep({ + question, + responses, + onToggle, +}: { + question: SurveyQuestion; + responses: string[]; + onToggle: (option: string) => void; +}) { + return ( +
+

+ {question.question} +

+ {question.multiSelect && ( +

Select all that apply

+ )} +
+ {question.options.map((option) => ( + onToggle(option)} + /> + ))} +
+
+ ); +} + +export function SurveyModal() { + const [open, setOpen] = useState(false); + const [step, setStep] = useState(0); + const [responses, setResponses] = useState>({}); + const hasChecked = useRef(false); + + useEffect(() => { + if (hasChecked.current || !isTauri()) { + return; + } + hasChecked.current = true; + + void (async () => { + const countResult = await commands.incrementAppOpenCount(); + if (countResult.status !== "ok") { + return; + } + + const dismissedResult = await commands.getSurveyDismissed(); + if (dismissedResult.status !== "ok") { + return; + } + + if (countResult.data === 2 && !dismissedResult.data) { + setOpen(true); + } + })(); + }, []); + + const currentQuestion = SURVEY_QUESTIONS[step]; + const currentResponses = responses[currentQuestion.id] ?? []; + const isLastStep = step === SURVEY_QUESTIONS.length - 1; + const canProceed = currentResponses.length > 0; + + const handleToggle = useCallback( + (option: string) => { + setResponses((prev) => { + const questionId = currentQuestion.id; + const current = prev[questionId] ?? []; + + if (currentQuestion.multiSelect) { + const next = current.includes(option) + ? current.filter((o) => o !== option) + : [...current, option]; + return { ...prev, [questionId]: next }; + } + + return { ...prev, [questionId]: [option] }; + }); + }, + [currentQuestion], + ); + + const handleNext = useCallback(() => { + if (isLastStep) { + void handleSubmit(); + } else { + setStep((s) => s + 1); + } + }, [isLastStep, step]); + + const handleBack = useCallback(() => { + if (step > 0) { + setStep((s) => s - 1); + } + }, [step]); + + const handleSubmit = useCallback(async () => { + const payload: Record = { + event: "survey sent", + $survey_id: SURVEY_ID, + }; + + SURVEY_QUESTIONS.forEach((q, i) => { + const answer = responses[q.id] ?? []; + const key = i === 0 ? "$survey_response" : `$survey_response_${i}`; + payload[key] = q.multiSelect ? answer : answer[0] ?? ""; + }); + + void analyticsCommands.event( + payload as Parameters[0], + ); + + void commands.setSurveyDismissed(true); + setOpen(false); + }, [responses]); + + const handleDismiss = useCallback(() => { + void analyticsCommands.event({ + event: "survey dismissed", + $survey_id: SURVEY_ID, + }); + void commands.setSurveyDismissed(true); + setOpen(false); + }, []); + + return ( + !v && handleDismiss()}> + + + + Quick survey + + + Help us make Char better for you + + + +
+ +
+ + +
+
+ {SURVEY_QUESTIONS.map((_, i) => ( + + ))} +
+
+ {step > 0 && ( + + )} + +
+
+
+
+
+ ); +} diff --git a/apps/desktop/src/types/tauri.gen.ts b/apps/desktop/src/types/tauri.gen.ts index 0014c6bdbd..21a6be06d7 100644 --- a/apps/desktop/src/types/tauri.gen.ts +++ b/apps/desktop/src/types/tauri.gen.ts @@ -107,6 +107,30 @@ async setRecentlyOpenedSessions(v: string) : Promise> { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } +}, +async incrementAppOpenCount() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("increment_app_open_count") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async getSurveyDismissed() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("get_survey_dismissed") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async setSurveyDismissed(v: boolean) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("set_survey_dismissed", { v }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} } } From 3c9730c29f8aeb5eeffe22668cac7caf24c2626e Mon Sep 17 00:00:00 2001 From: John Date: Mon, 16 Mar 2026 09:26:29 +0000 Subject: [PATCH 2/3] fix: reorder hooks to fix stale closure, use >= 2 trigger for survey Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/desktop/src/survey/survey-modal.tsx | 30 ++++++++++++------------ 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/survey/survey-modal.tsx b/apps/desktop/src/survey/survey-modal.tsx index c35438cde9..4e6566c830 100644 --- a/apps/desktop/src/survey/survey-modal.tsx +++ b/apps/desktop/src/survey/survey-modal.tsx @@ -165,7 +165,7 @@ export function SurveyModal() { return; } - if (countResult.data === 2 && !dismissedResult.data) { + if (countResult.data >= 2 && !dismissedResult.data) { setOpen(true); } })(); @@ -195,20 +195,6 @@ export function SurveyModal() { [currentQuestion], ); - const handleNext = useCallback(() => { - if (isLastStep) { - void handleSubmit(); - } else { - setStep((s) => s + 1); - } - }, [isLastStep, step]); - - const handleBack = useCallback(() => { - if (step > 0) { - setStep((s) => s - 1); - } - }, [step]); - const handleSubmit = useCallback(async () => { const payload: Record = { event: "survey sent", @@ -238,6 +224,20 @@ export function SurveyModal() { setOpen(false); }, []); + const handleNext = useCallback(() => { + if (isLastStep) { + void handleSubmit(); + } else { + setStep((s) => s + 1); + } + }, [isLastStep, handleSubmit]); + + const handleBack = useCallback(() => { + if (step > 0) { + setStep((s) => s - 1); + } + }, [step]); + return ( !v && handleDismiss()}> From 621243ae25ee08ddc9457c89042c9da18a7bd6a2 Mon Sep 17 00:00:00 2001 From: John Date: Mon, 16 Mar 2026 09:39:02 +0000 Subject: [PATCH 3/3] style: apply dprint formatting Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/desktop/src/services/event-listeners.tsx | 2 +- apps/desktop/src/survey/survey-modal.tsx | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/services/event-listeners.tsx b/apps/desktop/src/services/event-listeners.tsx index 9853aafba2..0e29c8ca36 100644 --- a/apps/desktop/src/services/event-listeners.tsx +++ b/apps/desktop/src/services/event-listeners.tsx @@ -9,12 +9,12 @@ import { import { getCurrentWebviewWindowLabel } from "@hypr/plugin-windows"; import * as main from "~/store/tinybase/store/main"; -import { SurveyModal } from "~/survey/survey-modal"; import { createSession, getOrCreateSessionForEventId, } from "~/store/tinybase/store/sessions"; import { useTabs } from "~/store/zustand/tabs"; +import { SurveyModal } from "~/survey/survey-modal"; function useUpdaterEvents() { const openNew = useTabs((state) => state.openNew); diff --git a/apps/desktop/src/survey/survey-modal.tsx b/apps/desktop/src/survey/survey-modal.tsx index 4e6566c830..06121c7b7d 100644 --- a/apps/desktop/src/survey/survey-modal.tsx +++ b/apps/desktop/src/survey/survey-modal.tsx @@ -204,7 +204,7 @@ export function SurveyModal() { SURVEY_QUESTIONS.forEach((q, i) => { const answer = responses[q.id] ?? []; const key = i === 0 ? "$survey_response" : `$survey_response_${i}`; - payload[key] = q.multiSelect ? answer : answer[0] ?? ""; + payload[key] = q.multiSelect ? answer : (answer[0] ?? ""); }); void analyticsCommands.event( @@ -277,11 +277,7 @@ export function SurveyModal() { Back )} -