diff --git a/src/main/ipc/telemetry.test.ts b/src/main/ipc/telemetry.test.ts index 162ee86ba..27c7d0cd1 100644 --- a/src/main/ipc/telemetry.test.ts +++ b/src/main/ipc/telemetry.test.ts @@ -160,6 +160,14 @@ describe('telemetry IPC handlers', () => { registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) const handler = handlers.get('telemetry:track')! handler({}, 'app_starred_orca', { source: 'settings' }) + handler({}, 'star_nag_outcome', { + outcome: 'shown', + source: 'threshold', + mode: 'gh', + threshold: 35, + agents_since_baseline: 35, + agents_since_baseline_bucket: '35-69' + }) handler({}, 'feature_interaction_usage_bucket_reached', { feature_id: 'tasks', feature_category: 'task_management', diff --git a/src/main/ipc/telemetry.ts b/src/main/ipc/telemetry.ts index a4faa9d79..03cc50e70 100644 --- a/src/main/ipc/telemetry.ts +++ b/src/main/ipc/telemetry.ts @@ -55,6 +55,7 @@ let storeRef: Store | null = null const MAIN_OWNED_TELEMETRY_EVENTS = new Set([ 'app_starred_orca', + 'star_nag_outcome', 'feature_interaction_usage_bucket_reached' ]) diff --git a/src/main/star-nag/service.test.ts b/src/main/star-nag/service.test.ts index 5028667d2..9dacb3d9d 100644 --- a/src/main/star-nag/service.test.ts +++ b/src/main/star-nag/service.test.ts @@ -13,7 +13,15 @@ type TestWindow = { webContents: { send: ReturnType } } -const { appMock, browserWindowMock, checkOrcaStarredMock, ipcMainHandleMock } = vi.hoisted(() => ({ +const { + appMock, + browserWindowMock, + checkOrcaStarredMock, + starOrcaMock, + trackMock, + getCohortAtEmitMock, + ipcMainHandleMock +} = vi.hoisted(() => ({ appMock: { getVersion: vi.fn(() => '1.2.3') }, @@ -21,6 +29,9 @@ const { appMock, browserWindowMock, checkOrcaStarredMock, ipcMainHandleMock } = getAllWindows: vi.fn<() => TestWindow[]>(() => []) }, checkOrcaStarredMock: vi.fn(), + starOrcaMock: vi.fn(), + trackMock: vi.fn(), + getCohortAtEmitMock: vi.fn(() => ({ nth_repo_added: 3 })), ipcMainHandleMock: vi.fn() })) @@ -33,7 +44,16 @@ vi.mock('electron', () => ({ })) vi.mock('../github/client', () => ({ - checkOrcaStarred: checkOrcaStarredMock + checkOrcaStarred: checkOrcaStarredMock, + starOrca: starOrcaMock +})) + +vi.mock('../telemetry/client', () => ({ + track: trackMock +})) + +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: getCohortAtEmitMock })) type AgentStartedListener = (totalAgentsSpawned: number) => void @@ -131,6 +151,11 @@ describe('StarNagService', () => { browserWindowMock.getAllWindows.mockReturnValue([]) checkOrcaStarredMock.mockReset() checkOrcaStarredMock.mockResolvedValue(false) + starOrcaMock.mockReset() + starOrcaMock.mockResolvedValue(true) + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 3 }) ipcMainHandleMock.mockReset() consoleInfoMock = vi.spyOn(console, 'info').mockImplementation(() => undefined) }) @@ -150,7 +175,9 @@ describe('StarNagService', () => { emitAgentStarted(46) expect(window.webContents.send).toHaveBeenCalledTimes(1) - expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'gh' }) + expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { + mode: 'gh' + }) expect(consoleInfoMock).toHaveBeenCalledTimes(1) expect(consoleInfoMock).toHaveBeenCalledWith({ event: 'star_nag_shown', @@ -171,7 +198,18 @@ describe('StarNagService', () => { emitAgentStarted(45) await flushAsyncWork() - expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'web' }) + expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { + mode: 'web' + }) + expect(trackMock).toHaveBeenCalledWith('star_nag_outcome', { + outcome: 'shown', + source: 'threshold', + mode: 'web', + threshold: STAR_NAG_INITIAL_THRESHOLD, + agents_since_baseline: 35, + agents_since_baseline_bucket: '35-69', + nth_repo_added: 3 + }) expect(consoleInfoMock).toHaveBeenCalledWith({ event: 'star_nag_shown', app_version: '1.2.3', @@ -203,13 +241,16 @@ describe('StarNagService', () => { await flushAsyncWork() expect(consoleInfoMock).not.toHaveBeenCalled() + expect(trackMock).not.toHaveBeenCalled() const window = createWindow() browserWindowMock.getAllWindows.mockReturnValue([window]) emitAgentStarted(46) await flushAsyncWork() - expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'gh' }) + expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { + mode: 'gh' + }) expect(consoleInfoMock).toHaveBeenCalledWith({ event: 'star_nag_shown', app_version: '1.2.3', @@ -282,7 +323,9 @@ describe('StarNagService', () => { browserWindowMock.getAllWindows.mockReturnValue([window]) forceShow() - expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'gh' }) + expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { + mode: 'gh' + }) expect(consoleInfoMock).toHaveBeenCalledWith({ event: 'star_nag_shown', app_version: '1.2.3', @@ -405,7 +448,9 @@ describe('StarNagService', () => { expect(window.webContents.send).toHaveBeenCalledTimes(1) expect(consoleInfoMock).toHaveBeenCalledTimes(1) - expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'web' }) + expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { + mode: 'web' + }) expect(consoleInfoMock).toHaveBeenCalledWith({ event: 'star_nag_shown', app_version: '1.2.3', @@ -458,4 +503,217 @@ describe('StarNagService', () => { source: 'force_show' }) }) + + it('emits shown and already_starred_suppressed outcomes with cohort context', async () => { + const window = createWindow() + browserWindowMock.getAllWindows.mockReturnValue([window]) + const { service, emitAgentStarted } = createHarness() + + service.start() + emitAgentStarted(45) + await flushAsyncWork() + + expect(trackMock).toHaveBeenCalledWith('star_nag_outcome', { + outcome: 'shown', + source: 'threshold', + mode: 'gh', + threshold: STAR_NAG_INITIAL_THRESHOLD, + agents_since_baseline: 35, + agents_since_baseline_bucket: '35-69', + nth_repo_added: 3 + }) + + trackMock.mockClear() + checkOrcaStarredMock.mockResolvedValue(true) + const next = createHarness() + next.service.start() + next.emitAgentStarted(45) + await flushAsyncWork() + + expect(trackMock).toHaveBeenCalledWith('star_nag_outcome', { + outcome: 'already_starred_suppressed', + source: 'threshold', + mode: 'gh', + threshold: STAR_NAG_INITIAL_THRESHOLD, + agents_since_baseline: 35, + agents_since_baseline_bucket: '35-69', + nth_repo_added: 3 + }) + }) + + it('emits dismissed, disabled, and opened_web as distinct main-owned outcomes', () => { + const window = createWindow() + browserWindowMock.getAllWindows.mockReturnValue([window]) + const dismissed = createHarness() + + dismissed.service.registerIpcHandlers() + getIpcHandler('star-nag:forceShow')() + getIpcHandler('star-nag:dismiss')() + + expect(trackMock).toHaveBeenCalledWith('star_nag_outcome', { + outcome: 'dismissed', + source: 'force_show', + mode: 'gh', + threshold: STAR_NAG_INITIAL_THRESHOLD, + agents_since_baseline: 35, + agents_since_baseline_bucket: '35-69', + nth_repo_added: 3, + next_threshold: STAR_NAG_INITIAL_THRESHOLD * 2 + }) + + trackMock.mockClear() + ipcMainHandleMock.mockClear() + const disabled = createHarness() + disabled.service.registerIpcHandlers() + getIpcHandler('star-nag:forceShow')() + getIpcHandler('star-nag:disable')() + + expect(trackMock).toHaveBeenCalledWith( + 'star_nag_outcome', + expect.objectContaining({ outcome: 'disabled', mode: 'gh' }) + ) + + trackMock.mockClear() + ipcMainHandleMock.mockClear() + const opened = createHarness() + opened.service.registerIpcHandlers() + getIpcHandler('star-nag:forceShow')() + getIpcHandler('star-nag:openWeb')() + + expect(trackMock).toHaveBeenCalledWith( + 'star_nag_outcome', + expect.objectContaining({ outcome: 'opened_web', mode: 'web' }) + ) + }) + + it('emits direct-star attempted and succeeded outcomes plus app_starred_orca', async () => { + const window = createWindow() + browserWindowMock.getAllWindows.mockReturnValue([window]) + const { service, ui } = createHarness() + + service.registerIpcHandlers() + getIpcHandler('star-nag:forceShow')() + const ok = await getIpcHandler('star-nag:starOrca')() + + expect(ok).toBe(true) + expect(ui.starNagCompleted).toBe(true) + expect(trackMock).toHaveBeenCalledWith( + 'star_nag_outcome', + expect.objectContaining({ outcome: 'star_attempted', mode: 'gh' }) + ) + expect(trackMock).toHaveBeenCalledWith( + 'star_nag_outcome', + expect.objectContaining({ outcome: 'star_succeeded', mode: 'gh' }) + ) + expect(trackMock).toHaveBeenCalledWith('app_starred_orca', { + source: 'star_nag', + nth_repo_added: 3 + }) + }) + + it('uses fresh cohort context for canonical app_starred_orca success telemetry', async () => { + const window = createWindow() + browserWindowMock.getAllWindows.mockReturnValue([window]) + getCohortAtEmitMock + .mockReturnValueOnce({ nth_repo_added: 2 }) + .mockReturnValueOnce({ nth_repo_added: 4 }) + const { service } = createHarness() + + service.registerIpcHandlers() + getIpcHandler('star-nag:forceShow')() + await getIpcHandler('star-nag:starOrca')() + + expect(trackMock).toHaveBeenCalledWith( + 'star_nag_outcome', + expect.objectContaining({ outcome: 'shown', nth_repo_added: 2 }) + ) + expect(trackMock).toHaveBeenCalledWith( + 'star_nag_outcome', + expect.objectContaining({ outcome: 'star_succeeded', nth_repo_added: 2 }) + ) + expect(trackMock).toHaveBeenCalledWith('app_starred_orca', { + source: 'star_nag', + nth_repo_added: 4 + }) + }) + + it('records success and completion when direct star resolves after dismissal cleared the visible session', async () => { + const window = createWindow() + browserWindowMock.getAllWindows.mockReturnValue([window]) + const deferredStar = createDeferred() + starOrcaMock.mockReturnValue(deferredStar.promise) + const { service, ui } = createHarness() + + service.registerIpcHandlers() + getIpcHandler('star-nag:forceShow')() + const starPromise = getIpcHandler('star-nag:starOrca')() + getIpcHandler('star-nag:dismiss')() + + deferredStar.resolve(true) + await expect(starPromise).resolves.toBe(true) + + expect(trackMock).toHaveBeenCalledWith( + 'star_nag_outcome', + expect.objectContaining({ outcome: 'star_succeeded', mode: 'gh' }) + ) + expect(trackMock).toHaveBeenCalledWith('app_starred_orca', { + source: 'star_nag', + nth_repo_added: 3 + }) + expect(ui.starNagCompleted).toBe(true) + }) + + it('clears the in-flight direct-star guard after thrown attempts so the user can retry', async () => { + const window = createWindow() + browserWindowMock.getAllWindows.mockReturnValue([window]) + starOrcaMock.mockRejectedValueOnce(new Error('gh failed')).mockResolvedValueOnce(true) + const { service, ui } = createHarness() + + service.registerIpcHandlers() + getIpcHandler('star-nag:forceShow')() + const starFromNag = getIpcHandler('star-nag:starOrca') + + await expect(starFromNag()).rejects.toThrow('gh failed') + await expect(starFromNag()).resolves.toBe(true) + + expect(starOrcaMock).toHaveBeenCalledTimes(2) + expect(ui.starNagCompleted).toBe(true) + }) + + it('records failed direct star before web fallback and guards duplicate in-flight attempts', async () => { + const window = createWindow() + browserWindowMock.getAllWindows.mockReturnValue([window]) + const deferredStar = createDeferred() + starOrcaMock.mockReturnValue(deferredStar.promise) + const { service, ui } = createHarness() + + service.registerIpcHandlers() + getIpcHandler('star-nag:forceShow')() + const starFromNag = getIpcHandler('star-nag:starOrca') + const first = starFromNag() + const second = starFromNag() + + deferredStar.resolve(false) + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(false) + + const starAttempts = trackMock.mock.calls.filter( + ([name, payload]) => + name === 'star_nag_outcome' && + (payload as { outcome?: string }).outcome === 'star_attempted' + ) + expect(starAttempts).toHaveLength(1) + expect(trackMock).toHaveBeenCalledWith( + 'star_nag_outcome', + expect.objectContaining({ outcome: 'star_failed', mode: 'gh' }) + ) + + getIpcHandler('star-nag:openWeb')() + + expect(trackMock).toHaveBeenCalledWith( + 'star_nag_outcome', + expect.objectContaining({ outcome: 'opened_web', mode: 'web' }) + ) + expect(ui.starNagCompleted).toBe(true) + }) }) diff --git a/src/main/star-nag/service.ts b/src/main/star-nag/service.ts index 76afffd8d..fd8b211a1 100644 --- a/src/main/star-nag/service.ts +++ b/src/main/star-nag/service.ts @@ -1,14 +1,22 @@ import { app, BrowserWindow, ipcMain } from 'electron' import { STAR_NAG_INITIAL_THRESHOLD } from '../../shared/constants' -import { checkOrcaStarred } from '../github/client' +import { checkOrcaStarred, starOrca } from '../github/client' import type { Store } from '../persistence' import type { StatsCollector } from '../stats/collector' +import { track } from '../telemetry/client' +import { getCohortAtEmit } from '../telemetry/cohort-classifier' +import { + bucketStarNagAgentsSinceBaseline, + type StarNagOutcome, + type StarNagPromptMode, + type StarNagPromptSource +} from '../../shared/star-nag-telemetry' +import type { EventProps } from '../../shared/telemetry-events' -type StarNagPromptSource = 'threshold' | 'force_show' -type StarNagPromptMode = 'gh' | 'web' +type StarNagPromptContext = Omit, 'outcome' | 'next_threshold'> -type StarNagPromptSession = { - source: StarNagPromptSource +type StarNagPromptSession = StarNagPromptContext & { + starAttemptPromise?: Promise } /** @@ -36,8 +44,9 @@ export class StarNagService { // resolving. private evaluating = false private pendingForceShow = false - // Why: dismissal backoff should only apply to a prompt that was actually - // delivered, and the dismissal payload needs the delivered prompt source. + // Why: dismissal backoff and action telemetry must use the prompt context + // that was delivered, not whatever threshold/source happens to be current + // when the renderer later reports a user action. private promptSession: StarNagPromptSession | null = null constructor(store: Store, stats: StatsCollector) { @@ -65,7 +74,9 @@ export class StarNagService { registerIpcHandlers(): void { ipcMain.handle('star-nag:dismiss', () => this.dismiss()) ipcMain.handle('star-nag:complete', () => this.markCompleted()) - ipcMain.handle('star-nag:disable', () => this.markCompleted()) + ipcMain.handle('star-nag:disable', () => this.disable()) + ipcMain.handle('star-nag:openWeb', () => this.openWeb()) + ipcMain.handle('star-nag:starOrca', () => this.starOrcaFromNag()) ipcMain.handle('star-nag:forceShow', () => this.forceShow()) } @@ -134,6 +145,7 @@ export class StarNagService { return } if (starred) { + this.trackAlreadyStarredSuppressed(source) // Already starred somewhere — lock in the permanent suppression so we // stop recomputing thresholds on every spawn. this.markCompleted() @@ -167,13 +179,67 @@ export class StarNagService { this.promptSession = null return false } + const context = this.createPromptContext(source, mode) win.webContents.send('star-nag:show', { mode }) this.promptVisible = true - this.promptSession = { source } + this.promptSession = context + this.trackOutcome('shown') this.logConsoleEvent('star_nag_shown', source) return true } + private createPromptContext( + source: StarNagPromptSource, + mode: StarNagPromptMode + ): StarNagPromptContext { + const ui = this.store.getUI() + const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD + const agentsSinceBaseline = Math.max( + 0, + this.stats.getTotalAgentsSpawned() - (ui.starNagBaselineAgents ?? 0) + ) + return { + source, + mode, + threshold, + agents_since_baseline: agentsSinceBaseline, + agents_since_baseline_bucket: bucketStarNagAgentsSinceBaseline(agentsSinceBaseline), + ...getCohortAtEmit() + } + } + + private trackOutcome( + outcome: StarNagOutcome, + options: { mode?: StarNagPromptMode; nextThreshold?: number } = {} + ): void { + const session = this.promptSession + if (!session) { + return + } + this.trackSessionOutcome(session, outcome, options) + } + + private trackSessionOutcome( + session: StarNagPromptSession, + outcome: StarNagOutcome, + options: { mode?: StarNagPromptMode; nextThreshold?: number } = {} + ): void { + const { starAttemptPromise: _starAttemptPromise, ...context } = session + track('star_nag_outcome', { + ...context, + outcome, + ...(options.mode === undefined ? {} : { mode: options.mode }), + ...(options.nextThreshold === undefined ? {} : { next_threshold: options.nextThreshold }) + }) + } + + private trackAlreadyStarredSuppressed(source: StarNagPromptSource): void { + track('star_nag_outcome', { + ...this.createPromptContext(source, 'gh'), + outcome: 'already_starred_suppressed' + }) + } + private logConsoleEvent( event: 'star_nag_shown' | 'star_nag_dismissed', source: StarNagPromptSource, @@ -211,6 +277,7 @@ export class StarNagService { const ui = this.store.getUI() const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD const nextThreshold = threshold * 2 + this.trackOutcome('dismissed', { nextThreshold }) this.logConsoleEvent('star_nag_dismissed', session.source, nextThreshold) this.store.updateUI({ starNagNextThreshold: nextThreshold, @@ -220,6 +287,56 @@ export class StarNagService { this.promptSession = null } + private disable(): void { + this.trackOutcome('disabled') + this.markCompleted() + } + + private openWeb(): void { + this.trackOutcome('opened_web', { mode: 'web' }) + this.markCompleted() + } + + private async starOrcaFromNag(): Promise { + const session = this.promptSession + if (!session) { + return false + } + if (session.starAttemptPromise) { + return session.starAttemptPromise + } + const attempt = this.runStarOrcaAttempt(session) + session.starAttemptPromise = attempt + try { + return await attempt + } finally { + if (this.promptSession === session) { + delete session.starAttemptPromise + } + } + } + + private async runStarOrcaAttempt(session: StarNagPromptSession): Promise { + this.trackSessionOutcome(session, 'star_attempted', { mode: 'gh' }) + const starred = await starOrca() + if (!starred) { + if (this.promptSession === session) { + this.trackSessionOutcome(session, 'star_failed', { mode: 'gh' }) + session.mode = 'web' + } + return false + } + this.trackSessionOutcome(session, 'star_succeeded', { mode: 'gh' }) + // Why: app_starred_orca remains the canonical cross-surface success event; + // star_nag_outcome is only the nag-funnel companion. + track('app_starred_orca', { + source: 'star_nag', + ...getCohortAtEmit() + }) + this.markCompleted() + return true + } + /** User successfully starred or opted out → never nag again. */ private markCompleted(): void { this.store.updateUI({ starNagCompleted: true }) diff --git a/src/main/telemetry/validator.test.ts b/src/main/telemetry/validator.test.ts index bb93bdad1..573b6907b 100644 --- a/src/main/telemetry/validator.test.ts +++ b/src/main/telemetry/validator.test.ts @@ -41,6 +41,43 @@ describe('validate', () => { expect(result.ok).toBe(true) }) + it('accepts a well-formed star_nag_outcome payload with cohort context', () => { + const result = validate('star_nag_outcome', { + outcome: 'opened_web', + source: 'force_show', + mode: 'web', + threshold: 35, + agents_since_baseline: 42, + agents_since_baseline_bucket: '35-69', + nth_repo_added: 4 + }) + expect(result.ok).toBe(true) + }) + + it('rejects malformed star_nag_outcome payloads', () => { + expect( + validate('star_nag_outcome', { + outcome: 'opened_web', + source: 'force_show', + mode: 'web', + threshold: 35, + agents_since_baseline: 42, + agents_since_baseline_bucket: '35-69', + raw_error: 'nope' + } as never).ok + ).toBe(false) + expect( + validate('star_nag_outcome', { + outcome: 'opened_web', + source: 'force_show', + mode: 'web', + threshold: 0, + agents_since_baseline: 42, + agents_since_baseline_bucket: '35-69' + } as never).ok + ).toBe(false) + }) + it('drops unknown event names', () => { const result = validate('not_a_real_event' as never, {}) expect(result.ok).toBe(false) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 549a266b2..75a51fad6 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1627,6 +1627,8 @@ export type PreloadApi = { dismiss: () => Promise complete: () => Promise disable: () => Promise + openWeb: () => Promise + starOrca: () => Promise forceShow: () => Promise } /** Fire-and-forget track. Loose typing at the IPC boundary on purpose — diff --git a/src/preload/index.ts b/src/preload/index.ts index 48a05a4bd..3d2ce8b61 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1475,6 +1475,8 @@ const api = { dismiss: (): Promise => ipcRenderer.invoke('star-nag:dismiss'), complete: (): Promise => ipcRenderer.invoke('star-nag:complete'), disable: (): Promise => ipcRenderer.invoke('star-nag:disable'), + openWeb: (): Promise => ipcRenderer.invoke('star-nag:openWeb'), + starOrca: (): Promise => ipcRenderer.invoke('star-nag:starOrca'), forceShow: (): Promise => ipcRenderer.invoke('star-nag:forceShow') }, diff --git a/src/renderer/src/components/StarNagCard.tsx b/src/renderer/src/components/StarNagCard.tsx index 4f6a4f9ea..f127caf34 100644 --- a/src/renderer/src/components/StarNagCard.tsx +++ b/src/renderer/src/components/StarNagCard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { ExternalLink, Star, X } from 'lucide-react' import { Card } from './ui/card' import { Button } from './ui/button' @@ -39,18 +39,24 @@ export function StarNagCard(): React.JSX.Element | null { }) }, []) - const handleClose = (): void => { + const handleClose = useCallback((): void => { + if (busy) { + return + } setVisible(false) // Why: fire-and-forget. If persisting the dismissal fails the worst case // is we re-fire the same threshold on next launch — not worth blocking // the close animation on. void window.api.starNag.dismiss() - } + }, [busy]) - const handleDisable = (): void => { + const handleDisable = useCallback((): void => { + if (busy) { + return + } setVisible(false) void window.api.starNag.disable() - } + }, [busy]) useEffect(() => { if (!visible) { @@ -63,9 +69,7 @@ export function StarNagCard(): React.JSX.Element | null { } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) - // eslint-disable-next-line react-hooks/exhaustive-deps -- handleClose closes - // over stable refs; re-binding on each render is unnecessary. - }, [visible]) + }, [handleClose, visible]) if (!visible) { return null @@ -78,7 +82,7 @@ export function StarNagCard(): React.JSX.Element | null { if (mode === 'web') { setBusy(true) await window.api.shell.openUrl(ORCA_STARGAZERS_URL) - await window.api.starNag.disable() + await window.api.starNag.openWeb() if (mountedRef.current) { setBusy(false) setVisible(false) @@ -86,7 +90,7 @@ export function StarNagCard(): React.JSX.Element | null { return } setBusy(true) - const ok = await window.api.gh.starOrca('star_nag') + const ok = await window.api.starNag.starOrca() if (mountedRef.current) { setBusy(false) } @@ -96,7 +100,6 @@ export function StarNagCard(): React.JSX.Element | null { } return } - await window.api.starNag.complete() if (mountedRef.current) { setVisible(false) } @@ -126,6 +129,7 @@ export function StarNagCard(): React.JSX.Element | null { size="icon" className="size-7 shrink-0" onClick={handleClose} + disabled={busy} aria-label={translate('auto.components.StarNagCard.b5e685e4d9', 'Dismiss')} > @@ -156,10 +160,22 @@ export function StarNagCard(): React.JSX.Element | null { : translate('auto.components.StarNagCard.2d67b6c849', 'Star on GitHub')}
- -
diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 340dd3147..541393b26 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -1215,6 +1215,42 @@ describe('web file preload API', () => { }) }) +describe('web star nag preload API', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('keeps the browser-paired star nag API safe and in parity with the preload contract', async () => { + const { api } = await installApi('Linux') + + expect(Object.keys(api.starNag).sort()).toEqual([ + 'complete', + 'disable', + 'dismiss', + 'forceShow', + 'onShow', + 'openWeb', + 'starOrca' + ]) + + const listener = vi.fn() + const unsubscribe = api.starNag.onShow(listener) + unsubscribe() + + await expect(api.starNag.dismiss()).resolves.toBeUndefined() + await expect(api.starNag.complete()).resolves.toBeUndefined() + await expect(api.starNag.disable()).resolves.toBeUndefined() + await expect(api.starNag.openWeb()).resolves.toBeUndefined() + await expect(api.starNag.forceShow()).resolves.toBeUndefined() + await expect(api.starNag.starOrca()).resolves.toBe(false) + expect(listener).not.toHaveBeenCalled() + }) +}) + describe('web GitHub preload API', () => { beforeEach(() => { vi.resetModules() diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index d9b23fdb0..d7d422f6b 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -170,6 +170,7 @@ function invalidateRuntimeWorktreeCaches(): void { type WebSettingsApi = NonNullable type WebKeybindingsApi = NonNullable type WebGitHubApi = NonNullable +type WebStarNagApi = NonNullable type WebGitHubResult = Awaited> type WebGitHubRouteKey = | 'repoSlug' @@ -555,6 +556,7 @@ function createWebPreloadApi(): Partial { browser: createBrowserApi(), emulator: createEmulatorApi(), gh: createGitHubApi(), + starNag: createStarNagApi(), gl: createGitLabApi(), hostedReview: createRuntimeNamespaceApi('hostedReview'), linear: createRuntimeNamespaceApi('linear'), @@ -1573,6 +1575,18 @@ function createEmulatorApi(): NonNullable['emulator']> { } as unknown as NonNullable['emulator']> } +function createStarNagApi(): WebStarNagApi { + return { + onShow: () => noopUnsubscribe, + dismiss: () => Promise.resolve(), + complete: () => Promise.resolve(), + disable: () => Promise.resolve(), + openWeb: () => Promise.resolve(), + starOrca: () => Promise.resolve(false), + forceShow: () => Promise.resolve() + } +} + function createGitHubApi(): WebGitHubApi { const route = (method: WebGitHubRuntimeMethod, args?: unknown): Promise => callRuntimeResult(method, mapRepoPathArg(args)) diff --git a/src/shared/star-nag-telemetry.ts b/src/shared/star-nag-telemetry.ts new file mode 100644 index 000000000..7f995d0c6 --- /dev/null +++ b/src/shared/star-nag-telemetry.ts @@ -0,0 +1,42 @@ +import { z } from 'zod' + +export const STAR_NAG_OUTCOMES = [ + 'shown', + 'dismissed', + 'disabled', + 'star_attempted', + 'star_succeeded', + 'star_failed', + 'opened_web', + 'already_starred_suppressed' +] as const + +export const STAR_NAG_PROMPT_SOURCES = ['threshold', 'force_show'] as const +export const STAR_NAG_PROMPT_MODES = ['gh', 'web'] as const +export const STAR_NAG_AGENT_BUCKETS = ['0-34', '35-69', '70-139', '140-279', '280+'] as const + +export const starNagOutcomeSchema = z.enum(STAR_NAG_OUTCOMES) +export const starNagPromptSourceSchema = z.enum(STAR_NAG_PROMPT_SOURCES) +export const starNagPromptModeSchema = z.enum(STAR_NAG_PROMPT_MODES) +export const starNagAgentBucketSchema = z.enum(STAR_NAG_AGENT_BUCKETS) + +export type StarNagOutcome = z.infer +export type StarNagPromptSource = z.infer +export type StarNagPromptMode = z.infer +export type StarNagAgentBucket = z.infer + +export function bucketStarNagAgentsSinceBaseline(agentsSinceBaseline: number): StarNagAgentBucket { + if (agentsSinceBaseline < 35) { + return '0-34' + } + if (agentsSinceBaseline < 70) { + return '35-69' + } + if (agentsSinceBaseline < 140) { + return '70-139' + } + if (agentsSinceBaseline < 280) { + return '140-279' + } + return '280+' +} diff --git a/src/shared/telemetry-events.test.ts b/src/shared/telemetry-events.test.ts index 9d32a1bff..f895ab957 100644 --- a/src/shared/telemetry-events.test.ts +++ b/src/shared/telemetry-events.test.ts @@ -158,6 +158,83 @@ describe('app_starred_orca schema', () => { }) }) +describe('star_nag_outcome schema', () => { + const valid = { + outcome: 'shown', + source: 'threshold', + mode: 'gh', + threshold: 35, + agents_since_baseline: 35, + agents_since_baseline_bucket: '35-69', + nth_repo_added: 2 + } + + it('accepts a strict valid payload with cohort context', () => { + expect(eventSchemas.star_nag_outcome.safeParse(valid).success).toBe(true) + }) + + it('is in the runtime cohort-injection roster', () => { + expect(isCohortExtendedEvent('star_nag_outcome')).toBe(true) + }) + + it('accepts next_threshold only as a positive integer', () => { + expect( + eventSchemas.star_nag_outcome.safeParse({ + ...valid, + outcome: 'dismissed', + next_threshold: 70 + }).success + ).toBe(true) + expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, next_threshold: 0 }).success).toBe( + false + ) + expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, next_threshold: 1.5 }).success).toBe( + false + ) + expect( + eventSchemas.star_nag_outcome.safeParse({ ...valid, outcome: 'shown', next_threshold: 70 }) + .success + ).toBe(false) + }) + + it('rejects unknown outcome source mode and bucket values', () => { + expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, outcome: 'ignored' }).success).toBe( + false + ) + expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, source: 'renderer' }).success).toBe( + false + ) + expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, mode: 'desktop' }).success).toBe( + false + ) + expect( + eventSchemas.star_nag_outcome.safeParse({ + ...valid, + agents_since_baseline_bucket: '35+' + }).success + ).toBe(false) + }) + + it('rejects malformed numeric fields and raw extra fields', () => { + expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, threshold: -1 }).success).toBe(false) + expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, threshold: 1.5 }).success).toBe( + false + ) + expect( + eventSchemas.star_nag_outcome.safeParse({ ...valid, agents_since_baseline: -1 }).success + ).toBe(false) + expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, nth_repo_added: -1 }).success).toBe( + false + ) + expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, error: 'gh failed' }).success).toBe( + false + ) + expect( + eventSchemas.star_nag_outcome.safeParse({ ...valid, url: 'https://github.com' }).success + ).toBe(false) + }) +}) + describe('agent_error schema', () => { it('round-trips a minimal {error_class, agent_kind} payload', () => { const parsed = eventSchemas.agent_error.safeParse({ diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 1203e21d3..9ebc26229 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -34,6 +34,12 @@ import { import { SETUP_SCRIPT_IMPORT_PROVIDERS } from './setup-script-import-providers' import { WORKSPACE_SOURCE_VALUES, type WorkspaceSource } from './workspace-source' import { appStarSourceSchema } from './gh-star-source' +import { + starNagAgentBucketSchema, + starNagOutcomeSchema, + starNagPromptModeSchema, + starNagPromptSourceSchema +} from './star-nag-telemetry' import { NESTED_REPO_COUNT_BUCKETS, NESTED_REPO_IMPORT_ACTIONS, @@ -349,6 +355,23 @@ const appStarredOrcaSchema = z }) .strict() +const starNagOutcomeEventSchema = z + .object({ + outcome: starNagOutcomeSchema, + source: starNagPromptSourceSchema, + mode: starNagPromptModeSchema, + threshold: z.number().int().positive(), + agents_since_baseline: z.number().int().nonnegative(), + agents_since_baseline_bucket: starNagAgentBucketSchema, + nth_repo_added: nthRepoAddedSchema, + next_threshold: z.number().int().positive().optional() + }) + .strict() + .refine((payload) => payload.next_threshold === undefined || payload.outcome === 'dismissed', { + message: 'next_threshold is only valid for dismissed outcomes', + path: ['next_threshold'] + }) + const workspaceCreatedSchema = z .object({ source: workspaceSourceSchema, @@ -1282,6 +1305,7 @@ const terminalPaneSplitSchema = z export const eventSchemas = { app_opened: appOpenedSchema, app_starred_orca: appStarredOrcaSchema, + star_nag_outcome: starNagOutcomeEventSchema, feature_interaction_usage_bucket_reached: featureInteractionUsageBucketReachedSchema, repo_added: repoAddedSchema, @@ -1408,6 +1432,7 @@ export const COHORT_EXTENDED: readonly EventName[] = Array.from(COHORT_EXTENDED_ type _CohortExtendedRoster = | 'app_opened' | 'app_starred_orca' + | 'star_nag_outcome' | 'feature_interaction_usage_bucket_reached' | 'repo_added' | 'add_repo_setup_step_action'