* feat(x): client auto-update with non-interrupting UX
Auto-update via update.electronjs.org (Squirrel), replacing the native
update dialog with a state machine pushed to the renderer:
- Non-modal "restart to update" toast, deferred while a call/turn is
active and retracted if one starts; 24h "Later" snooze persisted in
main so it survives window reloads
- Offline detection: network errors get a soft `offline` state instead
of a red failure, and don't emit update_failed analytics
- Update-waiting badges: macOS dock badge, Windows taskbar overlay icon
- macOS move-to-Applications prompt parented to the main window, with a
failure fallback dialog and focus re-check after a manual drag
- Settings > Help: version, manual check with accurate transient
"You're up to date" feedback, per-state messaging
- "Updated to vX" card on first launch after an upgrade (downgrades
restamp silently); "What's new" links to release notes
- Toaster: follow app dark mode (fixes unreadable description text) and
restyle to theme tokens
- Window state persistence so restart-to-update feels lossless
- Tests for version stamping and upgrade comparison
* feat(x): replace update toast with Zed-style titlebar chip
The restart-to-update prompt moves from a sonner toast to a persistent,
non-interrupting titlebar indicator: a spinner while an update downloads,
then a 'Restart to update' chip once staged. Clicking restarts into the
new version; the x snoozes the chip for 24h via the existing persisted
snooze. Busy-deferral is dropped - the chip never interrupts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(x): bottom-left update card with inline release notes
Reframe the restart prompt per PR feedback: by the time the user sees it,
Squirrel has already installed the update — the prompt only asks for a
restart (Chrome-style), and being loud about what shipped matters more
than being unobtrusive during early adoption.
- Replace the titlebar chip with a bottom-left "Update available" card
showing the new version, an inline "What's new" section rendered from
the GitHub release notes, and Later / Release notes / Restart now
- Release notes come from Squirrel.Mac's update feed (update.electronjs.org
passes the release body through); Squirrel.Windows only reports the
release name, so missing notes are backfilled from the GitHub API
- Drop the 24h snooze machinery — Later/× just dismiss for the session
- Drop offline detection (soft `offline` state) — separate PR later
- Drop the macOS move-to-Applications prompt/move button — separate PR
later; Settings still explains why updates are unavailable outside
/Applications
- Drop window state persistence
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(x): aggregate release notes across skipped versions, persistent up-to-date status
- Updater aggregates release bodies for every version between the running
app and the update target, with a commit-log fallback when no release in
range has notes
- CI fills empty release bodies with GitHub's auto-generated notes
- Settings shows a persistent 'You're up to date' line with last-checked
time instead of a transient confirmation
* fix(x): restore updater:quitAndInstall lines dropped in merge
The conflict resolution in 67d6a542 truncated the updater:quitAndInstall
entry in both the shared IPC schema and the main-process handler, leaving
an unclosed brace that broke the shared package build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(x): simplify release notes handling per review feedback
Release notes are a process concern, not the updater's: drop the
CI job that auto-filled empty release bodies and the in-app GitHub
API backfill (backfillReleaseNotes + commitLogFallback). The update
card now adapts instead — it renders the notes Squirrel supplies,
or a static "Bug fixes and improvements." line when empty.
Also document that updateElectronApp() configures the same
autoUpdater singleton our listeners observe (no race), and that
gen-install-loading.sh is a manual one-off tool whose committed
GIF feeds Squirrel's loadingGif.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(x): drop update-electron-app, drive autoUpdater directly
With notifyUser: false the package reduced to setFeedURL + an
immediate checkForUpdates() + a 10-minute unconditional timer, plus
guards we already have (isPackaged, platform, app-ready). Inline
those three lines instead and remove the dependency.
The interval now runs through the existing guarded checkForUpdates()
(no-op unless idle/error), so ticks no longer emit Squirrel.Mac
"check already in progress" errors or make Squirrel.Windows
re-download an already-staged update.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
20 KiB
Analytics
PostHog instrumentation for
apps/x. We capture LLM token usage (broken down by feature) and identity/auth events. Renderer (posthog-js) and main (posthog-node) share one stable distinct_id and one identified user, so events from either process resolve to the same person.
Identity model
- Anonymous distinct_id =
installationIdfrom~/.rowboat/config/installation.json(auto-generated on first run; seepackages/core/src/analytics/installation.ts). - Renderer fetches it from main on startup via the
analytics:bootstrapIPC channel and passes it as PostHog'sbootstrap.distinctID. Main uses it directly inposthog-node. - On rowboat sign-in:
posthog.identify(rowboatUserId)runs in both processes.- Main does it from
apps/main/src/oauth-handler.ts:285(aftergetBillingInfo()resolves) — this is the load-bearing call, since main always runs. - Renderer mirrors via
apps/renderer/src/hooks/useAnalyticsIdentity.tslistening on theoauth:didConnectIPC event. - Main also calls
alias()so events emitted under the anonymous installation_id are linked to the identified user retroactively.
- Main does it from
- On every app startup: main re-identifies if rowboat tokens exist (
packages/core/src/analytics/identify.ts, called fromapps/main/src/main.tswhenReady). Idempotent — PostHog merges person properties on duplicate identifies. This catches users who installed before analytics existed, and refreshes person properties (plan/status) on every launch. - On rowboat sign-out:
posthog.reset()in both processes; future events resolve to the installation_id again. emailis set onidentifyfrom main only (sourced from/v1/me). Person properties are server-side, so the renderer's events resolve to the same record without redundantly setting it.
Event catalog
All PostHog events include app_version and platform: 'desktop' automatically. Main-process events add them in packages/core/src/analytics/posthog.ts; renderer events get them from the analytics:bootstrap IPC payload via posthog.register (plus an initialization-time before_send hook for app_version). platform guards against the legacy web dashboard's autocapture (apps/rowboat, unidentified by design) muddying desktop dashboards if it ever shares the project.
llm_usage
Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run).
| Property | Type | Notes |
|---|---|---|
use_case |
enum | copilot_chat / live_note_agent / meeting_note / knowledge_sync / code_session |
sub_use_case |
string? | Refines use_case — see taxonomy table below |
agent_name |
string? | Present when the call goes through an agent run (createRun); omitted for direct generateText/generateObject |
model |
string | e.g. claude-sonnet-4-6 |
provider |
string | rowboat = cloud LLM gateway; otherwise the BYOK provider (openai, anthropic, ollama, etc.) |
input_tokens |
number | |
output_tokens |
number | |
total_tokens |
number | |
cached_input_tokens |
number? | When the provider reports it |
reasoning_tokens |
number? | When the provider reports it |
Use-case taxonomy
Every llm_usage emit point in the codebase:
use_case |
sub_use_case |
agent_name? |
Where | File:line |
|---|---|---|---|---|
copilot_chat |
(none) | yes | User chat in renderer (turn runtime; the ALS default when no caller set a use case) | packages/core/src/runtime/turns/bridges/real-usage-reporter.ts (reportModelUsage); legacy runs (code-mode carve-out) still emit from packages/core/src/runtime/legacy/engine.ts (streamLlm finish-step) |
copilot_chat |
scheduled |
yes | Background scheduled agent runner | packages/core/src/agent-schedule/runner.ts:167 |
copilot_chat |
file_parse |
inherits | parseFile builtin tool inside any chat |
packages/core/src/runtime/tools/domains/parsing.ts:179 |
live_note_agent |
routing |
no | Pass 1 routing classifier (generateObject) |
packages/core/src/knowledge/live-note/routing.ts:93 |
live_note_agent |
manual |
yes | Pass 2 agent run — user clicked Run / called the run-live-note-agent tool |
packages/core/src/knowledge/live-note/runner.ts:140 (createRun, subUseCase: trigger) |
live_note_agent |
cron |
yes | Pass 2 agent run — cron expression matched | same call site |
live_note_agent |
window |
yes | Pass 2 agent run — fired inside a configured time-of-day window | same call site |
live_note_agent |
event |
yes | Pass 2 agent run — Pass 1 routing flagged the note for an incoming event | same call site |
meeting_note |
(none) | no | Meeting transcript summarizer (generateText) |
packages/core/src/knowledge/summarize_meeting.ts:161 |
knowledge_sync |
agent_notes |
yes | Agent notes learning service | packages/core/src/knowledge/agent_notes.ts:309 (createRun) |
knowledge_sync |
tag_notes |
yes | Note tagging | packages/core/src/knowledge/tag_notes.ts:86 (createRun) |
knowledge_sync |
build_graph |
yes | Knowledge graph note creation | packages/core/src/knowledge/build_graph.ts:253 (createRun) |
knowledge_sync |
inline_task_run |
yes | Inline @rowboat task execution (two call sites) |
packages/core/src/knowledge/inline_tasks.ts:471, 552 (createRun) |
knowledge_sync |
inline_task_classify |
no | Inline task scheduling classifier (generateText) |
packages/core/src/knowledge/inline_tasks.ts:673 |
knowledge_sync |
pre_built |
yes | Pre-built scheduled agents | packages/core/src/pre_built/runner.ts:43 (createRun) |
code_session |
(none) | yes | Code-section coding session in Rowboat mode (direct mode talks to the on-device coding agent and emits no llm_usage) |
packages/core/src/code-mode/sessions/service.ts (createRun) |
live_note_agent sub-use-case shape
For the live-note feature specifically, sub_use_case discriminates what kind of work happened:
routing— Pass 1 LLM classifier deciding which live notes might be relevant to an incoming event. One emit per Pass 1 batch.manual/cron/window/event— Pass 2 agent run, tagged with the trigger that woke it up. The runner reads itstriggerargument (LiveNoteTriggerType) and passes it directly assubUseCase, so dashboards can break runs down by trigger source.
This means a single end-to-end event flow emits both routing (Pass 1) and event (Pass 2). A scheduled cron fire emits only cron. A user clicking Run emits only manual. There is no separate "run" sub-use-case anymore — the trigger IS the sub-use-case for Pass 2.
testModelConnection in packages/core/src/models/models.ts is not instrumented (diagnostic only — would skew per-model counts).
user_signed_in
Emitted when rowboat OAuth completes. Properties: plan, status (subscription state from /v1/me).
Emitted from both processes:
- Main (
apps/main/src/oauth-handler.ts:290) — always fires; load-bearing. - Renderer (
apps/renderer/src/hooks/useAnalyticsIdentity.ts:75) — fires only when the renderer is open. Same distinct_id, so dedup is automatic in PostHog dashboards.
user_signed_out
Emitted on rowboat disconnect. No properties. Followed immediately by posthog.reset().
Emit points: apps/main/src/oauth-handler.ts:369 and apps/renderer/src/hooks/useAnalyticsIdentity.ts:82.
Other events (pre-existing, not added by the LLM-usage work)
All in apps/renderer/src/lib/analytics.ts:
chat_session_created—{ run_id }chat_message_sent—{ voice_input, voice_output, search_enabled }oauth_connected/oauth_disconnected—{ provider }voice_input_started— no propertiescall_started—{ preset: 'voice' | 'video' | 'share' | 'practice' }— a hands-free call began (seeapps/x/VIDEO_MODE.md)call_turn_latency—{ endpoint_to_submit_ms, submit_to_speak_ms, speak_to_audio_ms, total_ms }— voice-to-voice latency breakdown for one call turn (utterance accepted → submitted → first TTS speak → audio playing)search_executed—{ types: string[] }note_exported—{ format }
Client auto-update funnel
The desktop client's own updates — distinct from the in-app apps feature, which owns app_updated:
update_prompted— renderer (apps/renderer/src/lib/analytics.ts): the "Update available" card was shown for a staged updateupdate_restarted— main (apps/main/src/updater.ts),{ from, to? }: the user clicked restart-to-update (tomay be missing when the update feed doesn't report the release name)update_failed— main (apps/main/src/updater.ts),{ message }: the auto-updater errored (includes network errors for now)client_updated— main (apps/main/src/ipc.ts),{ from, to }: first launch on a newer version (fires once per update, whatever the restart path; downgrades restamp silently and don't fire)
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 listemail_compose_opened—{ mode: 'new' | 'reply' | 'replyAll' | 'forward' | 'draft' }— a composer was openedemail_sent—{ mode, has_attachments, ai_assisted }—ai_assistedis true when Write-with-AI produced a draft in that composeremail_ai_draft_generated—{ mode: 'generate' | 'rewrite' }— the Write/Edit-with-AI bar completedemail_archived/email_trashed— one thread archived / moved to trashemail_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 verdictemail_category_changed—{ category }— user re-filed a threademail_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 savedemail_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 handlermeeting_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 callcall_ended—{ duration_seconds }
Background agents (components/bg-tasks-view.tsx, components/apps/app-detail.tsx):
bg_agent_created—{ method: 'manual' | 'coding' | 'copilot', has_triggers }—copilotmeans 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 agentbg_agent_toggled—{ active }bg_agent_run_clicked— manual Run nowbg_agent_stopped— manual stop of a runbg_agent_deleted
Live notes (components/live-note-sidebar.tsx, components/live-notes-view.tsx):
live_note_saved— live config created or edited via the panellive_note_toggled—{ active }live_note_run_clicked— manual Runlive_note_stopped— in-flight run stoppedlive_note_deleted— live config removed from the notelive_note_edit_with_copilot_clicked
Search (components/search-dialog.tsx):
search_opened— the palette openedsearch_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 thecodeSession:createIPC 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 nollm_usage, so this is the only usage-depth signal for direct mode; Rowboat-mode depth comes fromllm_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 appearedbilling_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.
| Property | Set by | Notes |
|---|---|---|
email |
main on identify | From /v1/me; powers PostHog cohort match + integrations |
plan, status |
main on identify | Subscription state |
api_url |
both processes (init + identify) | Distinguishes prod / staging / custom — assign meaning in PostHog dashboard. https://api.x.rowboatlabs.com = production |
platform |
both processes (init + identify) | Always desktop from this app; segments desktop users from any other surface |
app_version |
both processes (init + identify) | Electron app version; also included automatically on every event |
signed_in |
renderer | true while rowboat OAuth is connected |
{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
- Naming:
snake_case,[object]_[verb]shape (e.g.note_exported, notexportedNote). Matches PostHog convention. - Pick the right helper:
- LLM token usage →
captureLlmUsage()from@x/core/dist/analytics/usage.js. Always includeuseCase; addsubUseCaseif it refines an existing top-level case. - Anything else from main →
capture()from@x/core/dist/analytics/posthog.js. - Anything else from renderer → add a typed wrapper to
apps/renderer/src/lib/analytics.tsand call it from the UI code (don't callposthog.capture()directly from components).
- LLM token usage →
- If it's a new LLM call site:
- Goes through
createRun? PassuseCase(and optionallysubUseCase) to the create call. The runtime auto-emits at everyfinish-step— no further code needed. - Direct
generateText/generateObject? CallcaptureLlmUsageafter the call withmodel,provider,usagefrom the result. - Inside a builtin tool? Call
getCurrentUseCase()fromanalytics/use_case.tsfirst — the parent run's tag is propagated viaAsyncLocalStorage. Usectx?.useCase ?? 'copilot_chat'as fallback.
- Goes through
- Update this file in the same PR. That's the contract — without it, dashboards and downstream consumers drift.
How to add a new use-case sub-case
- New
sub_use_caseunder an existing top-level case: just pick a string and add a row to the taxonomy table above. No code changes beyond the call site. - New top-level
use_case: edit theUseCaseenum inpackages/shared/src/runs.tsand the matchingUseCasetype inpackages/core/src/analytics/use_case.ts. Then update this doc.
Configuration
PostHog credentials live in two env vars (also baked into the binary at packaging time — never set at runtime in distributed builds):
VITE_PUBLIC_POSTHOG_KEY— project API key (e.g.phc_xxx). Public-facing — safe to commit if you'd rather hardcode.VITE_PUBLIC_POSTHOG_HOST— e.g.https://us.i.posthog.com. Defaults to US cloud if unset.
Where they're consumed:
- Renderer (Vite):
import.meta.env.VITE_PUBLIC_POSTHOG_*— inlined at build time. - Main (esbuild via
apps/main/bundle.mjs): inlined intomain.cjsat packaging time using esbuilddefine. In dev (npm run dev), main reads them fromprocess.envat runtime.
For GitHub Actions / packaged builds: set both as workflow env vars (from secrets) on the step that runs npm run package or npm run make. They'll be baked in.
If unset, analytics no-op silently — you'll see [Analytics] POSTHOG_KEY not set; analytics disabled in main-process logs.
installationId: stored in ~/.rowboat/config/installation.json, generated on first run.
File map
| File | Purpose |
|---|---|
packages/core/src/analytics/installation.ts |
Stable per-install distinct_id |
packages/core/src/analytics/posthog.ts |
Main-process client (capture, identify, reset, shutdown) |
packages/core/src/analytics/usage.ts |
captureLlmUsage() helper |
packages/core/src/analytics/use_case.ts |
AsyncLocalStorage for tool-internal LLM call inheritance |
apps/renderer/src/lib/analytics.ts |
Renderer event wrappers |
apps/renderer/src/hooks/useAnalyticsIdentity.ts |
Renderer identify/reset on OAuth events |
apps/main/src/oauth-handler.ts |
Main-side identify/reset/sign-in/sign-out events |
apps/main/src/main.ts |
before-quit hook flushes queued events |
packages/shared/src/ipc.ts |
analytics:bootstrap IPC channel definition |
apps/main/src/ipc.ts |
analytics:bootstrap handler + forwards userId on oauth:didConnect |
apps/main/bundle.mjs |
Bakes POSTHOG_KEY/POSTHOG_HOST into packaged main.cjs |