From 86589dbe50b76bb80c8714dfc06c075784b81341 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:44:25 +0530 Subject: [PATCH 1/2] feat(x): PostHog usage analytics across all feature surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds feature-level instrumentation so dashboards can answer "how important is X and what do people do inside it": - view_opened fired centrally from the currentViewState effect — one event per section visit (email/meetings/bg-tasks/apps/...), plus one-shot has_used_* person properties for cohorts - Email: thread opened, compose opened, sent (mode/attachments/ ai_assisted), AI draft generated, archived, trashed, marked unread, importance/category corrections, bulk category archive, search, instructions saved, manual sync, send failures - Meetings: recording started/stopped (duration), popup action (captured in main — popup window has no PostHog), note opened, summarize failures - Calls: call_ended with duration (call_started already existed) - Background agents: created (manual/coding/copilot), updated, toggled, run clicked, stopped, deleted; run completed/failed with trigger captured in the core runner for a true failure rate - Live notes: saved, toggled, run, stopped, deleted, edit-with-copilot - Code mode: session created (direct vs rowboat, agent) and direct-drive messages (direct mode emits no llm_usage otherwise) - Billing: paywall shown / upgrade clicked by error kind - Search: opened + result selected; Settings: opened + tab changed - Notes: created; edited (deduped to one event per note per session) - Apps: app_rolled_back (rest were already captured in main) All changes are additive — existing events, identity flow, and person properties are untouched. ANALYTICS.md updated with the full catalog. Co-Authored-By: Claude Fable 5 --- apps/x/ANALYTICS.md | 89 +++++++ apps/x/apps/main/src/ipc.ts | 7 +- apps/x/apps/renderer/src/App.tsx | 22 ++ .../src/components/apps/app-detail.tsx | 3 + .../renderer/src/components/bg-tasks-view.tsx | 10 + .../src/components/billing-error-dialog.tsx | 6 + .../renderer/src/components/email-view.tsx | 25 ++ .../src/components/live-note-sidebar.tsx | 7 + .../src/components/live-notes-view.tsx | 3 + .../renderer/src/components/meetings-view.tsx | 3 +- .../renderer/src/components/search-dialog.tsx | 2 + .../src/components/settings-dialog.tsx | 7 +- apps/x/apps/renderer/src/lib/analytics.ts | 235 ++++++++++++++++++ .../core/src/background-tasks/runner.ts | 3 + .../core/src/code-mode/sessions/service.ts | 4 + 15 files changed, 423 insertions(+), 3 deletions(-) diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md index 0f4b3155..7cc9054f 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -96,6 +96,93 @@ All in `apps/renderer/src/lib/analytics.ts`: - `search_executed` — `{ types: string[] }` - `note_exported` — `{ format }` +### `view_opened` — feature-importance funnel + +One event per view the user lands on, fired centrally from the `currentViewState` effect in `apps/renderer/src/App.tsx`. `view` is one of: `chat`, `file`, `graph`, `task`, `suggested-topics`, `meetings`, `live-notes`, `email`, `workspace`, `knowledge-view`, `chat-history`, `home`, `code`, `bg-tasks`, `apps`. Keyed on the view *type*, so switching files or threads inside a view doesn't re-fire. + +This is the top of every feature funnel: unique users on `view = 'email'` ÷ all users = how many people even open email. First visit to a key view also sets a one-shot person property (`has_used_email`, `has_used_meetings`, `has_used_live_notes`, `has_used_bg_agents`, `has_used_apps`, `has_used_code`) for cohort building. + +### Feature action events + +All renderer events live in `apps/renderer/src/lib/analytics.ts` (typed wrappers); the emit sites are in the components named below. Events marked **(main)** are captured in `apps/main/src/ipc.ts` via `capture()` because the operation runs there. + +**Email** (`components/email-view.tsx`): + +- `email_thread_opened` — a thread was expanded in the list +- `email_compose_opened` — `{ mode: 'new' | 'reply' | 'replyAll' | 'forward' | 'draft' }` — a composer was opened +- `email_sent` — `{ mode, has_attachments, ai_assisted }` — `ai_assisted` is true when Write-with-AI produced a draft in that composer +- `email_ai_draft_generated` — `{ mode: 'generate' | 'rewrite' }` — the Write/Edit-with-AI bar completed +- `email_archived` / `email_trashed` — one thread archived / moved to trash +- `email_marked_unread` — explicit mark-as-unread (marking *read* fires automatically on open, so it's deliberately not tracked) +- `email_importance_changed` — `{ importance: 'important' | 'other' }` — user corrected the importance verdict +- `email_category_changed` — `{ category }` — user re-filed a thread +- `email_category_archived` — `{ category }` — bulk "archive all in category" +- `email_searched` — a search query executed (debounced, one per settled query) +- `email_instructions_saved` — standing email-agent instructions saved +- `email_sync_triggered` — manual refresh button + +**Meetings** (`App.tsx`, `components/meetings-view.tsx`): + +- `meeting_recording_started` — `{ has_calendar_event }` — transcription actually began (all entry points: meetings view, home, sidebar, popup funnel through one call site) +- `meeting_recording_stopped` — `{ duration_seconds }` +- `meeting_popup_action` — `{ action: 'take-notes' | 'dismiss' }` **(main)** — the "meeting detected" popup window runs without PostHog, so the action is captured in its IPC handler +- `meeting_note_opened` — a past meeting note opened from the meetings list + +**Calls** (`App.tsx`): + +- `call_started` — (pre-existing, above) fires on every call-button press that starts a call +- `call_ended` — `{ duration_seconds }` + +**Background agents** (`components/bg-tasks-view.tsx`, `components/apps/app-detail.tsx`): + +- `bg_agent_created` — `{ method: 'manual' | 'coding' | 'copilot', has_triggers }` — `copilot` means the user submitted the "describe it" form (the agent is then created by Copilot in chat) +- `bg_agent_updated` — instructions/triggers/model saved on an existing agent +- `bg_agent_toggled` — `{ active }` +- `bg_agent_run_clicked` — manual Run now +- `bg_agent_stopped` — manual stop of a run +- `bg_agent_deleted` + +**Live notes** (`components/live-note-sidebar.tsx`, `components/live-notes-view.tsx`): + +- `live_note_saved` — live config created or edited via the panel +- `live_note_toggled` — `{ active }` +- `live_note_run_clicked` — manual Run +- `live_note_stopped` — in-flight run stopped +- `live_note_deleted` — live config removed from the note +- `live_note_edit_with_copilot_clicked` + +**Search** (`components/search-dialog.tsx`): + +- `search_opened` — the palette opened +- `search_executed` — (pre-existing, above) +- `search_result_selected` — `{ type: 'knowledge' | 'chat' }` + +**Apps** — all **(main)**, in `apps/main/src/ipc.ts` (pre-existing except `app_rolled_back`): `app_created`, `app_installed`, `app_uninstalled`, `app_updated`, `app_rolled_back`, `app_published`, `app_starred`, `app_deleted`. Plus renderer-side `app_opened` — `{ folder }` — an installed app's UI was opened (`components/apps/app-frame.tsx`). + +**Code mode** — both **(main/core)**: + +- `code_session_created` — `{ mode: 'direct' | 'rowboat', agent }` — captured in the `codeSession:create` IPC handler. This is the direct-vs-rowboat session split. +- `code_session_message_sent` — `{ mode, agent }` — one per direct-drive message (`packages/core/src/code-mode/sessions/service.ts`). Direct turns bypass the agent runtime and emit no `llm_usage`, so this is the only usage-depth signal for direct mode; Rowboat-mode depth comes from `llm_usage where use_case = code_session`. + +**Billing** (`components/billing-error-dialog.tsx`): + +- `billing_error_shown` — `{ kind: 'subscription_required' | 'out_of_credits' | 'subscription_inactive' }` — the paywall dialog appeared +- `billing_upgrade_clicked` — `{ kind }` — the upgrade CTA was clicked (shown → clicked = paywall conversion) + +**Failures** — success events all have a failure sibling where the operation can fail after the click: + +- `email_send_failed` — send returned an error or threw (`components/email-view.tsx`) +- `meeting_summarize_failed` — post-recording notes generation threw (`App.tsx`) +- `bg_agent_run_failed` / `bg_agent_run_completed` — `{ trigger: 'manual' | 'cron' | 'window' | 'event' }` **(core)** — every background-agent run settles as exactly one of these (`packages/core/src/background-tasks/runner.ts`), giving a failure *rate* across all trigger sources, not just manual clicks + +**Misc**: + +- `note_created` — new note from the sidebar/knowledge actions (`App.tsx`) +- `note_edited` — a note's autosave wrote changed content; deduped to one event per note per app session (so it counts "notes touched", not keystroke bursts) +- `settings_opened` — `{ tab }` — settings dialog opened (tab = the initial tab) +- `settings_tab_changed` — `{ tab }` +- `onboarding_completed` — the onboarding flow finished (`App.tsx`) + ## Person properties Persistent across sessions for the same user. Set via `posthog.people.set` or as the `properties` arg to `identify`. @@ -110,6 +197,8 @@ Persistent across sessions for the same user. Set via `posthog.people.set` or as | `{provider}_connected` | renderer | One of `gmail`, `calendar`, `slack`, `rowboat` | | `total_notes` | renderer (init) | Workspace size signal | | `has_used_search`, `has_used_voice` | renderer | One-shot first-use flags | +| `has_used_email`, `has_used_meetings`, `has_used_live_notes`, `has_used_bg_agents`, `has_used_apps`, `has_used_code` | renderer (`view_opened`) | One-shot first-use flags per feature view | +| `has_created_bg_agent` | renderer | One-shot: user set up a background agent | ## How to add a new event diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index da0e4508..99bf361c 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -908,6 +908,8 @@ export function setupIpcHandlers() { return { payload: getMeetingPopupPayload() }; }, 'meetingDetect:action': async (_event, args) => { + // Captured here because the popup window runs without PostHog. + capture('meeting_popup_action', { action: args.action }); handleMeetingPopupAction(args.action); return {}; }, @@ -1333,6 +1335,7 @@ export function setupIpcHandlers() { 'codeSession:create': async (_event, args) => { const service = container.resolve('codeSessionService'); const session = await service.create(args); + capture('code_session_created', { mode: session.mode, agent: session.agent }); return { session }; }, 'codeSession:list': async () => { @@ -1845,7 +1848,9 @@ export function setupIpcHandlers() { return { app }; }, 'apps:rollback': async (_event, args) => { - return { app: await appsInstaller.rollbackApp(args.folder) }; + const app = await appsInstaller.rollbackApp(args.folder); + capture('app_rolled_back', { folder: args.folder }); + return { app }; }, 'apps:publish': async (event, args) => { const win = BrowserWindow.fromWebContents(event.sender); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 99791ba9..3ee42e8c 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -1217,6 +1217,8 @@ function App() { // the call button's main click) is "work together": screen shared, camera // off, floating pill — the user keeps working while the assistant watches // along. 'video'/'practice' open face-to-face full screen instead. + const callStartedAtMsRef = useRef(null) + const startCall = useCallback(async (preset: CallPreset) => { if (inCallRef.current) return const camera = preset === 'video' || preset === 'practice' @@ -1261,11 +1263,15 @@ function App() { setCallMinimized(preset === 'voice' || preset === 'share') inCallRef.current = true setInCall(true) + callStartedAtMsRef.current = performance.now() analytics.callStarted(preset) }, [video]) const endCall = useCallback(() => { if (!inCallRef.current) return + const startedAt = callStartedAtMsRef.current + callStartedAtMsRef.current = null + analytics.callEnded(startedAt != null ? (performance.now() - startedAt) / 1000 : 0) voiceRef.current.cancel() ttsEnabledRef.current = false ttsModeRef.current = 'summary' @@ -2270,6 +2276,7 @@ function App() { opts: { encoding: 'utf8' } }) markRecentLocalMarkdownWrite(pathToSave) + analytics.noteEdited(pathToSave) // Store body-only baseline (matches what debouncedContent compares against) initialContentByPathRef.current.set(pathToSave, splitFrontmatter(contentToSave).body) @@ -4082,6 +4089,12 @@ function App() { return { type: 'chat', runId } }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isAppsOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, knowledgeViewMode, isChatHistoryOpen, isHomeOpen, isCodeOpen, workspaceInitialPath, runId]) + // Feature-importance funnel: one event per view the user lands on. Keyed on + // the view *type* so switching files/threads inside a view doesn't re-fire. + useEffect(() => { + analytics.viewOpened(currentViewState.type) + }, [currentViewState.type]) + const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { const last = stack[stack.length - 1] if (last && viewStatesEqual(last, entry)) return stack @@ -5321,6 +5334,7 @@ function App() { } catch (err) { console.error('Failed to mark onboarding complete:', err) } + analytics.onboardingCompleted() setShowOnboarding(false) if (opts?.startTour) { window.setTimeout(() => setTourActive(true), 400) @@ -5345,6 +5359,7 @@ function App() { data: `# ${name}\n\n`, opts: { encoding: 'utf8' } }) + analytics.noteCreated() setExpandedPaths(prev => new Set([...prev, parentPath])) navigateToFile(fullPath) } catch (err) { @@ -5600,6 +5615,7 @@ function App() { }, [loadDirectory, navigateToFile, fileTabs]) const meetingNotePathRef = useRef(null) + const meetingRecordingStartedAtMsRef = useRef(null) const pendingCalendarEventRef = useRef(undefined) const [meetingSummarizing, setMeetingSummarizing] = useState(false) const [showMeetingPermissions, setShowMeetingPermissions] = useState(false) @@ -5613,6 +5629,8 @@ function App() { setRecordingMeetingSource(calEvent?.source ?? null) const notePath = await meetingTranscription.start(calEvent) if (notePath) { + meetingRecordingStartedAtMsRef.current = performance.now() + analytics.meetingRecordingStarted(Boolean(calEvent)) meetingNotePathRef.current = notePath await handleVoiceNoteCreated(notePath) } @@ -5638,6 +5656,9 @@ function App() { const handleToggleMeeting = useCallback(async () => { if (meetingTranscription.state === 'recording') { await meetingTranscription.stop() + const recordingStartedAt = meetingRecordingStartedAtMsRef.current + meetingRecordingStartedAtMsRef.current = null + analytics.meetingRecordingStopped(recordingStartedAt != null ? (performance.now() - recordingStartedAt) / 1000 : 0) setRecordingMeetingSource(null) // Read the final transcript and generate meeting notes via LLM @@ -5685,6 +5706,7 @@ function App() { } } } catch (err) { + analytics.meetingSummarizeFailed() console.error('[meeting] Failed to generate meeting notes:', err) } setMeetingSummarizing(false) diff --git a/apps/x/apps/renderer/src/components/apps/app-detail.tsx b/apps/x/apps/renderer/src/components/apps/app-detail.tsx index 5304c81d..a3755c13 100644 --- a/apps/x/apps/renderer/src/components/apps/app-detail.tsx +++ b/apps/x/apps/renderer/src/components/apps/app-detail.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { X, RotateCcw, Play, UploadCloud, ArrowUpCircle, Trash2 } from 'lucide-react' import type { rowboatApp } from '@x/shared' import { PublishDialog } from '@/components/apps/publish-dialog' +import * as analytics from '@/lib/analytics' // App detail panel (spec §14): manifest info, provenance/publish state, // bundled agents with enable toggles, rollback when available. Update/publish @@ -126,12 +127,14 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () => setAgents((prev) => prev.map((a) => (a.slug === slug ? { ...a, active } : a))) try { await window.ipc.invoke('bg-task:patch', { slug, partial: { active } }) + analytics.bgAgentToggled(active) } catch { setReloadNonce((n) => n + 1) // revert to truth on failure } } const runAgent = async (slug: string) => { + analytics.bgAgentRunClicked() try { await window.ipc.invoke('bg-task:run', { slug }) } catch { /* surfaced via bg-task UI */ } diff --git a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx index a143b216..8080d503 100644 --- a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx +++ b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx @@ -14,6 +14,7 @@ import { Textarea } from '@/components/ui/textarea' import { useBackgroundTaskAgentStatus } from '@/hooks/use-bg-task-agent-status' import { formatRelativeTime } from '@/lib/relative-time' import { toast } from '@/lib/toast' +import * as analytics from '@/lib/analytics' import type { ConversationItem } from '@/lib/chat-conversation' import { fetchAgentRunTranscript } from '@/lib/agent-transcript' import { useAgentRunTranscript } from '@/hooks/use-agent-run-transcript' @@ -364,6 +365,7 @@ function NewTaskDialog({ ...(projectId ? { projectId } : {}), }) if (result.success && result.slug) { + analytics.bgAgentCreated({ method: 'coding', hasTriggers: Boolean(triggers) }) onCreated(result.slug) } else { toast(result.error ?? 'Failed to create task', 'error') @@ -377,6 +379,7 @@ function NewTaskDialog({ const submitDescribe = () => { if (!canSubmitDescribe || !onCreateWithCopilot) return + analytics.bgAgentCreated({ method: 'copilot', hasTriggers: false }) onCreateWithCopilot(description.trim()) onClose() } @@ -391,6 +394,7 @@ function NewTaskDialog({ ...(triggers ? { triggers } : {}), }) if (result.success && result.slug) { + analytics.bgAgentCreated({ method: 'manual', hasTriggers: Boolean(triggers) }) onCreated(result.slug) } else { toast(result.error ?? 'Failed to create task', 'error') @@ -1467,6 +1471,7 @@ function TaskDetail({ if (draft.provider !== task.provider) partial.provider = draft.provider const result = await window.ipc.invoke('bg-task:patch', { slug, partial }) if (result.success && result.task) { + analytics.bgAgentUpdated() setTask(result.task) setDraft(result.task) setEditingInstructions(false) @@ -1488,12 +1493,14 @@ function TaskDetail({ if (!task) return const result = await window.ipc.invoke('bg-task:patch', { slug, partial: { active } }) if (result.success && result.task) { + analytics.bgAgentToggled(active) setTask(result.task) setDraft(result.task) } } const runNow = async () => { + analytics.bgAgentRunClicked() const result = await window.ipc.invoke('bg-task:run', { slug }) if (!result.success) { toast(result.error ?? 'Run failed', 'error') @@ -1501,12 +1508,14 @@ function TaskDetail({ } const stopRun = async () => { + analytics.bgAgentStopped() await window.ipc.invoke('bg-task:stop', { slug }) } const deleteTask = async () => { const result = await window.ipc.invoke('bg-task:delete', { slug }) if (result.success) { + analytics.bgAgentDeleted() onDeleted() } else { toast(result.error ?? 'Delete failed', 'error') @@ -1665,6 +1674,7 @@ export function BgTasksView({ onCreateWithCopilot, onEditWithCopilot, initialSlu toast(result.error ?? 'Failed to update task', 'error') return } + analytics.bgAgentToggled(active) // Optimistically reflect the new state without re-fetching the whole list. setItems(prev => prev.map(t => t.slug === slug ? { ...t, active } : t)) } catch (err) { diff --git a/apps/x/apps/renderer/src/components/billing-error-dialog.tsx b/apps/x/apps/renderer/src/components/billing-error-dialog.tsx index e93b2661..e0bf2521 100644 --- a/apps/x/apps/renderer/src/components/billing-error-dialog.tsx +++ b/apps/x/apps/renderer/src/components/billing-error-dialog.tsx @@ -9,6 +9,7 @@ import { } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import type { BillingErrorMatch } from "@/lib/billing-error" +import * as analytics from "@/lib/analytics" interface BillingRowboatAccount { config?: { @@ -33,9 +34,14 @@ export function BillingErrorDialog({ open, match, onOpenChange }: BillingErrorDi .catch(() => {}) }, [open]) + useEffect(() => { + if (open && match) analytics.billingErrorShown(match.kind) + }, [open, match]) + if (!match) return null const handleUpgrade = () => { + analytics.billingUpgradeClicked(match.kind) if (appUrl) window.open(`${appUrl}?intent=upgrade`) onOpenChange(false) } diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index 8301ac55..66ade9cd 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -7,6 +7,7 @@ import Placeholder from '@tiptap/extension-placeholder' import type { blocks } from '@x/shared' import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' +import * as analytics from '@/lib/analytics' import { prepareEmailHtml, splitPlainTextQuote, stripQuotedReplyText, QUOTED_CLASS } from '@/lib/email-quotes' import { useTheme } from '@/contexts/theme-context' import { SettingsDialog } from '@/components/settings-dialog' @@ -1167,6 +1168,13 @@ const ComposeBox = memo(function ComposeBox({ // always calls the latest send closure; assigned below sendInGmail. const sendRef = useRef<() => void>(() => {}) + // True once Write-with-AI produced a draft in this composer; sent with email_sent. + const aiUsedRef = useRef(false) + + useEffect(() => { + analytics.emailComposeOpened(mode) + }, [mode]) + const editor = useEditor({ extensions: [ StarterKit.configure({ link: false }), @@ -1333,6 +1341,8 @@ const ComposeBox = memo(function ComposeBox({ } // Replace via a tracked transaction (selectAll + insertContent) so the AI // draft lands in the editor's undo history and the toolbar's Undo reverts it. + aiUsedRef.current = true + analytics.emailAiDraftGenerated(aiMode) if (aiMode === 'generate') { const { subject: generatedSubject, body } = parseGeneratedEmail(res.text) // Only new emails take the AI's subject; replies/forwards keep their @@ -1625,9 +1635,11 @@ const ComposeBox = memo(function ComposeBox({ }) if (result.error) { sentRef.current = false // allow autosave to resume on a failed send + analytics.emailSendFailed() toast(`Send failed: ${result.error}`, 'error') return } + analytics.emailSent({ mode, hasAttachments: attachments.length > 0, aiAssisted: aiUsedRef.current }) // Gmail only auto-cleans drafts on a threaded send; remove any draft we // autosaved for a brand-new message so it doesn't linger after sending. const leftover = draftIdRef.current @@ -1640,6 +1652,7 @@ const ComposeBox = memo(function ComposeBox({ onClose() } catch (err) { sentRef.current = false + analytics.emailSendFailed() toast(`Send failed: ${err instanceof Error ? err.message : String(err)}`, 'error') } finally { setSending(false) @@ -2356,6 +2369,7 @@ function EmailInstructionsDialog({ open, onOpenChange, onSaved }: { open: boolea try { const result = await window.ipc.invoke('gmail:setEmailInstructions', { instructions: text }) if (result.ok) { + analytics.emailInstructionsSaved() toast('Instructions saved — they apply to every email from now on.', 'success') onOpenChange(false) onSaved?.() @@ -2657,6 +2671,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery const handle = window.setTimeout(async () => { try { const res = await window.ipc.invoke('gmail:search', { query: q, limit: 100 }) + analytics.emailSearched() if (searchEpoch.current !== epoch) return setSearchError(res.error ?? null) setSearchResults(res.threads ?? []) @@ -2755,6 +2770,9 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery })) try { const result = await window.ipc.invoke('gmail:markThreadRead', { threadId, read }) + // Marking read happens automatically on open; only the explicit + // mark-as-unread gesture is worth an event. + if (!read) analytics.emailMarkedUnread() if (!result.ok && result.error) console.warn('[Gmail] mark-read failed:', result.error) } catch (err) { console.warn('[Gmail] mark-read failed:', err) @@ -2771,6 +2789,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery new Promise((resolve) => window.setTimeout(resolve, ROW_LEAVE_MS)), ]) if (result.ok) { + analytics.emailArchived() removeThreadFromState(threadId) } else if (result.error) { toast(`Archive failed: ${result.error}`, 'error') @@ -2794,6 +2813,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery new Promise((resolve) => window.setTimeout(resolve, ROW_LEAVE_MS)), ]) if (result.ok) { + analytics.emailImportanceChanged(importance) const from = importance === 'important' ? 'other' as const : 'important' as const setSection(from, (prev) => ({ ...prev, threads: prev.threads.filter((t) => t.threadId !== threadId) })) if (source) { @@ -2819,6 +2839,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery try { const result = await window.ipc.invoke('gmail:setCategory', { threadId, category }) if (result.ok) { + analytics.emailCategoryChanged(category) updateThreadInState(threadId, (t) => ({ ...t, category })) setCategoryCounts((prev) => { const next = { ...prev } @@ -2848,6 +2869,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery new Promise((resolve) => window.setTimeout(resolve, ROW_LEAVE_MS)), ]) if (result.ok) { + analytics.emailTrashed() removeThreadFromState(threadId) } else if (result.error) { toast(`Delete failed: ${result.error}`, 'error') @@ -2864,6 +2886,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery setSelectedThreadId((current) => { const next = current === thread.threadId ? null : thread.threadId if (next) { + analytics.emailThreadOpened() setOpenedThreadIds((prev) => { const without = prev.filter((id) => id !== next) return [...without, next].slice(-MAX_KEPT_OPEN) @@ -3004,6 +3027,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery if (result.error) { toast(`Bulk archive failed: ${result.error}`, 'error') } else { + analytics.emailCategoryArchived(category) toast( `Archived ${result.archived} thread${result.archived === 1 ? '' : 's'}${result.failed ? ` (${result.failed} failed)` : ''}. They stay searchable in Gmail.`, result.failed ? 'error' : 'success', @@ -3113,6 +3137,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery setRefreshing(true) setError(null) try { + analytics.emailSyncTriggered() await window.ipc.invoke('gmail:triggerSync', {}) } catch (err) { console.warn('[Gmail] triggerSync failed:', err) diff --git a/apps/x/apps/renderer/src/components/live-note-sidebar.tsx b/apps/x/apps/renderer/src/components/live-note-sidebar.tsx index f863e6cb..f3034827 100644 --- a/apps/x/apps/renderer/src/components/live-note-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/live-note-sidebar.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Streamdown } from 'streamdown' import '@/styles/live-note-panel.css' +import * as analytics from '@/lib/analytics' import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' @@ -166,6 +167,7 @@ export function LiveNoteSidebar({ filePath, onClose }: LiveNoteSidebarProps) { setError(res.error ?? 'Save failed') return } + analytics.liveNoteSaved() setLive(res.live ?? null) setDraft(res.live ? structuredClone(res.live) as LiveNote : null) setEditingObjective(false) @@ -195,6 +197,7 @@ export function LiveNoteSidebar({ filePath, onClose }: LiveNoteSidebarProps) { setError(res.error ?? 'Failed') return } + analytics.liveNoteToggled(live.active === false) setLive(res.live ?? null) setDraft(res.live ? structuredClone(res.live) as LiveNote : null) } catch (err) { @@ -207,6 +210,7 @@ export function LiveNoteSidebar({ filePath, onClose }: LiveNoteSidebarProps) { const handleRun = useCallback(async () => { if (!knowledgeRelPath) return setError(null) + analytics.liveNoteRunClicked() try { await window.ipc.invoke('live-note:run', { filePath: knowledgeRelPath }) } catch (err) { @@ -219,6 +223,7 @@ export function LiveNoteSidebar({ filePath, onClose }: LiveNoteSidebarProps) { setError(null) try { const res = await window.ipc.invoke('live-note:stop', { filePath: knowledgeRelPath }) + if (res.success) analytics.liveNoteStopped() if (!res.success && res.error) setError(res.error) } catch (err) { setError(err instanceof Error ? err.message : String(err)) @@ -235,6 +240,7 @@ export function LiveNoteSidebar({ filePath, onClose }: LiveNoteSidebarProps) { setError(res.error ?? 'Delete failed') return } + analytics.liveNoteDeleted() setLive(null) setDraft(null) setConfirmingDelete(false) @@ -247,6 +253,7 @@ export function LiveNoteSidebar({ filePath, onClose }: LiveNoteSidebarProps) { const handleEditWithCopilot = useCallback(() => { if (!filePath) return + analytics.liveNoteEditWithCopilotClicked() window.dispatchEvent(new CustomEvent('rowboat:open-copilot-edit-live-note', { detail: { filePath }, })) diff --git a/apps/x/apps/renderer/src/components/live-notes-view.tsx b/apps/x/apps/renderer/src/components/live-notes-view.tsx index c1fc491f..a364bc3b 100644 --- a/apps/x/apps/renderer/src/components/live-notes-view.tsx +++ b/apps/x/apps/renderer/src/components/live-notes-view.tsx @@ -6,6 +6,7 @@ import { Switch } from '@/components/ui/switch' import { stripKnowledgePrefix, wikiLabel } from '@/lib/wiki-links' import { toast } from '@/lib/toast' import { formatRelativeTime } from '@/lib/relative-time' +import * as analytics from '@/lib/analytics' import { useLiveNoteAgentStatus } from '@/hooks/use-live-note-agent-status' type LiveNoteRow = { @@ -145,6 +146,7 @@ export function LiveNotesView({ onOpenNote, onAddNewLiveNote }: LiveNotesViewPro throw new Error(result.error ?? 'Failed to update live-note state') } + analytics.liveNoteToggled(active) setNotes((prev) => prev.map((entry) => ( entry.path === note.path ? { @@ -173,6 +175,7 @@ export function LiveNotesView({ onOpenNote, onAddNewLiveNote }: LiveNotesViewPro try { const knowledgeRelative = note.path.replace(/^knowledge\//, '') const result = await window.ipc.invoke('live-note:stop', { filePath: knowledgeRelative }) + if (result.success) analytics.liveNoteStopped() if (!result.success && result.error) { toast(result.error, 'error') } diff --git a/apps/x/apps/renderer/src/components/meetings-view.tsx b/apps/x/apps/renderer/src/components/meetings-view.tsx index 96e94523..6da16296 100644 --- a/apps/x/apps/renderer/src/components/meetings-view.tsx +++ b/apps/x/apps/renderer/src/components/meetings-view.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { SettingsDialog } from '@/components/settings-dialog' import { formatRelativeTime } from '@/lib/relative-time' +import * as analytics from '@/lib/analytics' import { extractConferenceLink } from '@/lib/calendar-event' import { cn } from '@/lib/utils' import type { MeetingTranscriptionState } from '@/hooks/useMeetingTranscription' @@ -1310,7 +1311,7 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee