Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/main/ipc/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
} from 'node:fs/promises';
import {
TELEMETRY_ACTION_SURFACES,
TELEMETRY_APP_NAMES,
TELEMETRY_USER_ACTIONS,
type TelemetryStatusUpdateResult,
type UserActionSignal,
Expand Down Expand Up @@ -97,14 +98,16 @@ export function registerAppIpc(): void {
const candidate = payload as Partial<UserActionSignal> | 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();
if (!telemetry) return { ok: false };
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 };
});
Expand Down
6 changes: 4 additions & 2 deletions apps/desktop/src/main/telemetry/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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']),
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/main/telemetry/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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: [] };
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/telemetry/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 11 additions & 2 deletions apps/desktop/src/renderer/modules/telemetry/actions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
FirstModelShownSignal,
TelemetryActionSurface,
TelemetryAppName,
TelemetryUserAction,
} from '../../../shared/telemetry.js';
import type { ViewName } from '../../ui/types.js';
Expand Down Expand Up @@ -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.
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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<void> {
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);
Expand Down
12 changes: 7 additions & 5 deletions apps/desktop/src/renderer/ui/components/views/VprToolsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -241,7 +242,6 @@ export function VprToolsView() {
const startProfiles = useCallback(async (names: string[]): Promise<boolean> => {
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 };
Expand All @@ -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);
Expand All @@ -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 {
Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -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);
}
Expand Down
31 changes: 31 additions & 0 deletions apps/desktop/src/shared/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 = {
Expand Down
Loading