Add effectiveness telemetry for in-app GitHub stars (#5245)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-12 10:14:49 -07:00 committed by GitHub
parent f31c40cb18
commit d93de0935f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 664 additions and 29 deletions

View File

@ -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',

View File

@ -55,6 +55,7 @@ let storeRef: Store | null = null
const MAIN_OWNED_TELEMETRY_EVENTS = new Set<EventName>([
'app_starred_orca',
'star_nag_outcome',
'feature_interaction_usage_bucket_reached'
])

View File

@ -13,7 +13,15 @@ type TestWindow = {
webContents: { send: ReturnType<typeof vi.fn> }
}
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<boolean>()
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<boolean>()
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)
})
})

View File

@ -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<EventProps<'star_nag_outcome'>, 'outcome' | 'next_threshold'>
type StarNagPromptSession = {
source: StarNagPromptSource
type StarNagPromptSession = StarNagPromptContext & {
starAttemptPromise?: Promise<boolean>
}
/**
@ -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<boolean> {
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<boolean> {
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 })

View File

@ -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)

View File

@ -1627,6 +1627,8 @@ export type PreloadApi = {
dismiss: () => Promise<void>
complete: () => Promise<void>
disable: () => Promise<void>
openWeb: () => Promise<void>
starOrca: () => Promise<boolean>
forceShow: () => Promise<void>
}
/** Fire-and-forget track. Loose typing at the IPC boundary on purpose

View File

@ -1475,6 +1475,8 @@ const api = {
dismiss: (): Promise<void> => ipcRenderer.invoke('star-nag:dismiss'),
complete: (): Promise<void> => ipcRenderer.invoke('star-nag:complete'),
disable: (): Promise<void> => ipcRenderer.invoke('star-nag:disable'),
openWeb: (): Promise<void> => ipcRenderer.invoke('star-nag:openWeb'),
starOrca: (): Promise<boolean> => ipcRenderer.invoke('star-nag:starOrca'),
forceShow: (): Promise<void> => ipcRenderer.invoke('star-nag:forceShow')
},

View File

@ -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')}
>
<X className="size-3.5" />
@ -156,10 +160,22 @@ export function StarNagCard(): React.JSX.Element | null {
: translate('auto.components.StarNagCard.2d67b6c849', 'Star on GitHub')}
</Button>
<div className="flex items-center justify-between gap-2">
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={handleClose}>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleClose}
disabled={busy}
>
{translate('auto.components.StarNagCard.8c967b4d15', 'Not now')}
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={handleDisable}>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleDisable}
disabled={busy}
>
{translate('auto.components.StarNagCard.73dfd4eb8d', "Don't ask again")}
</Button>
</div>

View File

@ -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()

View File

@ -170,6 +170,7 @@ function invalidateRuntimeWorktreeCaches(): void {
type WebSettingsApi = NonNullable<PreloadApi['settings']>
type WebKeybindingsApi = NonNullable<PreloadApi['keybindings']>
type WebGitHubApi = NonNullable<PreloadApi['gh']>
type WebStarNagApi = NonNullable<PreloadApi['starNag']>
type WebGitHubResult<K extends keyof WebGitHubApi> = Awaited<ReturnType<WebGitHubApi[K]>>
type WebGitHubRouteKey =
| 'repoSlug'
@ -555,6 +556,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
browser: createBrowserApi(),
emulator: createEmulatorApi(),
gh: createGitHubApi(),
starNag: createStarNagApi(),
gl: createGitLabApi(),
hostedReview: createRuntimeNamespaceApi('hostedReview'),
linear: createRuntimeNamespaceApi('linear'),
@ -1573,6 +1575,18 @@ function createEmulatorApi(): NonNullable<Partial<PreloadApi>['emulator']> {
} as unknown as NonNullable<Partial<PreloadApi>['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 = <Result>(method: WebGitHubRuntimeMethod, args?: unknown): Promise<Result> =>
callRuntimeResult<Result>(method, mapRepoPathArg(args))

View File

@ -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<typeof starNagOutcomeSchema>
export type StarNagPromptSource = z.infer<typeof starNagPromptSourceSchema>
export type StarNagPromptMode = z.infer<typeof starNagPromptModeSchema>
export type StarNagAgentBucket = z.infer<typeof starNagAgentBucketSchema>
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+'
}

View File

@ -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({

View File

@ -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'