diff --git a/CHANGELOG.md b/CHANGELOG.md index 28006d1b0..261aee6c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project uses selective package publishing. Each release entry lists the pub ### Added +- Desktop telemetry's `app_connect` / `app_disconnect` user actions now carry which app was connected, as an `app` property drawn from a fixed local taxonomy (the packaged profile names plus the Telegram bot); user-added custom apps report as `custom`, so raw app names never leave the device. Connect events are also attributed to the specific app being connected rather than firing on every profile-set restart (profile switches and custom-app removals no longer emit spurious `app_connect`). - Desktop now finds T3 Code installed under any release channel — the launch-target lookup previously only checked for "T3 Code (Alpha)", so stable/Beta/Nightly installs got no app icon, no default "Open with" application, and no restart action. All channel variants are now probed (stable first), and the T3 Code rows fall back to the official t3.codes icon instead of the generic mark when the app isn't installed. - Desktop's Home screen keeps the "Use AntSeed on your favorite app" pills visible after connecting a tool — previously connecting anything hid the whole list. The pitch now disappears only once the user has chats, and an already-connected app's pill shows as connected (green dot, green-tinted border) and opens the Apps page instead of reconnecting. - Desktop's floating window now shows on traffic by default: when a connected app starts sending requests, the pill pops up on its own unless the "Show on traffic" preference is explicitly turned off (previously it defaulted to off). diff --git a/apps/desktop/src/main/ipc/app.ts b/apps/desktop/src/main/ipc/app.ts index b7845bc0f..a116b687d 100644 --- a/apps/desktop/src/main/ipc/app.ts +++ b/apps/desktop/src/main/ipc/app.ts @@ -39,6 +39,7 @@ import { } from 'node:fs/promises'; import { TELEMETRY_ACTION_SURFACES, + TELEMETRY_APP_NAMES, TELEMETRY_USER_ACTIONS, type TelemetryStatusUpdateResult, type UserActionSignal, @@ -97,7 +98,8 @@ export function registerAppIpc(): void { const candidate = payload as Partial | null; if (!candidate || !TELEMETRY_USER_ACTIONS.includes(candidate.action as never) - || !TELEMETRY_ACTION_SURFACES.includes(candidate.surface as never)) { + || !TELEMETRY_ACTION_SURFACES.includes(candidate.surface as never) + || (candidate.app !== undefined && !TELEMETRY_APP_NAMES.includes(candidate.app as never))) { return { ok: false }; } const telemetry = getTelemetryService(); @@ -105,6 +107,7 @@ export function registerAppIpc(): void { void telemetry.recordUserAction({ action: candidate.action as UserActionSignal['action'], surface: candidate.surface as UserActionSignal['surface'], + ...(candidate.app !== undefined ? { app: candidate.app as UserActionSignal['app'] } : {}), }); return { ok: true }; }); diff --git a/apps/desktop/src/main/telemetry/events.ts b/apps/desktop/src/main/telemetry/events.ts index e494d121c..bfee20d1e 100644 --- a/apps/desktop/src/main/telemetry/events.ts +++ b/apps/desktop/src/main/telemetry/events.ts @@ -15,7 +15,7 @@ * - Only coarse buckets for durations and amounts. */ -import type { TelemetryActionSurface, TelemetryUserAction } from '../../shared/telemetry.js'; +import type { TelemetryActionSurface, TelemetryAppName, TelemetryUserAction } from '../../shared/telemetry.js'; import { modelMetadataFor } from '../../shared/model-metadata.js'; export const TELEMETRY_SCHEMA_VERSION = 1; @@ -101,6 +101,8 @@ export type TelemetryEventProperties = { surface: TelemetryActionSurface; duration_bucket: DurationBucket; is_first_action: boolean; + /** app_connect / app_disconnect only: which app (fixed taxonomy). */ + app?: TelemetryAppName; }; /** Emitted when first-run setup completes. */ setup_completed: { @@ -200,7 +202,7 @@ export const TELEMETRY_EVENT_ALLOWLIST: { readonly [K in TelemetryEventName]: Re 'has_free_eligible_offer', 'eligible_offer_count_bucket', ]), - user_action: new Set(['action', 'surface', 'duration_bucket', 'is_first_action']), + user_action: new Set(['action', 'surface', 'duration_bucket', 'is_first_action', 'app']), setup_completed: new Set(['duration_bucket']), deposit_completed: new Set(['method_category', 'amount_bucket', 'is_first_deposit', 'days_since_first_open']), deposit_failed: new Set(['method_category', 'failure_code', 'failure_stage', 'retryable']), diff --git a/apps/desktop/src/main/telemetry/telemetry.test.ts b/apps/desktop/src/main/telemetry/telemetry.test.ts index 04920d2bb..ce66e4312 100644 --- a/apps/desktop/src/main/telemetry/telemetry.test.ts +++ b/apps/desktop/src/main/telemetry/telemetry.test.ts @@ -478,6 +478,31 @@ test('user actions identify the first meaningful action per launch', async (t) = assert.equal(actions[1]?.properties['duration_bucket'], '30s_2m'); }); +test('app connect actions carry the app name and are not coalesced across apps', async (t) => { + const dir = await makeTempDir(t); + const captured: Captured = { payloads: [] }; + const service = await createTelemetryService(baseOptions(dir, captured)); + await service.recordAppStarted(0); + await service.recordUserAction({ action: 'app_connect', surface: 'apps', app: 'claude-desktop' }, 1_000); + await service.recordUserAction({ action: 'app_connect', surface: 'apps', app: 'cursor' as never }, 2_000); + await service.recordUserAction({ action: 'app_disconnect', surface: 'apps', app: 'telegram' }, 3_000); + await service.recordUserAction({ action: 'chat_send', surface: 'chat' }, 4_000); + + let drainAttempts = 0; + while (captured.payloads.filter((payload) => payload.event === 'user_action').length < 4) { + await new Promise((resolve) => setImmediate(resolve)); + drainAttempts += 1; + assert.ok(drainAttempts < 100, 'user action queue did not drain'); + } + + const actions = captured.payloads.filter((payload) => payload.event === 'user_action'); + assert.equal(actions.length, 4); + assert.equal(actions[0]?.properties['app'], 'claude-desktop'); + assert.equal(actions[1]?.properties['app'], 'cursor'); + assert.equal(actions[2]?.properties['app'], 'telegram'); + assert.equal('app' in (actions[3]?.properties ?? {}), false); +}); + test('setup completion requires a started transition and emits once across restarts', async (t) => { const dir = await makeTempDir(t); const captured: Captured = { payloads: [] }; diff --git a/apps/desktop/src/main/telemetry/telemetry.ts b/apps/desktop/src/main/telemetry/telemetry.ts index f744a715f..baa73b9c1 100644 --- a/apps/desktop/src/main/telemetry/telemetry.ts +++ b/apps/desktop/src/main/telemetry/telemetry.ts @@ -297,7 +297,7 @@ export async function createTelemetryService( ]); }; - const userActionKey = (input: UserActionSignal): string => `${input.action}:${input.surface}`; + const userActionKey = (input: UserActionSignal): string => `${input.action}:${input.surface}:${input.app ?? ''}`; const deliverNextUserAction = (): void => { if (userActionDelivery) return; diff --git a/apps/desktop/src/renderer/modules/telemetry/actions.ts b/apps/desktop/src/renderer/modules/telemetry/actions.ts index be5d359df..52f35b62d 100644 --- a/apps/desktop/src/renderer/modules/telemetry/actions.ts +++ b/apps/desktop/src/renderer/modules/telemetry/actions.ts @@ -1,6 +1,7 @@ import type { FirstModelShownSignal, TelemetryActionSurface, + TelemetryAppName, TelemetryUserAction, } from '../../../shared/telemetry.js'; import type { ViewName } from '../../ui/types.js'; @@ -32,9 +33,17 @@ export function telemetrySurfaceForView(view: ViewName): TelemetryActionSurface return VIEW_SURFACES[view]; } -export function recordUserAction(action: TelemetryUserAction, surface: TelemetryActionSurface): void { +export function recordUserAction( + action: TelemetryUserAction, + surface: TelemetryActionSurface, + app?: TelemetryAppName, +): void { try { - void window.antseedDesktop?.telemetryRecordUserAction?.({ action, surface }).catch(() => undefined); + void window.antseedDesktop?.telemetryRecordUserAction?.({ + action, + surface, + ...(app !== undefined ? { app } : {}), + }).catch(() => undefined); } catch { // Telemetry must never affect user actions. } diff --git a/apps/desktop/src/renderer/ui/components/views/TelegramBotCard.tsx b/apps/desktop/src/renderer/ui/components/views/TelegramBotCard.tsx index 2ef6bca3c..015a168ca 100644 --- a/apps/desktop/src/renderer/ui/components/views/TelegramBotCard.tsx +++ b/apps/desktop/src/renderer/ui/components/views/TelegramBotCard.tsx @@ -38,7 +38,7 @@ export function TelegramBotCard() { const bridge = window.antseedDesktop; const token = tokenDraft.trim(); if (!token || !bridge?.telegramConnect) return; - recordUserAction('app_connect', 'apps'); + recordUserAction('app_connect', 'apps', 'telegram'); setConnectBusy(true); setError(null); try { @@ -59,7 +59,7 @@ export function TelegramBotCard() { const bridge = window.antseedDesktop; if (!bridge?.telegramDisconnect) return; if (!window.confirm('Disconnect the Telegram bot? The saved token is removed from this device.')) return; - recordUserAction('app_disconnect', 'apps'); + recordUserAction('app_disconnect', 'apps', 'telegram'); const result = await bridge.telegramDisconnect(); if (result.data) setStatus(result.data); setChangingBot(false); diff --git a/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx b/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx index 9405769cf..f12250d97 100644 --- a/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx +++ b/apps/desktop/src/renderer/ui/components/views/VprHomeView.tsx @@ -45,6 +45,7 @@ import { isBuyerReady } from '../../../modules/app/connect-badge'; import { isDisconnectConfirmDismissed, persistDisconnectConfirmDismissed } from '../../../modules/app/disconnect-confirm'; import styles from './VprHomeView.module.scss'; import { recordFirstModelShown, recordUserAction } from '../../../modules/telemetry/actions'; +import { normalizeTelemetryAppName } from '../../../../shared/telemetry.js'; type Props = { onSelectView?: (view: ViewName) => void }; @@ -292,7 +293,7 @@ export function VprHomeView({ onSelectView }: Props) { // the profile can't be connected automatically (e.g. no route yet). async function connectApp(profileName: string): Promise { if (connectingProfile !== null) return; - recordUserAction('app_connect', 'home'); + recordUserAction('app_connect', 'home', normalizeTelemetryAppName(profileName)); setConnectingProfile(profileName); try { const result = await connectVprProfile(window.antseedDesktop, getUiStateRef(), profileName); diff --git a/apps/desktop/src/renderer/ui/components/views/VprToolsView.tsx b/apps/desktop/src/renderer/ui/components/views/VprToolsView.tsx index 55e110b10..49f527140 100644 --- a/apps/desktop/src/renderer/ui/components/views/VprToolsView.tsx +++ b/apps/desktop/src/renderer/ui/components/views/VprToolsView.tsx @@ -22,6 +22,7 @@ import { AppsOnboarding } from './AppsOnboarding'; import { isAppsOnboardingSeen, persistAppsOnboardingSeen } from '../../../modules/app/apps-onboarding'; import styles from './VprToolsView.module.scss'; import { recordUserAction } from '../../../modules/telemetry/actions'; +import { normalizeTelemetryAppName } from '../../../../shared/telemetry.js'; declare const __ANTSEED_SYSTEM_PROXY_PORT__: number; @@ -241,7 +242,6 @@ export function VprToolsView() { const startProfiles = useCallback(async (names: string[]): Promise => { const bridge = window.antseedDesktop; if (!bridge?.systemProxyStart || !defaultPeerId) return false; - recordUserAction('app_connect', 'apps'); setBusy(names.join(',')); setMessage(null); const defaultRoute = { peerId: defaultPeerId, model: defaultModel }; @@ -266,9 +266,9 @@ export function VprToolsView() { return true; }, [activeProfileNames.length, defaultModel, defaultPeerId, peerOptions, profiles, proxyState?.running]); - const disconnect = useCallback(async () => { + const disconnect = useCallback(async (appName?: string) => { const bridge = window.antseedDesktop; - recordUserAction('app_disconnect', 'apps'); + recordUserAction('app_disconnect', 'apps', appName !== undefined ? normalizeTelemetryAppName(appName) : undefined); setBusy('stop'); const result = await bridge?.systemProxyStop?.(); setBusy(null); @@ -290,6 +290,7 @@ export function VprToolsView() { }, []); const connectProfile = useCallback(async (profileName: string) => { + recordUserAction('app_connect', 'apps', normalizeTelemetryAppName(profileName)); const names = Array.from(new Set([...activeProfileNames, profileName])); setConnecting(profileName); try { @@ -317,9 +318,10 @@ export function VprToolsView() { const disconnectProfile = useCallback((profileName: string) => { const remaining = activeProfileNames.filter((name) => name !== profileName); if (remaining.length === 0) { - void disconnect(); + void disconnect(profileName); return; } + recordUserAction('app_disconnect', 'apps', normalizeTelemetryAppName(profileName)); void startProfiles(remaining); }, [activeProfileNames, disconnect, startProfiles]); @@ -515,7 +517,7 @@ export function VprToolsView() { if (connected) { const remaining = activeProfileNames.filter((name) => name !== profileName); if (remaining.length === 0) { - await disconnect(); + await disconnect(profileName); } else { await startProfiles(remaining); } diff --git a/apps/desktop/src/shared/telemetry.ts b/apps/desktop/src/shared/telemetry.ts index 8ba01918a..5edac082e 100644 --- a/apps/desktop/src/shared/telemetry.ts +++ b/apps/desktop/src/shared/telemetry.ts @@ -32,6 +32,35 @@ export const TELEMETRY_USER_ACTIONS = [ export type TelemetryUserAction = (typeof TELEMETRY_USER_ACTIONS)[number]; +/** + * Which app an app_connect / app_disconnect action refers to. A fixed local + * taxonomy: the packaged profile names plus the Telegram bot; anything else + * (user-added apps) reports as 'custom' so raw names never leave the device. + */ +export const TELEMETRY_APP_NAMES = [ + 'opencode', + 'codex', + 'claude-desktop', + 'hermes', + 'droid', + 't3code', + 'pi', + 'gooeypi', + 'crush', + 'goose', + 'zed', + 'telegram', + 'custom', +] as const; + +export type TelemetryAppName = (typeof TELEMETRY_APP_NAMES)[number]; + +export function normalizeTelemetryAppName(name: string): TelemetryAppName { + return (TELEMETRY_APP_NAMES as readonly string[]).includes(name) + ? (name as TelemetryAppName) + : 'custom'; +} + export const TELEMETRY_ACTION_SURFACES = [ 'home', 'explore', @@ -65,6 +94,8 @@ export type FirstModelShownSignal = { export type UserActionSignal = { action: TelemetryUserAction; surface: TelemetryActionSurface; + /** Set on app_connect / app_disconnect: which app it was. */ + app?: TelemetryAppName; }; export type TelemetryStatus = {