diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index b2d00320206..5a2d8869afd 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index bd1a40ecaf8..3f14216a59f 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -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(), diff --git a/src/main/star-nag/prompt-session-telemetry.ts b/src/main/star-nag/prompt-session-telemetry.ts new file mode 100644 index 00000000000..f4aaa24c034 --- /dev/null +++ b/src/main/star-nag/prompt-session-telemetry.ts @@ -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 +} + +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 }) + }) +} diff --git a/src/main/star-nag/service.test.ts b/src/main/star-nag/service.test.ts index 9dacb3d9dca..a6ba32cefb6 100644 --- a/src/main/star-nag/service.test.ts +++ b/src/main/star-nag/service.test.ts @@ -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() + 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() }) }) diff --git a/src/main/star-nag/service.ts b/src/main/star-nag/service.ts index fd8b211a1a0..93ac58f7a1c 100644 --- a/src/main/star-nag/service.ts +++ b/src/main/star-nag/service.ts @@ -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, 'outcome' | 'next_threshold'> - -type StarNagPromptSession = StarNagPromptContext & { - starAttemptPromise?: Promise -} +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): 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 { - 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) { diff --git a/src/main/telemetry/validator.test.ts b/src/main/telemetry/validator.test.ts index d3273516332..031df8d208f 100644 --- a/src/main/telemetry/validator.test.ts +++ b/src/main/telemetry/validator.test.ts @@ -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, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 3e056345ff0..005566ce01e 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1714,8 +1714,11 @@ export type PreloadApi = { starNag: { onShow: (callback: (payload?: { mode?: 'gh' | 'web' }) => void) => () => void dismiss: () => Promise + later: () => Promise complete: () => Promise disable: () => Promise + openWeb: () => Promise + starOrca: () => Promise forceShow: () => Promise } /** Fire-and-forget track. Loose typing at the IPC boundary on purpose — diff --git a/src/preload/index.ts b/src/preload/index.ts index a4311c66271..76d162a0c1d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1528,8 +1528,11 @@ const api = { return () => ipcRenderer.removeListener('star-nag:show', listener) }, dismiss: (): Promise => ipcRenderer.invoke('star-nag:dismiss'), + later: (): Promise => ipcRenderer.invoke('star-nag:later'), complete: (): Promise => ipcRenderer.invoke('star-nag:complete'), disable: (): Promise => ipcRenderer.invoke('star-nag:disable'), + openWeb: (): Promise => ipcRenderer.invoke('star-nag:openWeb'), + starOrca: (): Promise => ipcRenderer.invoke('star-nag:starOrca'), forceShow: (): Promise => ipcRenderer.invoke('star-nag:forceShow') }, diff --git a/src/renderer/src/components/StarNagCard.tsx b/src/renderer/src/components/StarNagCard.tsx index 4f6a4f9eaf4..7bbcfd2be0d 100644 --- a/src/renderer/src/components/StarNagCard.tsx +++ b/src/renderer/src/components/StarNagCard.tsx @@ -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 {

{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.' )}

@@ -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')} -
- - -
+ diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index cdd31034a31..66135cecaa5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 24b7e056262..ab93e512253 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c2d14925c46..2961d8f0e48 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5f697559f12..7a359f88c1c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1259,7 +1259,7 @@ "92b0f9d921": "인증된 후 다시 시도하세요.", "cd8c34aac1": "gh", "cf82170065": "repo 에 스타를 표시할 수 없습니다. 다음을 확인하세요", - "30c36231c1": "Orca를 통해 시간을 절약했다면 GitHub 스타가 큰 도움이 될 것입니다. 이는 다른 개발자가 프로젝트를 발견하는 데 도움이 되고 팀이 개선 사항을 출시하도록 동기를 부여합니다.", + "30c36231c1": "Orca는 오픈 소스입니다. 오늘 도움이 되었다면 GitHub 스타가 다른 개발자가 Orca를 찾는 데 도움이 됩니다.", "b5e685e4d9": "닫기", "5f6df21046": "Orca를 즐기고 있나요?", "2d67b6c849": "GitHub에서 별표 주기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index ebdbe7c77b1..f1168b3e472 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": { diff --git a/src/shared/star-nag-telemetry.ts b/src/shared/star-nag-telemetry.ts index 7f995d0c6f9..0b7bd3b997c 100644 --- a/src/shared/star-nag-telemetry.ts +++ b/src/shared/star-nag-telemetry.ts @@ -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 diff --git a/src/shared/telemetry-events.test.ts b/src/shared/telemetry-events.test.ts index f895ab957e3..7b65e72b66c 100644 --- a/src/shared/telemetry-events.test.ts +++ b/src/shared/telemetry-events.test.ts @@ -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 diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 007ec2c154c..cfc3f1ce5c8 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -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({ diff --git a/src/shared/types.ts b/src/shared/types.ts index 87c0551ffc6..e2bb744c5da 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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