Skip to content

feat: add PostHog custom survey modal on second app launch#4604

Closed
devin-ai-integration[bot] wants to merge 7 commits intomainfrom
devin/1773652691-survey-modal
Closed

feat: add PostHog custom survey modal on second app launch#4604
devin-ai-integration[bot] wants to merge 7 commits intomainfrom
devin/1773652691-survey-modal

Conversation

@devin-ai-integration
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot commented Mar 16, 2026

Summary

Adds a 4-question onboarding survey that appears as a modal dialog on the user's second app launch (after a full quit). Responses are captured as PostHog custom survey events via the existing analytics plugin.

Backend (Rust): New persisted store keys (AppOpenCount, SurveyDismissed) with Tauri commands to get/set/increment the open count and track whether the survey was dismissed/submitted.

Frontend (React): SurveyModal component rendered inside EventListeners. On mount, it increments the app open count and shows the dialog when count is ≥ 2 and the survey hasn't been dismissed. The modal steps through 4 questions (select/multi-select), then fires a "survey sent" event with PostHog's $survey_id / $survey_response property convention. A window label guard ensures the survey only runs in the "main" window (not the control window).

Questions:

  1. How did you find us? (single select)
  2. Why did you choose Char over other options? (multi-select)
  3. What's your role? (single select)
  4. How are you currently taking notes? (multi-select)

Devtools: New SurveyCard in the devtools sidebar with buttons to inspect current state (open count, dismissed flag), reset both values, and force-open the survey modal for testing. The SurveyModal accepts optional forceOpen and onClose props to support this.

Updates since last revision

  • Resolved merge conflicts with main — kept both survey commands and char_v1p1_preview commands
  • Added getCurrentWebviewWindowLabel() !== "main" guard to prevent the survey from triggering (and inflating the open count) in the control window

Review & Testing Checklist for Human

  • PostHog survey ID: SURVEY_ID is set to "onboarding_survey_v1" — you'll need to create a matching API-mode survey in the PostHog dashboard (or replace with the actual UUID) for responses to appear correctly in the PostHog survey UI.
  • TypeScript bindings were manually edited (tauri.gen.ts). Run cargo test export_types in apps/desktop/src-tauri to regenerate and confirm the manual additions match specta's output.
  • Multi-select response format: Multi-select answers are sent as arrays, single-select as strings. Verify the PostHog analytics plugin and dashboard handle array values for $survey_response keys correctly.
  • analyticsCommands.event payload cast: The PostHog event payload is built as Record<string, unknown> then cast via as. Verify the analytics plugin actually accepts arbitrary properties, or the extra $survey_response_* keys may be silently dropped.
  • Test plan: Build the desktop app, launch it twice (quit fully between launches), and verify the survey modal appears on the second launch. Submit responses, confirm "survey sent" event appears in PostHog. Relaunch a third time and confirm the survey does not reappear. Also test dismissing (clicking X / closing) and confirm "survey dismissed" fires and the modal does not return.
  • Devtools test: Open the devtools sidebar, verify the Survey card shows current state after clicking "Refresh State". Test "Reset App Open Count", "Reset Survey Dismissed", and "Show Survey Modal" buttons.

Notes

  • On macOS, closing the window without Cmd+Q keeps the app alive in the background. Reopening from the dock recreates the webview and will increment the count — so the "2nd launch" may actually be a "2nd window open" rather than a true process restart. This matches the stated intent ("mostly after updates") but is worth being aware of.
  • The survey response format uses $survey_response for Q1 and $survey_response_N for subsequent questions. Multi-select answers are sent as arrays; single-select as strings.
  • The trigger uses >= 2 (not strict equality) combined with the SurveyDismissed flag, so if something goes wrong on the second launch the user will still see the survey on subsequent launches.

Link to Devin session: https://app.devin.ai/sessions/3f4f1d83f2dd40a5aa6d39fd8b0bbe9f
Requested by: @ComputelessComputer

- 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>
@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@netlify
Copy link
Copy Markdown

netlify bot commented Mar 16, 2026

Deploy Preview for hyprnote canceled.

Name Link
🔨 Latest commit feb098f
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote/deploys/69d44a1ee306a80008595437

@netlify
Copy link
Copy Markdown

netlify bot commented Mar 16, 2026

Deploy Preview for hyprnote-storybook canceled.

Name Link
🔨 Latest commit 621243a
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote-storybook/deploys/69b7cfc1c5e05c0008625147

John and others added 4 commits March 16, 2026 09:26
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@netlify
Copy link
Copy Markdown

netlify bot commented Apr 5, 2026

Deploy Preview for char-cli-web canceled.

Name Link
🔨 Latest commit feb098f
🔍 Latest deploy log https://app.netlify.com/projects/char-cli-web/deploys/69d44a1e931eb70007789da7

…ommands)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment on lines +162 to +165
if (hasChecked.current || !isTauri()) {
return;
}
hasChecked.current = true;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 SurveyModal missing window label guard causes count inflation and survey in wrong window

The SurveyModal is rendered by EventListeners (apps/desktop/src/services/event-listeners.tsx:149), which is mounted at the root level for ALL Tauri windows (apps/desktop/src/main.tsx:111), including the tiny 120×36px control window. The useEffect at line 162 only checks !isTauri() but does not check getCurrentWebviewWindowLabel() === "main", unlike the sibling hooks useUpdaterEvents (apps/desktop/src/services/event-listeners.tsx:23) and useNotificationEvents (apps/desktop/src/services/event-listeners.tsx:75) which both guard against non-main windows. This means when the control window opens, incrementAppOpenCount is called again (inflating the counter and triggering the survey earlier than intended), and the survey dialog could render inside the control window's tiny viewport.

Suggested change
if (hasChecked.current || !isTauri()) {
return;
}
hasChecked.current = true;
if (hasChecked.current || !isTauri() || getCurrentWebviewWindowLabel() !== "main") {
return;
}
hasChecked.current = true;
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +214 to +218
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] ?? "");
});
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Survey response keys follow PostHog convention but multiSelect sends arrays

The analytics payload construction at apps/desktop/src/survey/survey-modal.tsx:214-218 uses $survey_response for the first question and $survey_response_N for subsequent ones, matching PostHog's survey response format. For multi-select questions, the value is an array of strings rather than a single string. Verify that the analytics backend (PostHog or equivalent) correctly handles array values for $survey_response keys, as some integrations expect only scalar values.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@ComputelessComputer ComputelessComputer deleted the devin/1773652691-survey-modal branch April 11, 2026 03:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant