diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index f3bfed02..9107189a 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -87,6 +87,8 @@ import { toast } from "sonner" import { useVoiceMode } from '@/hooks/useVoiceMode' import { useVoiceTTS } from '@/hooks/useVoiceTTS' import { useMeetingTranscription, type MeetingTranscriptionState, type CalendarEventMeta } from '@/hooks/useMeetingTranscription' +import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity' +import * as analytics from '@/lib/analytics' type DirEntry = z.infer type RunEventType = z.infer @@ -624,6 +626,8 @@ function App() { type ShortcutPane = 'left' | 'right' type MarkdownHistoryHandlers = { undo: () => boolean; redo: () => boolean } + useAnalyticsIdentity() + // File browser state (for Knowledge section) const [selectedPath, setSelectedPath] = useState(null) const [fileContent, setFileContent] = useState('') @@ -2163,6 +2167,7 @@ function App() { currentRunId = run.id newRunCreatedAt = run.createdAt setRunId(currentRunId) + analytics.chatSessionCreated(currentRunId) // Update active chat tab's runId to the new run setChatTabs((prev) => prev.map((tab) => ( tab.id === submitTabId @@ -2223,6 +2228,11 @@ function App() { voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, searchEnabled: searchEnabled || undefined, }) + analytics.chatMessageSent({ + voiceInput: pendingVoiceInputRef.current || undefined, + voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, + searchEnabled: searchEnabled || undefined, + }) } else { // Legacy path: plain string with optional XML-formatted @mentions. let formattedMessage = userMessage @@ -2254,6 +2264,11 @@ function App() { voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, searchEnabled: searchEnabled || undefined, }) + analytics.chatMessageSent({ + voiceInput: pendingVoiceInputRef.current || undefined, + voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, + searchEnabled: searchEnabled || undefined, + }) titleSource = formattedMessage } @@ -4267,6 +4282,7 @@ function App() { const title = getBaseName(tab.path) try { await window.ipc.invoke('export:note', { markdown, format, title }) + analytics.noteExported(format) } catch (err) { console.error('Export failed:', err) } diff --git a/apps/x/apps/renderer/src/components/search-dialog.tsx b/apps/x/apps/renderer/src/components/search-dialog.tsx index 22e398fe..32bca1b3 100644 --- a/apps/x/apps/renderer/src/components/search-dialog.tsx +++ b/apps/x/apps/renderer/src/components/search-dialog.tsx @@ -1,4 +1,6 @@ import { useState, useEffect, useCallback } from 'react' +import posthog from 'posthog-js' +import * as analytics from '@/lib/analytics' import { FileTextIcon, MessageSquareIcon } from 'lucide-react' import { CommandDialog, @@ -68,6 +70,8 @@ export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: .then((res) => { if (!cancelled) { setResults(res.results) + analytics.searchExecuted(types) + posthog.people.set_once({ has_used_search: true }) } }) .catch((err) => { diff --git a/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts b/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts new file mode 100644 index 00000000..47975fa5 --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts @@ -0,0 +1,74 @@ +import { useEffect } from 'react' +import posthog from 'posthog-js' + +/** + * Identifies the user in PostHog when signed into Rowboat, + * and sets user properties for connected OAuth providers. + * Call once at the App level. + */ +export function useAnalyticsIdentity() { + // On mount: check current OAuth state and identify if signed in + useEffect(() => { + async function init() { + try { + const result = await window.ipc.invoke('oauth:getState', null) + const config = result.config || {} + + // Identify if Rowboat account is connected + const rowboat = config.rowboat + if (rowboat?.connected && rowboat?.userId) { + posthog.identify(rowboat.userId) + } + + // Set provider connection flags + const providers = ['gmail', 'calendar', 'slack', 'rowboat'] + const props: Record = { signed_in: !!rowboat?.connected } + for (const p of providers) { + props[`${p}_connected`] = !!config[p]?.connected + } + posthog.people.set(props) + + // Count notes for total_notes property + try { + const dirs = await window.ipc.invoke('workspace:readdir', { path: '/' }) + let totalNotes = 0 + if (dirs?.entries) { + for (const entry of dirs.entries) { + if (entry.type === 'directory') { + try { + const sub = await window.ipc.invoke('workspace:readdir', { path: `/${entry.name}` }) + totalNotes += sub?.entries?.length ?? 0 + } catch { + // skip inaccessible dirs + } + } + } + } + posthog.people.set({ total_notes: totalNotes }) + } catch { + // workspace may not be available + } + } catch { + // oauth state unavailable + } + } + init() + }, []) + + // Listen for OAuth connect/disconnect events to update identity + useEffect(() => { + const cleanup = window.ipc.on('oauth:didConnect', (event) => { + if (!event.success) return + + // If Rowboat provider connected, identify user + if (event.provider === 'rowboat' && event.userId) { + posthog.identify(event.userId) + posthog.people.set({ signed_in: true }) + } + + posthog.people.set({ [`${event.provider}_connected`]: true }) + }) + + return cleanup + }, []) +} diff --git a/apps/x/apps/renderer/src/hooks/useOAuth.ts b/apps/x/apps/renderer/src/hooks/useOAuth.ts index de86ce4c..6f315ec0 100644 --- a/apps/x/apps/renderer/src/hooks/useOAuth.ts +++ b/apps/x/apps/renderer/src/hooks/useOAuth.ts @@ -1,5 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { toast } from '@/lib/toast'; +import posthog from 'posthog-js'; +import * as analytics from '@/lib/analytics'; /** * Hook for managing OAuth connection state for a specific provider @@ -40,6 +42,8 @@ export function useOAuth(provider: string) { setIsLoading(false); if (event.success) { + analytics.oauthConnected(provider); + posthog.people.set({ [`${provider}_connected`]: true }); toast(`Successfully connected to ${provider}`, 'success'); // Refresh connection status to ensure consistency checkConnection(); @@ -75,6 +79,8 @@ export function useOAuth(provider: string) { setIsLoading(true); const result = await window.ipc.invoke('oauth:disconnect', { provider }); if (result.success) { + analytics.oauthDisconnected(provider); + posthog.people.set({ [`${provider}_connected`]: false }); toast(`Disconnected from ${provider}`, 'success'); setIsConnected(false); } else { diff --git a/apps/x/apps/renderer/src/hooks/useVoiceMode.ts b/apps/x/apps/renderer/src/hooks/useVoiceMode.ts index ce4f7ccb..b5e1187d 100644 --- a/apps/x/apps/renderer/src/hooks/useVoiceMode.ts +++ b/apps/x/apps/renderer/src/hooks/useVoiceMode.ts @@ -1,6 +1,8 @@ import { useCallback, useRef, useState } from 'react'; import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url'; import { useRowboatAccount } from '@/hooks/useRowboatAccount'; +import posthog from 'posthog-js'; +import * as analytics from '@/lib/analytics'; export type VoiceState = 'idle' | 'connecting' | 'listening'; @@ -146,6 +148,8 @@ export function useVoiceMode() { // Show listening immediately — don't wait for WebSocket setState('listening'); + analytics.voiceInputStarted(); + posthog.people.set_once({ has_used_voice: true }); // Kick off mic + WebSocket in parallel, don't await WebSocket const [stream] = await Promise.all([ diff --git a/apps/x/apps/renderer/src/lib/analytics.ts b/apps/x/apps/renderer/src/lib/analytics.ts new file mode 100644 index 00000000..672ea0c3 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/analytics.ts @@ -0,0 +1,37 @@ +import posthog from 'posthog-js' + +export function chatSessionCreated(runId: string) { + posthog.capture('chat_session_created', { run_id: runId }) +} + +export function chatMessageSent(props: { + voiceInput?: boolean + voiceOutput?: string + searchEnabled?: boolean +}) { + posthog.capture('chat_message_sent', { + voice_input: props.voiceInput ?? false, + voice_output: props.voiceOutput ?? false, + search_enabled: props.searchEnabled ?? false, + }) +} + +export function oauthConnected(provider: string) { + posthog.capture('oauth_connected', { provider }) +} + +export function oauthDisconnected(provider: string) { + posthog.capture('oauth_disconnected', { provider }) +} + +export function voiceInputStarted() { + posthog.capture('voice_input_started') +} + +export function searchExecuted(types: string[]) { + posthog.capture('search_executed', { types }) +} + +export function noteExported(format: string) { + posthog.capture('note_exported', { format }) +}