Make the GitHub star prompt clearer (#5538)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-06-16 18:12:55 -07:00
committed by GitHub
co-authored by Orca
parent 692e0b0ab9
commit fa557a0cd9
18 changed files with 374 additions and 94 deletions
@@ -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 })
})
}
+106 -11
View File
@@ -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()
})
})
+43 -36
View File
@@ -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) {
+3 -3
View File
@@ -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,
+3
View File
@@ -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 —
+3
View File
@@ -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')
},
+41 -24
View File
@@ -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>
+2 -2
View File
@@ -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": {
+2 -2
View File
@@ -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": {
+2 -2
View File
@@ -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": {
+1 -1
View File
@@ -1259,7 +1259,7 @@
"92b0f9d921": "인증된 후 다시 시도하세요.",
"cd8c34aac1": "gh",
"cf82170065": "repo 에 스타를 표시할 수 없습니다. 다음을 확인하세요",
"30c36231c1": "Orca를 통해 시간을 절약했다면 GitHub 스타가 큰 도움이 될 것입니다. 이는 다른 개발자가 프로젝트를 발견하는 데 도움이 되고 팀이 개선 사항을 출시하도록 동기를 부여합니다.",
"30c36231c1": "Orca는 오픈 소스입니다. 오늘 도움이 되었다면 GitHub 스타가 다른 개발자가 Orca를 찾는 데 도움이 됩니다.",
"b5e685e4d9": "닫기",
"5f6df21046": "Orca를 즐기고 있나요?",
"2d67b6c849": "GitHub에서 별표 주기",
+2 -2
View File
@@ -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": {
+14 -1
View File
@@ -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
+47 -1
View File
@@ -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
+22 -5
View File
@@ -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({
+3
View File
@@ -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