mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Make the GitHub star prompt clearer (#5538)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -267,6 +267,48 @@ describe('client UI RPC methods', () => {
|
||||
expect(runtime.updateUIState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects star-nag persisted state mutations from remote clients', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
updateUIState: vi.fn()
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('ui.set', {
|
||||
starNagBaselineAgents: 10,
|
||||
starNagAppVersion: '1.2.3',
|
||||
starNagNextThreshold: 70,
|
||||
starNagCompleted: true,
|
||||
starNagDeferredUntil: null
|
||||
})
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
|
||||
expect(runtime.updateUIState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects each star-nag persisted state mutation field from remote clients', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
updateUIState: vi.fn()
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS })
|
||||
const forbiddenPayloads = [
|
||||
{ starNagBaselineAgents: 10 },
|
||||
{ starNagAppVersion: '1.2.3' },
|
||||
{ starNagNextThreshold: 70 },
|
||||
{ starNagCompleted: true },
|
||||
{ starNagDeferredUntil: null }
|
||||
]
|
||||
|
||||
for (const payload of forbiddenPayloads) {
|
||||
const response = await dispatcher.dispatch(makeRequest('ui.set', payload))
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
|
||||
}
|
||||
expect(runtime.updateUIState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects unknown feature interaction ids', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
|
||||
@@ -204,10 +204,6 @@ const UiUpdate = z
|
||||
_sortBySmartMigrated: z.boolean().optional(),
|
||||
_inlineAgentsDefaultedForExperiment: z.boolean().optional(),
|
||||
_inlineAgentsDefaultedForAllUsers: z.boolean().optional(),
|
||||
starNagBaselineAgents: z.number().finite().nullable().optional(),
|
||||
starNagAppVersion: NullableString.optional(),
|
||||
starNagNextThreshold: z.number().finite().optional(),
|
||||
starNagCompleted: z.boolean().optional(),
|
||||
trustedOrcaHooks: z.record(z.string(), z.unknown()).optional(),
|
||||
setupScriptPromptDismissedRepoIds: StringArray.optional(),
|
||||
projectOrderManualDefaultNoticeDismissed: z.boolean().optional(),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { type StarNagOutcome, type StarNagPromptMode } from '../../shared/star-nag-telemetry'
|
||||
import type { EventProps } from '../../shared/telemetry-events'
|
||||
import { track } from '../telemetry/client'
|
||||
|
||||
export type StarNagPromptContext = Omit<
|
||||
EventProps<'star_nag_outcome'>,
|
||||
'outcome' | 'next_threshold' | 'cooldown_days'
|
||||
>
|
||||
|
||||
export type StarNagPromptSession = StarNagPromptContext & {
|
||||
openedRepoTracked?: boolean
|
||||
starAttemptPromise?: Promise<boolean>
|
||||
}
|
||||
|
||||
type StarNagOutcomeOptions = {
|
||||
mode?: StarNagPromptMode
|
||||
nextThreshold?: number
|
||||
cooldownDays?: number
|
||||
}
|
||||
|
||||
export function trackStarNagSessionOutcome(
|
||||
session: StarNagPromptSession,
|
||||
outcome: StarNagOutcome,
|
||||
options: StarNagOutcomeOptions = {}
|
||||
): void {
|
||||
const {
|
||||
openedRepoTracked: _openedRepoTracked,
|
||||
starAttemptPromise: _starAttemptPromise,
|
||||
...context
|
||||
} = session
|
||||
track('star_nag_outcome', {
|
||||
...context,
|
||||
outcome,
|
||||
...(options.mode === undefined ? {} : { mode: options.mode }),
|
||||
...(options.nextThreshold === undefined ? {} : { next_threshold: options.nextThreshold }),
|
||||
...(options.cooldownDays === undefined ? {} : { cooldown_days: options.cooldownDays })
|
||||
})
|
||||
}
|
||||
@@ -281,6 +281,37 @@ describe('StarNagService', () => {
|
||||
})
|
||||
expect(ui.starNagNextThreshold).toBe(STAR_NAG_INITIAL_THRESHOLD * 2)
|
||||
expect(ui.starNagBaselineAgents).toBe(45)
|
||||
expect(ui.starNagDeferredUntil).toBeGreaterThan(Date.now())
|
||||
})
|
||||
|
||||
it('does not show threshold prompts while the persisted cooldown is active', async () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
const { service, emitAgentStarted } = createHarness({
|
||||
starNagDeferredUntil: Date.now() + 3 * 24 * 60 * 60 * 1000
|
||||
})
|
||||
|
||||
service.start()
|
||||
emitAgentStarted(45)
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(window.webContents.send).not.toHaveBeenCalled()
|
||||
expect(trackMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows force_show to bypass the persisted cooldown', () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
const { service } = createHarness({
|
||||
starNagDeferredUntil: Date.now() + 3 * 24 * 60 * 60 * 1000
|
||||
})
|
||||
|
||||
service.registerIpcHandlers()
|
||||
getIpcHandler('star-nag:forceShow')()
|
||||
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
|
||||
mode: 'gh'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the force_show source through exposure and dismissal', () => {
|
||||
@@ -541,7 +572,7 @@ describe('StarNagService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('emits dismissed, disabled, and opened_web as distinct main-owned outcomes', () => {
|
||||
it('emits dismissed, disabled, and opened_repo as distinct main-owned outcomes', () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
const dismissed = createHarness()
|
||||
@@ -558,7 +589,8 @@ describe('StarNagService', () => {
|
||||
agents_since_baseline: 35,
|
||||
agents_since_baseline_bucket: '35-69',
|
||||
nth_repo_added: 3,
|
||||
next_threshold: STAR_NAG_INITIAL_THRESHOLD * 2
|
||||
next_threshold: STAR_NAG_INITIAL_THRESHOLD * 2,
|
||||
cooldown_days: 3
|
||||
})
|
||||
|
||||
trackMock.mockClear()
|
||||
@@ -582,8 +614,47 @@ describe('StarNagService', () => {
|
||||
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'opened_web', mode: 'web' })
|
||||
expect.objectContaining({ outcome: 'opened_repo', mode: 'web' })
|
||||
)
|
||||
expect(opened.ui.starNagCompleted).toBe(true)
|
||||
expect(opened.ui.starNagDeferredUntil).toBeNull()
|
||||
})
|
||||
|
||||
it('emits opened_repo at most once for one prompt session', () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
const { service } = createHarness()
|
||||
|
||||
service.registerIpcHandlers()
|
||||
getIpcHandler('star-nag:forceShow')()
|
||||
getIpcHandler('star-nag:openWeb')()
|
||||
getIpcHandler('star-nag:openWeb')()
|
||||
|
||||
const openedRepoOutcomes = trackMock.mock.calls.filter(
|
||||
([name, payload]) =>
|
||||
name === 'star_nag_outcome' && (payload as { outcome?: string }).outcome === 'opened_repo'
|
||||
)
|
||||
expect(openedRepoOutcomes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits later cooldown outcome without completing', () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
const later = createHarness()
|
||||
|
||||
later.service.registerIpcHandlers()
|
||||
getIpcHandler('star-nag:forceShow')()
|
||||
getIpcHandler('star-nag:later')()
|
||||
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'later', cooldown_days: 3 })
|
||||
)
|
||||
expect(consoleInfoMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ event: 'star_nag_later' })
|
||||
)
|
||||
expect(later.ui.starNagCompleted).toBeUndefined()
|
||||
expect(later.ui.starNagDeferredUntil).toBeGreaterThan(Date.now())
|
||||
})
|
||||
|
||||
it('emits direct-star attempted and succeeded outcomes plus app_starred_orca', async () => {
|
||||
@@ -599,11 +670,11 @@ describe('StarNagService', () => {
|
||||
expect(ui.starNagCompleted).toBe(true)
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'star_attempted', mode: 'gh' })
|
||||
expect.objectContaining({ outcome: 'star_clicked', mode: 'gh' })
|
||||
)
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'star_succeeded', mode: 'gh' })
|
||||
expect.objectContaining({ outcome: 'direct_star_succeeded', mode: 'gh' })
|
||||
)
|
||||
expect(trackMock).toHaveBeenCalledWith('app_starred_orca', {
|
||||
source: 'star_nag',
|
||||
@@ -629,7 +700,7 @@ describe('StarNagService', () => {
|
||||
)
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'star_succeeded', nth_repo_added: 2 })
|
||||
expect.objectContaining({ outcome: 'direct_star_succeeded', nth_repo_added: 2 })
|
||||
)
|
||||
expect(trackMock).toHaveBeenCalledWith('app_starred_orca', {
|
||||
source: 'star_nag',
|
||||
@@ -654,7 +725,7 @@ describe('StarNagService', () => {
|
||||
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'star_succeeded', mode: 'gh' })
|
||||
expect.objectContaining({ outcome: 'direct_star_succeeded', mode: 'gh' })
|
||||
)
|
||||
expect(trackMock).toHaveBeenCalledWith('app_starred_orca', {
|
||||
source: 'star_nag',
|
||||
@@ -663,6 +734,30 @@ describe('StarNagService', () => {
|
||||
expect(ui.starNagCompleted).toBe(true)
|
||||
})
|
||||
|
||||
it('records failed direct star after dismissal without clearing the cooldown or re-showing', 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(false)
|
||||
await expect(starPromise).resolves.toBe(false)
|
||||
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'direct_star_failed', mode: 'gh' })
|
||||
)
|
||||
expect(ui.starNagCompleted).toBeUndefined()
|
||||
expect(ui.starNagDeferredUntil).toBeGreaterThan(Date.now())
|
||||
expect(window.webContents.send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clears the in-flight direct-star guard after thrown attempts so the user can retry', async () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
@@ -699,21 +794,21 @@ describe('StarNagService', () => {
|
||||
|
||||
const starAttempts = trackMock.mock.calls.filter(
|
||||
([name, payload]) =>
|
||||
name === 'star_nag_outcome' &&
|
||||
(payload as { outcome?: string }).outcome === 'star_attempted'
|
||||
name === 'star_nag_outcome' && (payload as { outcome?: string }).outcome === 'star_clicked'
|
||||
)
|
||||
expect(starAttempts).toHaveLength(1)
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'star_failed', mode: 'gh' })
|
||||
expect.objectContaining({ outcome: 'direct_star_failed', mode: 'gh' })
|
||||
)
|
||||
|
||||
getIpcHandler('star-nag:openWeb')()
|
||||
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'opened_web', mode: 'web' })
|
||||
expect.objectContaining({ outcome: 'opened_repo', mode: 'web' })
|
||||
)
|
||||
expect(ui.starNagCompleted).toBe(true)
|
||||
expect(ui.starNagDeferredUntil).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,13 +11,14 @@ import {
|
||||
type StarNagPromptMode,
|
||||
type StarNagPromptSource
|
||||
} from '../../shared/star-nag-telemetry'
|
||||
import type { EventProps } from '../../shared/telemetry-events'
|
||||
import {
|
||||
type StarNagPromptContext,
|
||||
type StarNagPromptSession,
|
||||
trackStarNagSessionOutcome
|
||||
} from './prompt-session-telemetry'
|
||||
|
||||
type StarNagPromptContext = Omit<EventProps<'star_nag_outcome'>, 'outcome' | 'next_threshold'>
|
||||
|
||||
type StarNagPromptSession = StarNagPromptContext & {
|
||||
starAttemptPromise?: Promise<boolean>
|
||||
}
|
||||
const STAR_NAG_COOLDOWN_DAYS = 3
|
||||
const STAR_NAG_COOLDOWN_MS = STAR_NAG_COOLDOWN_DAYS * 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Service that decides when to prompt the user with the "star Orca on GitHub"
|
||||
@@ -73,6 +74,7 @@ export class StarNagService {
|
||||
|
||||
registerIpcHandlers(): void {
|
||||
ipcMain.handle('star-nag:dismiss', () => this.dismiss())
|
||||
ipcMain.handle('star-nag:later', () => this.defer('later'))
|
||||
ipcMain.handle('star-nag:complete', () => this.markCompleted())
|
||||
ipcMain.handle('star-nag:disable', () => this.disable())
|
||||
ipcMain.handle('star-nag:openWeb', () => this.openWeb())
|
||||
@@ -108,6 +110,9 @@ export class StarNagService {
|
||||
if (ui.starNagCompleted) {
|
||||
return
|
||||
}
|
||||
if (this.isCooldownActive(ui.starNagDeferredUntil)) {
|
||||
return
|
||||
}
|
||||
// Guard against drift: if the version changed since last boot but we
|
||||
// haven't rehydrated yet (e.g. in-process update on Linux AppImage), fix
|
||||
// the baseline before evaluating the threshold so we don't instantly fire.
|
||||
@@ -210,27 +215,13 @@ export class StarNagService {
|
||||
|
||||
private trackOutcome(
|
||||
outcome: StarNagOutcome,
|
||||
options: { mode?: StarNagPromptMode; nextThreshold?: number } = {}
|
||||
options: { mode?: StarNagPromptMode; nextThreshold?: number; cooldownDays?: 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 })
|
||||
})
|
||||
trackStarNagSessionOutcome(session, outcome, options)
|
||||
}
|
||||
|
||||
private trackAlreadyStarredSuppressed(source: StarNagPromptSource): void {
|
||||
@@ -241,7 +232,7 @@ export class StarNagService {
|
||||
}
|
||||
|
||||
private logConsoleEvent(
|
||||
event: 'star_nag_shown' | 'star_nag_dismissed',
|
||||
event: 'star_nag_shown' | 'star_nag_dismissed' | 'star_nag_later',
|
||||
source: StarNagPromptSource,
|
||||
nextThreshold?: number
|
||||
): void {
|
||||
@@ -262,13 +253,15 @@ export class StarNagService {
|
||||
// ── Public actions (invoked from IPC) ─────────────────────────────
|
||||
|
||||
/**
|
||||
* User closed the notification without starring → double the threshold and
|
||||
* rebase the baseline so the next fire is "threshold more agents since this
|
||||
* dismissal" (not "threshold total since install"). This matches the
|
||||
* product intent of exponential back-off: 35 more, then 70 more, then 140
|
||||
* more, etc.
|
||||
* User closed the notification without starring → defer threshold prompts
|
||||
* for a substantial cross-version cooldown. We still maintain the legacy
|
||||
* threshold fields so historical dashboards and old builds remain coherent.
|
||||
*/
|
||||
private dismiss(): void {
|
||||
this.defer('dismissed')
|
||||
}
|
||||
|
||||
private defer(outcome: Extract<StarNagOutcome, 'dismissed' | 'later'>): void {
|
||||
const session = this.promptSession
|
||||
if (!session) {
|
||||
this.promptVisible = false
|
||||
@@ -277,11 +270,16 @@ 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.trackOutcome(outcome, { nextThreshold, cooldownDays: STAR_NAG_COOLDOWN_DAYS })
|
||||
this.logConsoleEvent(
|
||||
outcome === 'later' ? 'star_nag_later' : 'star_nag_dismissed',
|
||||
session.source,
|
||||
nextThreshold
|
||||
)
|
||||
this.store.updateUI({
|
||||
starNagNextThreshold: nextThreshold,
|
||||
starNagBaselineAgents: this.stats.getTotalAgentsSpawned()
|
||||
starNagBaselineAgents: this.stats.getTotalAgentsSpawned(),
|
||||
starNagDeferredUntil: Date.now() + STAR_NAG_COOLDOWN_MS
|
||||
})
|
||||
this.promptVisible = false
|
||||
this.promptSession = null
|
||||
@@ -293,7 +291,12 @@ export class StarNagService {
|
||||
}
|
||||
|
||||
private openWeb(): void {
|
||||
this.trackOutcome('opened_web', { mode: 'web' })
|
||||
const session = this.promptSession
|
||||
if (!session || session.openedRepoTracked) {
|
||||
return
|
||||
}
|
||||
session.openedRepoTracked = true
|
||||
trackStarNagSessionOutcome(session, 'opened_repo', { mode: 'web' })
|
||||
this.markCompleted()
|
||||
}
|
||||
|
||||
@@ -317,16 +320,16 @@ export class StarNagService {
|
||||
}
|
||||
|
||||
private async runStarOrcaAttempt(session: StarNagPromptSession): Promise<boolean> {
|
||||
this.trackSessionOutcome(session, 'star_attempted', { mode: 'gh' })
|
||||
trackStarNagSessionOutcome(session, 'star_clicked', { mode: 'gh' })
|
||||
const starred = await starOrca()
|
||||
if (!starred) {
|
||||
trackStarNagSessionOutcome(session, 'direct_star_failed', { mode: 'gh' })
|
||||
if (this.promptSession === session) {
|
||||
this.trackSessionOutcome(session, 'star_failed', { mode: 'gh' })
|
||||
session.mode = 'web'
|
||||
}
|
||||
return false
|
||||
}
|
||||
this.trackSessionOutcome(session, 'star_succeeded', { mode: 'gh' })
|
||||
trackStarNagSessionOutcome(session, 'direct_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', {
|
||||
@@ -339,12 +342,16 @@ export class StarNagService {
|
||||
|
||||
/** User successfully starred or opted out → never nag again. */
|
||||
private markCompleted(): void {
|
||||
this.store.updateUI({ starNagCompleted: true })
|
||||
this.store.updateUI({ starNagCompleted: true, starNagDeferredUntil: null })
|
||||
this.promptVisible = false
|
||||
this.promptSession = null
|
||||
this.pendingForceShow = false
|
||||
}
|
||||
|
||||
private isCooldownActive(deferredUntil: number | null | undefined): boolean {
|
||||
return typeof deferredUntil === 'number' && deferredUntil > Date.now()
|
||||
}
|
||||
|
||||
/** Dev-only entry point: skip all gating and fire the notification. */
|
||||
private forceShow(): void {
|
||||
if (this.promptVisible) {
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('validate', () => {
|
||||
|
||||
it('accepts a well-formed star_nag_outcome payload with cohort context', () => {
|
||||
const result = validate('star_nag_outcome', {
|
||||
outcome: 'opened_web',
|
||||
outcome: 'opened_repo',
|
||||
source: 'force_show',
|
||||
mode: 'web',
|
||||
threshold: 35,
|
||||
@@ -57,7 +57,7 @@ describe('validate', () => {
|
||||
it('rejects malformed star_nag_outcome payloads', () => {
|
||||
expect(
|
||||
validate('star_nag_outcome', {
|
||||
outcome: 'opened_web',
|
||||
outcome: 'opened_repo',
|
||||
source: 'force_show',
|
||||
mode: 'web',
|
||||
threshold: 35,
|
||||
@@ -68,7 +68,7 @@ describe('validate', () => {
|
||||
).toBe(false)
|
||||
expect(
|
||||
validate('star_nag_outcome', {
|
||||
outcome: 'opened_web',
|
||||
outcome: 'opened_repo',
|
||||
source: 'force_show',
|
||||
mode: 'web',
|
||||
threshold: 0,
|
||||
|
||||
@@ -1714,8 +1714,11 @@ export type PreloadApi = {
|
||||
starNag: {
|
||||
onShow: (callback: (payload?: { mode?: 'gh' | 'web' }) => void) => () => void
|
||||
dismiss: () => Promise<void>
|
||||
later: () => 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 —
|
||||
|
||||
@@ -1528,8 +1528,11 @@ const api = {
|
||||
return () => ipcRenderer.removeListener('star-nag:show', listener)
|
||||
},
|
||||
dismiss: (): Promise<void> => ipcRenderer.invoke('star-nag:dismiss'),
|
||||
later: (): Promise<void> => ipcRenderer.invoke('star-nag:later'),
|
||||
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')
|
||||
},
|
||||
|
||||
|
||||
@@ -6,16 +6,16 @@ import { useAppStore } from '../store'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const ORCA_STARGAZERS_URL = 'https://github.com/stablyai/orca/stargazers'
|
||||
const ORCA_REPO_URL = 'https://github.com/stablyai/orca'
|
||||
type StarNagMode = 'gh' | 'web'
|
||||
|
||||
/**
|
||||
* Persistent "star Orca on GitHub" notification card.
|
||||
*
|
||||
* Rendered at the bottom-right of the app (alongside UpdateCard). It is
|
||||
* intentionally non-auto-dismissing: the user must either click Star or the
|
||||
* close button. Dismissing doubles the next-trigger threshold in the main
|
||||
* process so the nag backs off exponentially.
|
||||
* intentionally non-auto-dismissing: the user must either click Star, defer,
|
||||
* confirm an existing star, or close the card. Nonterminal exits set a
|
||||
* persisted cooldown in the main process.
|
||||
*
|
||||
* Visibility is driven by the main-process 'star-nag:show' IPC event — this
|
||||
* component does no threshold math or gh-CLI checks locally.
|
||||
@@ -47,9 +47,12 @@ export function StarNagCard(): React.JSX.Element | null {
|
||||
void window.api.starNag.dismiss()
|
||||
}
|
||||
|
||||
const handleDisable = (): void => {
|
||||
const handleLater = (): void => {
|
||||
if (busy) {
|
||||
return
|
||||
}
|
||||
setVisible(false)
|
||||
void window.api.starNag.disable()
|
||||
void window.api.starNag.later()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -77,18 +80,32 @@ 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()
|
||||
if (mountedRef.current) {
|
||||
setBusy(false)
|
||||
setVisible(false)
|
||||
try {
|
||||
await window.api.shell.openUrl(ORCA_REPO_URL)
|
||||
await window.api.starNag.openWeb()
|
||||
if (mountedRef.current) {
|
||||
setVisible(false)
|
||||
}
|
||||
} catch {
|
||||
// Why: failing to open the external browser is recoverable; keep the
|
||||
// prompt available so the user can retry or choose another action.
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
const ok = await window.api.gh.starOrca('star_nag')
|
||||
if (mountedRef.current) {
|
||||
setBusy(false)
|
||||
let ok = false
|
||||
try {
|
||||
ok = await window.api.starNag.starOrca()
|
||||
} catch {
|
||||
ok = false
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
if (!ok) {
|
||||
if (mountedRef.current) {
|
||||
@@ -96,7 +113,6 @@ export function StarNagCard(): React.JSX.Element | null {
|
||||
}
|
||||
return
|
||||
}
|
||||
await window.api.starNag.complete()
|
||||
if (mountedRef.current) {
|
||||
setVisible(false)
|
||||
}
|
||||
@@ -135,7 +151,7 @@ export function StarNagCard(): React.JSX.Element | null {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.StarNagCard.30c36231c1',
|
||||
'If Orca has saved you time, a GitHub star goes a long way. It helps other developers discover the project and keeps the team motivated to ship improvements.'
|
||||
'Orca is open source. If it helped today, a GitHub star helps other developers find it.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -155,14 +171,15 @@ export function StarNagCard(): React.JSX.Element | null {
|
||||
? translate('auto.components.StarNagCard.157bb5ecbb', 'Open GitHub')
|
||||
: 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}>
|
||||
{translate('auto.components.StarNagCard.8c967b4d15', 'Not now')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={handleDisable}>
|
||||
{translate('auto.components.StarNagCard.73dfd4eb8d', "Don't ask again")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 w-full"
|
||||
onClick={handleLater}
|
||||
disabled={busy}
|
||||
>
|
||||
{translate('auto.components.StarNagCard.8c967b4d15', 'Later')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1259,7 +1259,7 @@
|
||||
"92b0f9d921": "is authenticated and try again.",
|
||||
"cd8c34aac1": "gh",
|
||||
"cf82170065": "Could not star the repo. Make sure",
|
||||
"30c36231c1": "If Orca has saved you time, a GitHub star goes a long way. It helps other developers discover the project and keeps the team motivated to ship improvements.",
|
||||
"30c36231c1": "Orca is open source. If it helped today, a GitHub star helps other developers find it.",
|
||||
"b5e685e4d9": "Dismiss",
|
||||
"5f6df21046": "Enjoying Orca?",
|
||||
"2d67b6c849": "Star on GitHub",
|
||||
@@ -1268,7 +1268,7 @@
|
||||
"996bf76e46": "Open GitHub to finish in your browser.",
|
||||
"d32015fec7": "Opening...",
|
||||
"157bb5ecbb": "Open GitHub",
|
||||
"8c967b4d15": "Not now",
|
||||
"8c967b4d15": "Later",
|
||||
"73dfd4eb8d": "Don't ask again"
|
||||
},
|
||||
"TaskPage": {
|
||||
|
||||
@@ -1259,7 +1259,7 @@
|
||||
"92b0f9d921": "está autenticado y vuelve a intentarlo.",
|
||||
"cd8c34aac1": "gh",
|
||||
"cf82170065": "No se pudo destacar el repo. Cerciorarse",
|
||||
"30c36231c1": "Si Orca te ha ahorrado tiempo, una estrella de GitHub es de gran ayuda. Ayuda a otros desarrolladores a descubrir el proyecto y mantiene al equipo motivado para implementar mejoras.",
|
||||
"30c36231c1": "Orca es de código abierto. Si te ayudó hoy, una estrella en GitHub ayuda a otros desarrolladores a encontrarlo.",
|
||||
"b5e685e4d9": "Despedir",
|
||||
"5f6df21046": "¿Disfrutando de Orca?",
|
||||
"2d67b6c849": "Dar estrella en GitHub",
|
||||
@@ -1268,7 +1268,7 @@
|
||||
"996bf76e46": "Abre GitHub para terminar en tu navegador.",
|
||||
"d32015fec7": "Abriendo...",
|
||||
"157bb5ecbb": "Abrir GitHub",
|
||||
"8c967b4d15": "Ahora no",
|
||||
"8c967b4d15": "Más tarde",
|
||||
"73dfd4eb8d": "No volver a preguntar"
|
||||
},
|
||||
"TaskPage": {
|
||||
|
||||
@@ -1259,7 +1259,7 @@
|
||||
"92b0f9d921": "認証されているので、再試行してください。",
|
||||
"cd8c34aac1": "gh",
|
||||
"cf82170065": "repo にスターを付けることができませんでした。確認する",
|
||||
"30c36231c1": "Orca のおかげで時間を節約できたなら、GitHub のスターは大いに役立ちます。これは、他の開発者がプロジェクトを発見するのに役立ち、チームの改善を出荷する意欲を維持します。",
|
||||
"30c36231c1": "Orca はオープンソースです。今日役に立ったなら、GitHub のスターが他の開発者の発見につながります。",
|
||||
"b5e685e4d9": "閉じる",
|
||||
"5f6df21046": "Orcaを楽しんでいますか?",
|
||||
"2d67b6c849": "GitHub でスターを付ける",
|
||||
@@ -1268,7 +1268,7 @@
|
||||
"996bf76e46": "ブラウザーでGitHubを開いて完了してください。",
|
||||
"d32015fec7": "開いています...",
|
||||
"157bb5ecbb": "GitHubを開く",
|
||||
"8c967b4d15": "今はしない",
|
||||
"8c967b4d15": "あとで",
|
||||
"73dfd4eb8d": "今後表示しない"
|
||||
},
|
||||
"TaskPage": {
|
||||
|
||||
@@ -1259,7 +1259,7 @@
|
||||
"92b0f9d921": "인증된 후 다시 시도하세요.",
|
||||
"cd8c34aac1": "gh",
|
||||
"cf82170065": "repo 에 스타를 표시할 수 없습니다. 다음을 확인하세요",
|
||||
"30c36231c1": "Orca를 통해 시간을 절약했다면 GitHub 스타가 큰 도움이 될 것입니다. 이는 다른 개발자가 프로젝트를 발견하는 데 도움이 되고 팀이 개선 사항을 출시하도록 동기를 부여합니다.",
|
||||
"30c36231c1": "Orca는 오픈 소스입니다. 오늘 도움이 되었다면 GitHub 스타가 다른 개발자가 Orca를 찾는 데 도움이 됩니다.",
|
||||
"b5e685e4d9": "닫기",
|
||||
"5f6df21046": "Orca를 즐기고 있나요?",
|
||||
"2d67b6c849": "GitHub에서 별표 주기",
|
||||
|
||||
@@ -1259,7 +1259,7 @@
|
||||
"92b0f9d921": "已通过身份验证并重试。",
|
||||
"cd8c34aac1": "gh",
|
||||
"cf82170065": "无法为该 repo 加注星标。确保",
|
||||
"30c36231c1": "如果 Orca 节省了您的时间,那么 GitHub 之星就会大有帮助。它可以帮助其他开发人员发现该项目并保持团队进行改进的动力。",
|
||||
"30c36231c1": "Orca 是开源项目。如果它今天帮到了你,GitHub 星标会帮助其他开发者找到它。",
|
||||
"b5e685e4d9": "关闭",
|
||||
"5f6df21046": "喜欢 Orca 吗?",
|
||||
"2d67b6c849": "在 GitHub 上加星标",
|
||||
@@ -1268,7 +1268,7 @@
|
||||
"996bf76e46": "在浏览器中打开 GitHub 以完成操作。",
|
||||
"d32015fec7": "正在打开...",
|
||||
"157bb5ecbb": "打开 GitHub",
|
||||
"8c967b4d15": "暂时不要",
|
||||
"8c967b4d15": "稍后",
|
||||
"73dfd4eb8d": "不再询问"
|
||||
},
|
||||
"TaskPage": {
|
||||
|
||||
@@ -2,8 +2,14 @@ import { z } from 'zod'
|
||||
|
||||
export const STAR_NAG_OUTCOMES = [
|
||||
'shown',
|
||||
'star_clicked',
|
||||
'direct_star_succeeded',
|
||||
'direct_star_failed',
|
||||
'opened_repo',
|
||||
'later',
|
||||
'dismissed',
|
||||
'disabled',
|
||||
// Historical values kept valid so older rows and validators do not drift.
|
||||
'star_attempted',
|
||||
'star_succeeded',
|
||||
'star_failed',
|
||||
@@ -11,7 +17,14 @@ export const STAR_NAG_OUTCOMES = [
|
||||
'already_starred_suppressed'
|
||||
] as const
|
||||
|
||||
export const STAR_NAG_PROMPT_SOURCES = ['threshold', 'force_show'] as const
|
||||
export const STAR_NAG_PROMPT_SOURCES = [
|
||||
'threshold',
|
||||
'force_show',
|
||||
'agent_value_moment',
|
||||
'update_flow',
|
||||
'settings',
|
||||
'legacy_threshold'
|
||||
] 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
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ describe('star_nag_outcome schema', () => {
|
||||
expect(isCohortExtendedEvent('star_nag_outcome')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts next_threshold only as a positive integer', () => {
|
||||
it('accepts next_threshold only as a positive integer for deferrals', () => {
|
||||
expect(
|
||||
eventSchemas.star_nag_outcome.safeParse({
|
||||
...valid,
|
||||
@@ -185,6 +185,13 @@ describe('star_nag_outcome schema', () => {
|
||||
next_threshold: 70
|
||||
}).success
|
||||
).toBe(true)
|
||||
expect(
|
||||
eventSchemas.star_nag_outcome.safeParse({
|
||||
...valid,
|
||||
outcome: 'later',
|
||||
next_threshold: 70
|
||||
}).success
|
||||
).toBe(true)
|
||||
expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, next_threshold: 0 }).success).toBe(
|
||||
false
|
||||
)
|
||||
@@ -197,6 +204,45 @@ describe('star_nag_outcome schema', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts cooldown_days only for deferrals', () => {
|
||||
expect(
|
||||
eventSchemas.star_nag_outcome.safeParse({
|
||||
...valid,
|
||||
outcome: 'later',
|
||||
cooldown_days: 30
|
||||
}).success
|
||||
).toBe(true)
|
||||
expect(
|
||||
eventSchemas.star_nag_outcome.safeParse({
|
||||
...valid,
|
||||
outcome: 'dismissed',
|
||||
cooldown_days: 30
|
||||
}).success
|
||||
).toBe(true)
|
||||
expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, cooldown_days: 0 }).success).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
eventSchemas.star_nag_outcome.safeParse({
|
||||
...valid,
|
||||
outcome: 'opened_repo',
|
||||
cooldown_days: 30
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts new star nag action outcomes', () => {
|
||||
for (const outcome of [
|
||||
'star_clicked',
|
||||
'direct_star_succeeded',
|
||||
'direct_star_failed',
|
||||
'opened_repo',
|
||||
'later'
|
||||
]) {
|
||||
expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, outcome }).success).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unknown outcome source mode and bucket values', () => {
|
||||
expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, outcome: 'ignored' }).success).toBe(
|
||||
false
|
||||
|
||||
@@ -368,13 +368,30 @@ const starNagOutcomeEventSchema = z
|
||||
agents_since_baseline: z.number().int().nonnegative(),
|
||||
agents_since_baseline_bucket: starNagAgentBucketSchema,
|
||||
nth_repo_added: nthRepoAddedSchema,
|
||||
next_threshold: z.number().int().positive().optional()
|
||||
next_threshold: z.number().int().positive().optional(),
|
||||
cooldown_days: 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']
|
||||
})
|
||||
.refine(
|
||||
(payload) =>
|
||||
payload.next_threshold === undefined ||
|
||||
payload.outcome === 'dismissed' ||
|
||||
payload.outcome === 'later',
|
||||
{
|
||||
message: 'next_threshold is only valid for later or dismissed outcomes',
|
||||
path: ['next_threshold']
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(payload) =>
|
||||
payload.cooldown_days === undefined ||
|
||||
payload.outcome === 'later' ||
|
||||
payload.outcome === 'dismissed',
|
||||
{
|
||||
message: 'cooldown_days is only valid for later or dismissed outcomes',
|
||||
path: ['cooldown_days']
|
||||
}
|
||||
)
|
||||
|
||||
const workspaceCreatedSchema = z
|
||||
.object({
|
||||
|
||||
@@ -3107,6 +3107,9 @@ export type PersistedUIState = {
|
||||
/** Once the user has starred Orca (from any entry point) we permanently
|
||||
* suppress the nag — no further thresholds, no notifications. */
|
||||
starNagCompleted?: boolean
|
||||
/** Timestamp until which nonterminal dismissals suppress threshold prompts.
|
||||
* Force-show bypasses this for dev/testing. */
|
||||
starNagDeferredUntil?: number | null
|
||||
trustedOrcaHooks?: PersistedTrustedOrcaHooks
|
||||
setupScriptPromptDismissedRepoIds?: string[]
|
||||
/** Whether the experimental pet overlay is currently visible. Separate
|
||||
|
||||
Reference in New Issue
Block a user