Add effectiveness telemetry for in-app GitHub stars (#5245)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
f31c40cb18
commit
d93de0935f
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
])
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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+'
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue