Celebrate onboarding and agent wins with GitHub star prompts (#5642)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-17 22:21:32 -07:00 committed by GitHub
parent 4a3ec0232f
commit fd9e79a77a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 2026 additions and 204 deletions

View File

@ -97,6 +97,7 @@ vi.mock('./rate-limit', () => ({
}))
import {
checkOrcaStarred,
getPRComments,
getPRForBranch,
getRepoUpstream,
@ -112,6 +113,45 @@ import {
_resetMergeQueueCacheForTests
} from './client'
describe('checkOrcaStarred', () => {
beforeEach(() => {
execFileAsyncMock.mockReset()
acquireMock.mockReset()
releaseMock.mockReset()
acquireMock.mockResolvedValue(undefined)
})
it('returns true only for an included successful GitHub response', async () => {
execFileAsyncMock.mockResolvedValueOnce({ stdout: 'HTTP/2.0 204 No Content\r\n', stderr: '' })
await expect(checkOrcaStarred()).resolves.toBe(true)
expect(execFileAsyncMock).toHaveBeenCalledWith(
'gh',
['api', '--include', 'user/starred/stablyai/orca'],
{ encoding: 'utf-8' }
)
})
it('returns true for an HTTP 200 starred response', async () => {
execFileAsyncMock.mockResolvedValueOnce({ stdout: 'HTTP/2.0 200 OK\r\n', stderr: '' })
await expect(checkOrcaStarred()).resolves.toBe(true)
})
it('returns false for GitHub 404 not starred responses', async () => {
execFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 404: Not Found'))
await expect(checkOrcaStarred()).resolves.toBe(false)
})
it('returns null when gh exits successfully without response headers', async () => {
execFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' })
await expect(checkOrcaStarred()).resolves.toBe(null)
})
})
describe('getPRForBranch', () => {
beforeEach(() => {
execFileAsyncMock.mockReset()

View File

@ -236,8 +236,16 @@ function isNoPullRequestError(err: unknown): boolean {
export async function checkOrcaStarred(): Promise<boolean | null> {
await acquire()
try {
await execFileAsync('gh', ['api', `user/starred/${ORCA_REPO}`], { encoding: 'utf-8' })
return true
const { stdout, stderr } = await execFileAsync(
'gh',
['api', '--include', `user/starred/${ORCA_REPO}`],
{ encoding: 'utf-8' }
)
const response = `${stdout ?? ''}\n${stderr ?? ''}`
if (/HTTP\/\S+\s+(?:200|204)\b/.test(response)) {
return true
}
return null
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
// 404 means the user hasn't starred — the only expected "no" answer

View File

@ -1008,13 +1008,21 @@ describe('registerGitHubHandlers', () => {
registerGitHubHandlers(store as never, stats as never)
for (const source of ['star_nag', 'settings', 'landing'] as const) {
for (const source of [
'star_nag',
'agent_value_moment',
'onboarding_completed',
'settings',
'landing'
] as const) {
await expect(handlers['gh:starOrca'](null, source)).resolves.toBe(true)
}
expect(trackMock).toHaveBeenCalledTimes(3)
expect(trackMock).toHaveBeenCalledTimes(5)
expect(trackMock.mock.calls.map(([, props]) => props)).toEqual([
{ source: 'star_nag', nth_repo_added: undefined },
{ source: 'agent_value_moment', nth_repo_added: undefined },
{ source: 'onboarding_completed', nth_repo_added: undefined },
{ source: 'settings', nth_repo_added: undefined },
{ source: 'landing', nth_repo_added: undefined }
])

View File

@ -301,6 +301,7 @@ describe('client UI RPC methods', () => {
makeRequest('ui.set', {
starNagBaselineAgents: 10,
starNagAppVersion: '1.2.3',
starNagAgentValueMomentAppVersion: '1.2.3',
starNagNextThreshold: 70,
starNagCompleted: true,
starNagDeferredUntil: null
@ -341,6 +342,7 @@ describe('client UI RPC methods', () => {
const forbiddenPayloads = [
{ starNagBaselineAgents: 10 },
{ starNagAppVersion: '1.2.3' },
{ starNagAgentValueMomentAppVersion: '1.2.3' },
{ starNagNextThreshold: 70 },
{ starNagCompleted: true },
{ starNagDeferredUntil: null }

View File

@ -0,0 +1,109 @@
import { app } from 'electron'
import { checkOrcaStarred } from '../github/client'
import type { Store } from '../persistence'
import type { StarNagPromptMode } from '../../shared/star-nag-telemetry'
export type AgentValueMomentPreparation =
| { status: 'ready'; mode: StarNagPromptMode }
| { status: 'skipped' }
type StarNagAgentValueMomentDeps = {
store: Store
isEvaluating: () => boolean
setEvaluating: (value: boolean) => void
isPromptVisible: () => boolean
isCooldownActive: (deferredUntil: number | null | undefined) => boolean
markCompleted: () => void
trackAlreadyStarredSuppressed: () => void
broadcastShow: (mode: StarNagPromptMode) => boolean
}
export class StarNagAgentValueMoment {
private readonly deps: StarNagAgentValueMomentDeps
private pendingMode: StarNagPromptMode | null = null
constructor(deps: StarNagAgentValueMomentDeps) {
this.deps = deps
}
async prepare(): Promise<AgentValueMomentPreparation> {
if (this.wasConsumed() || this.deps.isEvaluating()) {
return { status: 'skipped' }
}
const ui = this.deps.store.getUI()
if (
ui.starNagCompleted ||
this.deps.isCooldownActive(ui.starNagDeferredUntil) ||
this.deps.isPromptVisible()
) {
// Why: each app version gets at most one completion-moment attempt, even if
// an existing prompt/cooldown blocks the extra agent-finished trigger.
this.consumeVersion()
return { status: 'skipped' }
}
this.deps.setEvaluating(true)
try {
const starred = await checkOrcaStarred()
if (this.deps.store.getUI().starNagCompleted) {
return { status: 'skipped' }
}
if (starred === null) {
this.pendingMode = 'web'
return { status: 'ready', mode: 'web' }
}
if (starred) {
this.deps.trackAlreadyStarredSuppressed()
this.deps.markCompleted()
// Why: already-starred users should not be rechecked on every agent
// completion after this version has been resolved.
this.consumeVersion()
return { status: 'skipped' }
}
this.pendingMode = 'gh'
return { status: 'ready', mode: 'gh' }
} finally {
this.deps.setEvaluating(false)
}
}
showPrepared(): void {
const mode = this.pendingMode
if (!mode || this.wasConsumed()) {
return
}
const ui = this.deps.store.getUI()
if (
ui.starNagCompleted ||
this.deps.isCooldownActive(ui.starNagDeferredUntil) ||
this.deps.isPromptVisible() ||
this.deps.isEvaluating()
) {
// Why: the prepared moment can go stale before display; consuming prevents
// repeated prompts from the same app-version completion moment.
this.consumeVersion()
this.pendingMode = null
return
}
const delivered = this.deps.broadcastShow(mode)
if (delivered || this.deps.store.getUI().starNagCompleted) {
// Why: once a prompt is delivered or completion wins the race, this app
// version's agent-value moment has been spent.
this.consumeVersion()
}
if (delivered) {
this.pendingMode = null
}
}
clear(): void {
this.pendingMode = null
}
private consumeVersion(): void {
this.deps.store.updateUI({ starNagAgentValueMomentAppVersion: app.getVersion() })
}
private wasConsumed(): boolean {
return this.deps.store.getUI().starNagAgentValueMomentAppVersion === app.getVersion()
}
}

View File

@ -0,0 +1,9 @@
import type { AppStarSource } from '../../shared/gh-star-source'
import type { StarNagPromptSource } from '../../shared/star-nag-telemetry'
export function getStarNagAppStarSource(source: StarNagPromptSource): AppStarSource {
if (source === 'agent_value_moment' || source === 'onboarding_completed') {
return source
}
return 'star_nag'
}

View File

@ -0,0 +1,31 @@
import { app } from 'electron'
import { STAR_NAG_INITIAL_THRESHOLD } from '../../shared/constants'
import type { StarNagPromptSource } from '../../shared/star-nag-telemetry'
import type { Store } from '../persistence'
import type { StatsCollector } from '../stats/collector'
type StarNagConsoleEvent = 'star_nag_shown' | 'star_nag_dismissed' | 'star_nag_later'
export function logStarNagConsoleEvent(
store: Store,
stats: StatsCollector,
event: StarNagConsoleEvent,
source: StarNagPromptSource,
nextThreshold?: number
): void {
const ui = store.getUI()
const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD
const agentsSinceBaseline = Math.max(
0,
stats.getTotalAgentsSpawned() - (ui.starNagBaselineAgents ?? 0)
)
console.info({
event,
app_version: app.getVersion(),
threshold,
agents_since_baseline: agentsSinceBaseline,
source,
...(nextThreshold === undefined ? {} : { next_threshold: nextThreshold })
})
}

View File

@ -0,0 +1,23 @@
import { starOrca } from '../github/client'
import { track } from '../telemetry/client'
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
import { type StarNagPromptSession, trackStarNagSessionOutcome } from './prompt-session-telemetry'
import { getStarNagAppStarSource } from './app-star-source'
export async function runStarNagDirectStarAttempt(session: StarNagPromptSession): Promise<boolean> {
trackStarNagSessionOutcome(session, 'star_clicked', { mode: 'gh' })
const starred = await starOrca()
if (!starred) {
trackStarNagSessionOutcome(session, 'direct_star_failed', { mode: 'gh' })
session.mode = 'web'
return false
}
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', {
source: getStarNagAppStarSource(session.source),
...getCohortAtEmit()
})
return true
}

View File

@ -0,0 +1,28 @@
import type { Store } from '../persistence'
type OnboardingCompletedDeps = {
store: Store
isCooldownActive: (deferredUntil: number | null | undefined) => boolean
isEvaluating: () => boolean
queueAfterEvaluation: () => void
isPromptVisible: () => boolean
clearVisiblePrompt: () => void
showToast: () => Promise<boolean>
}
export async function handleStarNagOnboardingCompleted(
deps: OnboardingCompletedDeps
): Promise<void> {
const ui = deps.store.getUI()
const cooldownActive = deps.isCooldownActive(ui.starNagDeferredUntil)
if (ui.starNagCompleted || cooldownActive || deps.isEvaluating()) {
if (!ui.starNagCompleted && !cooldownActive && deps.isEvaluating()) {
deps.queueAfterEvaluation()
}
return
}
if (deps.isPromptVisible()) {
deps.clearVisiblePrompt()
}
await deps.showToast()
}

View File

@ -0,0 +1,32 @@
import { STAR_NAG_INITIAL_THRESHOLD } from '../../shared/constants'
import {
bucketStarNagAgentsSinceBaseline,
type StarNagPromptMode,
type StarNagPromptSource
} from '../../shared/star-nag-telemetry'
import type { Store } from '../persistence'
import type { StatsCollector } from '../stats/collector'
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
import type { StarNagPromptContext } from './prompt-session-telemetry'
export function createStarNagPromptContext(
store: Store,
stats: StatsCollector,
source: StarNagPromptSource,
mode: StarNagPromptMode
): StarNagPromptContext {
const ui = store.getUI()
const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD
const agentsSinceBaseline = Math.max(
0,
stats.getTotalAgentsSpawned() - (ui.starNagBaselineAgents ?? 0)
)
return {
source,
mode,
threshold,
agents_since_baseline: agentsSinceBaseline,
agents_since_baseline_bucket: bucketStarNagAgentsSinceBaseline(agentsSinceBaseline),
...getCohortAtEmit()
}
}

View File

@ -176,7 +176,8 @@ describe('StarNagService', () => {
expect(window.webContents.send).toHaveBeenCalledTimes(1)
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
mode: 'gh'
mode: 'gh',
surface: 'card'
})
expect(consoleInfoMock).toHaveBeenCalledTimes(1)
expect(consoleInfoMock).toHaveBeenCalledWith({
@ -199,7 +200,8 @@ describe('StarNagService', () => {
await flushAsyncWork()
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
mode: 'web'
mode: 'web',
surface: 'card'
})
expect(trackMock).toHaveBeenCalledWith('star_nag_outcome', {
outcome: 'shown',
@ -249,7 +251,8 @@ describe('StarNagService', () => {
await flushAsyncWork()
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
mode: 'gh'
mode: 'gh',
surface: 'card'
})
expect(consoleInfoMock).toHaveBeenCalledWith({
event: 'star_nag_shown',
@ -299,6 +302,198 @@ describe('StarNagService', () => {
expect(trackMock).not.toHaveBeenCalled()
})
it('shows agent value moment prompts once per app version after eligibility passes', async () => {
const window = createWindow()
browserWindowMock.getAllWindows.mockReturnValue([window])
const { service, ui } = createHarness()
service.registerIpcHandlers()
await expect(getIpcHandler('star-nag:agentValueMoment')()).resolves.toEqual({
status: 'ready',
mode: 'gh'
})
await getIpcHandler('star-nag:showAgentValueMoment')()
getIpcHandler('star-nag:dismiss')()
await expect(getIpcHandler('star-nag:agentValueMoment')()).resolves.toEqual({
status: 'skipped'
})
expect(window.webContents.send).toHaveBeenCalledTimes(1)
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
mode: 'gh',
surface: 'card'
})
expect(ui.starNagAgentValueMomentAppVersion).toBe('1.2.3')
expect(trackMock).toHaveBeenCalledWith(
'star_nag_outcome',
expect.objectContaining({ outcome: 'shown', source: 'agent_value_moment' })
)
})
it('consumes agent value moment for cooldown suppression without showing later in the same version', async () => {
const window = createWindow()
browserWindowMock.getAllWindows.mockReturnValue([window])
const { service, ui } = createHarness({
starNagDeferredUntil: Date.now() + 3 * 24 * 60 * 60 * 1000
})
service.registerIpcHandlers()
await expect(getIpcHandler('star-nag:agentValueMoment')()).resolves.toEqual({
status: 'skipped'
})
ui.starNagDeferredUntil = null
await expect(getIpcHandler('star-nag:agentValueMoment')()).resolves.toEqual({
status: 'skipped'
})
expect(window.webContents.send).not.toHaveBeenCalled()
expect(ui.starNagAgentValueMomentAppVersion).toBe('1.2.3')
})
it('does not consume agent value moment when no window can receive the card', async () => {
const { service, ui } = createHarness()
service.registerIpcHandlers()
await expect(getIpcHandler('star-nag:agentValueMoment')()).resolves.toEqual({
status: 'ready',
mode: 'gh'
})
await getIpcHandler('star-nag:showAgentValueMoment')()
expect(ui.starNagAgentValueMomentAppVersion).toBeUndefined()
const window = createWindow()
browserWindowMock.getAllWindows.mockReturnValue([window])
await getIpcHandler('star-nag:showAgentValueMoment')()
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
mode: 'gh',
surface: 'card'
})
expect(ui.starNagAgentValueMomentAppVersion).toBe('1.2.3')
})
it('shows onboarding completed prompts on the toast surface', async () => {
const window = createWindow()
browserWindowMock.getAllWindows.mockReturnValue([window])
const { service } = createHarness()
service.registerIpcHandlers()
await getIpcHandler('star-nag:onboardingCompleted')()
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
mode: 'gh',
surface: 'toast'
})
expect(trackMock).toHaveBeenCalledWith(
'star_nag_outcome',
expect.objectContaining({ outcome: 'shown', source: 'onboarding_completed' })
)
})
it('lets onboarding completed supersede an already visible threshold card', async () => {
const window = createWindow()
browserWindowMock.getAllWindows.mockReturnValue([window])
const { service, ui } = createHarness()
service.registerIpcHandlers()
getIpcHandler('star-nag:forceShow')()
await getIpcHandler('star-nag:onboardingCompleted')()
expect(window.webContents.send).toHaveBeenNthCalledWith(1, 'star-nag:show', {
mode: 'gh',
surface: 'card'
})
expect(window.webContents.send).toHaveBeenNthCalledWith(2, 'star-nag:hide')
expect(window.webContents.send).toHaveBeenNthCalledWith(3, 'star-nag:show', {
mode: 'gh',
surface: 'toast'
})
expect(trackMock).toHaveBeenCalledWith(
'star_nag_outcome',
expect.objectContaining({ outcome: 'shown', source: 'onboarding_completed' })
)
expect(ui.starNagCompleted).toBeUndefined()
})
it('hides a superseded visible card when onboarding completion detects an existing star', async () => {
const window = createWindow()
browserWindowMock.getAllWindows.mockReturnValue([window])
checkOrcaStarredMock.mockResolvedValueOnce(true)
const { service, ui } = createHarness()
service.registerIpcHandlers()
getIpcHandler('star-nag:forceShow')()
await getIpcHandler('star-nag:onboardingCompleted')()
expect(window.webContents.send).toHaveBeenNthCalledWith(1, 'star-nag:show', {
mode: 'gh',
surface: 'card'
})
expect(window.webContents.send).toHaveBeenNthCalledWith(2, 'star-nag:hide')
expect(window.webContents.send).toHaveBeenCalledTimes(2)
expect(ui.starNagCompleted).toBe(true)
})
it('queues onboarding completed while a threshold star check is in flight', async () => {
const window = createWindow()
browserWindowMock.getAllWindows.mockReturnValue([window])
const deferredStarCheck = createDeferred<boolean | null>()
checkOrcaStarredMock.mockReturnValueOnce(deferredStarCheck.promise).mockResolvedValueOnce(null)
const { service, emitAgentStarted, ui } = createHarness()
service.start()
service.registerIpcHandlers()
emitAgentStarted(45)
await getIpcHandler('star-nag:onboardingCompleted')()
expect(window.webContents.send).not.toHaveBeenCalled()
deferredStarCheck.resolve(false)
await flushAsyncWork()
expect(window.webContents.send).toHaveBeenNthCalledWith(1, 'star-nag:show', {
mode: 'gh',
surface: 'card'
})
expect(window.webContents.send).toHaveBeenNthCalledWith(2, 'star-nag:hide')
expect(window.webContents.send).toHaveBeenNthCalledWith(3, 'star-nag:show', {
mode: 'web',
surface: 'toast'
})
expect(trackMock).toHaveBeenCalledWith(
'star_nag_outcome',
expect.objectContaining({ outcome: 'shown', source: 'onboarding_completed', mode: 'web' })
)
expect(ui.starNagCompleted).toBeUndefined()
})
it('queues onboarding completed while an agent value moment star check is in flight', async () => {
const window = createWindow()
browserWindowMock.getAllWindows.mockReturnValue([window])
const deferredStarCheck = createDeferred<boolean | null>()
checkOrcaStarredMock.mockReturnValueOnce(deferredStarCheck.promise).mockResolvedValueOnce(null)
const { service, ui } = createHarness()
service.registerIpcHandlers()
const agentValueMoment = getIpcHandler('star-nag:agentValueMoment')()
await getIpcHandler('star-nag:onboardingCompleted')()
deferredStarCheck.resolve(false)
await expect(agentValueMoment).resolves.toEqual({ status: 'ready', mode: 'gh' })
await flushAsyncWork()
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
mode: 'web',
surface: 'toast'
})
expect(trackMock).toHaveBeenCalledWith(
'star_nag_outcome',
expect.objectContaining({ outcome: 'shown', source: 'onboarding_completed', mode: 'web' })
)
expect(ui.starNagCompleted).toBeUndefined()
})
it('allows force_show to bypass the persisted cooldown', () => {
const window = createWindow()
browserWindowMock.getAllWindows.mockReturnValue([window])
@ -310,7 +505,8 @@ describe('StarNagService', () => {
getIpcHandler('star-nag:forceShow')()
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
mode: 'gh'
mode: 'gh',
surface: 'card'
})
})
@ -355,7 +551,8 @@ describe('StarNagService', () => {
forceShow()
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
mode: 'gh'
mode: 'gh',
surface: 'card'
})
expect(consoleInfoMock).toHaveBeenCalledWith({
event: 'star_nag_shown',
@ -480,7 +677,8 @@ describe('StarNagService', () => {
expect(window.webContents.send).toHaveBeenCalledTimes(1)
expect(consoleInfoMock).toHaveBeenCalledTimes(1)
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
mode: 'web'
mode: 'web',
surface: 'card'
})
expect(consoleInfoMock).toHaveBeenCalledWith({
event: 'star_nag_shown',
@ -616,8 +814,9 @@ describe('StarNagService', () => {
'star_nag_outcome',
expect.objectContaining({ outcome: 'opened_repo', mode: 'web' })
)
expect(opened.ui.starNagCompleted).toBe(true)
expect(opened.ui.starNagDeferredUntil).toBeNull()
expect(opened.ui.starNagCompleted).toBeUndefined()
expect(opened.ui.starNagDeferredUntil).toBeGreaterThan(Date.now())
expect(opened.ui.starNagNextThreshold).toBe(STAR_NAG_INITIAL_THRESHOLD * 2)
})
it('emits opened_repo at most once for one prompt session', () => {
@ -682,6 +881,43 @@ describe('StarNagService', () => {
})
})
it('uses the source moment for confirmed direct-star success telemetry', async () => {
const window = createWindow()
browserWindowMock.getAllWindows.mockReturnValue([window])
const { service } = createHarness()
service.registerIpcHandlers()
await getIpcHandler('star-nag:onboardingCompleted')()
await getIpcHandler('star-nag:starOrca')()
expect(trackMock).toHaveBeenCalledWith('app_starred_orca', {
source: 'onboarding_completed',
nth_repo_added: 3
})
})
it('does not emit confirmed star telemetry for web fallback handoff', async () => {
const window = createWindow()
browserWindowMock.getAllWindows.mockReturnValue([window])
checkOrcaStarredMock.mockResolvedValue(null)
const { service, ui } = createHarness()
service.registerIpcHandlers()
await getIpcHandler('star-nag:onboardingCompleted')()
getIpcHandler('star-nag:openWeb')()
expect(trackMock).toHaveBeenCalledWith(
'star_nag_outcome',
expect.objectContaining({ outcome: 'opened_repo', source: 'onboarding_completed' })
)
expect(trackMock).not.toHaveBeenCalledWith(
'app_starred_orca',
expect.objectContaining({ source: 'onboarding_completed' })
)
expect(ui.starNagCompleted).toBeUndefined()
expect(ui.starNagDeferredUntil).toBeGreaterThan(Date.now())
})
it('uses fresh cohort context for canonical app_starred_orca success telemetry', async () => {
const window = createWindow()
browserWindowMock.getAllWindows.mockReturnValue([window])
@ -808,7 +1044,7 @@ describe('StarNagService', () => {
'star_nag_outcome',
expect.objectContaining({ outcome: 'opened_repo', mode: 'web' })
)
expect(ui.starNagCompleted).toBe(true)
expect(ui.starNagDeferredUntil).toBeNull()
expect(ui.starNagCompleted).toBeUndefined()
expect(ui.starNagDeferredUntil).toBeGreaterThan(Date.now())
})
})

View File

@ -1,34 +1,27 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import { BrowserWindow, ipcMain } from 'electron'
import { STAR_NAG_INITIAL_THRESHOLD } from '../../shared/constants'
import { checkOrcaStarred, starOrca } from '../github/client'
import { checkOrcaStarred } from '../github/client'
import type { Store } from '../persistence'
import type { StatsCollector } from '../stats/collector'
import { track } from '../telemetry/client'
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
import {
bucketStarNagAgentsSinceBaseline,
type StarNagOutcome,
type StarNagPromptMode,
type StarNagPromptSource
import type {
StarNagOutcome,
StarNagPromptMode,
StarNagPromptSource
} from '../../shared/star-nag-telemetry'
import {
type StarNagPromptContext,
type StarNagPromptSession,
trackStarNagSessionOutcome
} from './prompt-session-telemetry'
import { type StarNagPromptSession, trackStarNagSessionOutcome } from './prompt-session-telemetry'
import { createStarNagPromptContext } from './prompt-context'
import { logStarNagConsoleEvent } from './console-events'
import { StarNagAgentValueMoment, type AgentValueMomentPreparation } from './agent-value-moment'
import { deferAfterStarNagWebHandoff } from './web-handoff'
import { runStarNagDirectStarAttempt } from './direct-star-attempt'
import { handleStarNagOnboardingCompleted } from './onboarding-completed'
import { ensureStarNagBaseline, shouldShowStarNagThresholdPrompt } from './threshold-trigger'
const STAR_NAG_COOLDOWN_DAYS = 3
const STAR_NAG_COOLDOWN_MS = STAR_NAG_COOLDOWN_DAYS * 24 * 60 * 60 * 1000
type StarNagSurface = 'card' | 'toast'
/**
* Service that decides when to prompt the user with the "star Orca on GitHub"
* notification. Counts agents spawned since the current app version was first
* seen; crosses a doubling threshold (default 35 70 140 ) to fire the
* renderer notification via 'star-nag:show'.
*
* State lives in PersistedUIState so it survives restarts alongside the rest
* of the UI preferences (dismissed update versions, etc).
*/
export class StarNagService {
private store: Store
private stats: StatsCollector
@ -45,23 +38,32 @@ export class StarNagService {
// resolving.
private evaluating = false
private pendingForceShow = false
private pendingOnboardingCompleted = false
// Why: dismissal backoff and action telemetry must use the prompt context
// that was delivered, not whatever threshold/source happens to be current
// when the renderer later reports a user action.
private promptSession: StarNagPromptSession | null = null
private agentValueMoment: StarNagAgentValueMoment
constructor(store: Store, stats: StatsCollector) {
this.store = store
this.stats = stats
this.agentValueMoment = new StarNagAgentValueMoment({
store,
isEvaluating: () => this.evaluating,
setEvaluating: (value) => {
this.setEvaluating(value)
},
isPromptVisible: () => this.promptVisible,
isCooldownActive: (deferredUntil) => this.isCooldownActive(deferredUntil),
markCompleted: () => this.markCompleted(),
trackAlreadyStarredSuppressed: () => this.trackAlreadyStarredSuppressed('agent_value_moment'),
broadcastShow: (mode) => this.broadcastShow('agent_value_moment', mode)
})
}
start(): void {
// Why: capture the baseline eagerly on first boot after an update so the
// "agents since update" counter doesn't include pre-update spawns. We do
// this here instead of waiting for the next agent_start so that a brand
// new install with a pre-existing stats file (unusual, but possible via
// copy of userData) starts from a sensible baseline.
this.ensureBaseline()
ensureStarNagBaseline(this.store, this.stats)
this.disposeStatsListener = this.stats.onAgentStarted((total) => {
this.handleAgentSpawned(total)
})
@ -80,61 +82,37 @@ export class StarNagService {
ipcMain.handle('star-nag:openWeb', () => this.openWeb())
ipcMain.handle('star-nag:starOrca', () => this.starOrcaFromNag())
ipcMain.handle('star-nag:forceShow', () => this.forceShow())
ipcMain.handle('star-nag:agentValueMoment', () => this.prepareAgentValueMoment())
ipcMain.handle('star-nag:showAgentValueMoment', () => this.showPreparedAgentValueMoment())
ipcMain.handle('star-nag:onboardingCompleted', () => this.onboardingCompleted())
}
// ── State helpers ─────────────────────────────────────────────────
private ensureBaseline(): void {
const ui = this.store.getUI()
const currentVersion = app.getVersion()
if (ui.starNagAppVersion === currentVersion && ui.starNagBaselineAgents != null) {
return
}
// Why: reset both the baseline and the threshold so the user gets a fresh
// nag countdown after each update. Past dismissal state is intentionally
// discarded — shipping new value is the whole reason we bother asking
// again. `starNagCompleted` is preserved so we never re-ask someone who
// already starred.
this.store.updateUI({
starNagAppVersion: currentVersion,
starNagBaselineAgents: this.stats.getTotalAgentsSpawned(),
starNagNextThreshold: STAR_NAG_INITIAL_THRESHOLD
})
}
private handleAgentSpawned(total: number): void {
if (this.promptVisible || this.evaluating) {
return
}
const ui = this.store.getUI()
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.
const currentVersion = app.getVersion()
if (ui.starNagAppVersion !== currentVersion) {
this.ensureBaseline()
return
}
const baseline = ui.starNagBaselineAgents ?? total
const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD
const sinceBaseline = total - baseline
if (sinceBaseline < threshold) {
if (
!shouldShowStarNagThresholdPrompt({
store: this.store,
stats: this.stats,
total,
promptVisible: this.promptVisible,
evaluating: this.evaluating,
isCooldownActive: (deferredUntil) => this.isCooldownActive(deferredUntil)
})
) {
return
}
void this.maybeShow('threshold')
}
private async maybeShow(source: StarNagPromptSource): Promise<void> {
private async maybeShow(
source: StarNagPromptSource,
surface: StarNagSurface = 'card'
): Promise<boolean> {
if (this.promptVisible || this.evaluating) {
return
return false
}
this.evaluating = true
this.setEvaluating(true)
try {
// Why: checkOrcaStarred lets us skip users who already starred outside
// the app. When gh cannot tell us, keep the prompt available but route
@ -143,29 +121,35 @@ export class StarNagService {
const starred = await checkOrcaStarred()
if (this.store.getUI().starNagCompleted) {
this.pendingForceShow = false
return
return false
}
if (starred === null) {
this.broadcastShow(source, 'web')
return
return this.broadcastShow(source, 'web', surface)
}
if (starred) {
this.trackAlreadyStarredSuppressed(source)
// Already starred somewhere — lock in the permanent suppression so we
// stop recomputing thresholds on every spawn.
this.markCompleted()
return
return false
}
if (this.promptVisible) {
return
return false
}
this.broadcastShow(source, 'gh')
return this.broadcastShow(source, 'gh', surface)
} finally {
this.evaluating = false
this.setEvaluating(false)
this.flushPendingForceShow()
}
}
private setEvaluating(value: boolean): void {
this.evaluating = value
if (!value) {
this.flushPendingOnboardingCompleted()
}
}
private flushPendingForceShow(): void {
if (!this.pendingForceShow || this.evaluating) {
return
@ -177,39 +161,39 @@ export class StarNagService {
this.broadcastShow('force_show', 'gh')
}
private broadcastShow(source: StarNagPromptSource, mode: StarNagPromptMode): boolean {
private flushPendingOnboardingCompleted(): void {
if (!this.pendingOnboardingCompleted || this.evaluating) {
return
}
this.pendingOnboardingCompleted = false
void this.onboardingCompleted()
}
private broadcastShow(
source: StarNagPromptSource,
mode: StarNagPromptMode,
surface: StarNagSurface = 'card'
): boolean {
const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed())
if (!win) {
this.promptVisible = false
this.promptSession = null
return false
}
const context = this.createPromptContext(source, mode)
win.webContents.send('star-nag:show', { mode })
const context = createStarNagPromptContext(this.store, this.stats, source, mode)
win.webContents.send('star-nag:show', { mode, surface })
this.promptVisible = true
this.promptSession = context
this.trackOutcome('shown')
this.logConsoleEvent('star_nag_shown', source)
logStarNagConsoleEvent(this.store, this.stats, 'star_nag_shown', source)
return true
}
private createPromptContext(
source: StarNagPromptSource,
mode: StarNagPromptMode
): StarNagPromptContext {
const ui = this.store.getUI()
const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD
const agentsSinceBaseline = Math.max(
0,
this.stats.getTotalAgentsSpawned() - (ui.starNagBaselineAgents ?? 0)
)
return {
source,
mode,
threshold,
agents_since_baseline: agentsSinceBaseline,
agents_since_baseline_bucket: bucketStarNagAgentsSinceBaseline(agentsSinceBaseline),
...getCohortAtEmit()
private broadcastHide(): void {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send('star-nag:hide')
}
}
}
@ -226,31 +210,45 @@ export class StarNagService {
private trackAlreadyStarredSuppressed(source: StarNagPromptSource): void {
track('star_nag_outcome', {
...this.createPromptContext(source, 'gh'),
...createStarNagPromptContext(this.store, this.stats, source, 'gh'),
outcome: 'already_starred_suppressed'
})
}
private logConsoleEvent(
event: 'star_nag_shown' | 'star_nag_dismissed' | 'star_nag_later',
source: StarNagPromptSource,
nextThreshold?: number
): void {
const ui = this.store.getUI()
const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD
const agentsSinceBaseline = this.stats.getTotalAgentsSpawned() - (ui.starNagBaselineAgents ?? 0)
// ── Public actions (invoked from IPC) ─────────────────────────────
console.info({
event,
app_version: app.getVersion(),
threshold,
agents_since_baseline: agentsSinceBaseline,
source,
...(nextThreshold === undefined ? {} : { next_threshold: nextThreshold })
private async prepareAgentValueMoment(): Promise<AgentValueMomentPreparation> {
return this.agentValueMoment.prepare()
}
private showPreparedAgentValueMoment(): void {
// Why: renderer re-confirms "not typing / no active agent" after the slow
// gh check before invoking this show step.
this.agentValueMoment.showPrepared()
}
private async onboardingCompleted(): Promise<void> {
await handleStarNagOnboardingCompleted({
store: this.store,
isCooldownActive: (deferredUntil) => this.isCooldownActive(deferredUntil),
isEvaluating: () => this.evaluating,
queueAfterEvaluation: () => {
this.pendingOnboardingCompleted = true
},
isPromptVisible: () => this.promptVisible,
clearVisiblePrompt: () => this.clearVisiblePromptForOnboarding(),
showToast: () => this.maybeShow('onboarding_completed', 'toast')
})
}
// ── Public actions (invoked from IPC) ─────────────────────────────
private clearVisiblePromptForOnboarding(): void {
// Why: onboarding completion is a stronger app-level value moment than a
// threshold card that may have fired behind the wizard.
this.promptVisible = false
this.promptSession = null
this.agentValueMoment.clear()
this.broadcastHide()
}
/**
* User closed the notification without starring defer threshold prompts
@ -271,7 +269,9 @@ export class StarNagService {
const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD
const nextThreshold = threshold * 2
this.trackOutcome(outcome, { nextThreshold, cooldownDays: STAR_NAG_COOLDOWN_DAYS })
this.logConsoleEvent(
logStarNagConsoleEvent(
this.store,
this.stats,
outcome === 'later' ? 'star_nag_later' : 'star_nag_dismissed',
session.source,
nextThreshold
@ -297,7 +297,11 @@ export class StarNagService {
}
session.openedRepoTracked = true
trackStarNagSessionOutcome(session, 'opened_repo', { mode: 'web' })
this.markCompleted()
// Why: opening GitHub is only a handoff, not verified star success. Keep the
// ask quiet for the normal cooldown, but do not set starNagCompleted.
deferAfterStarNagWebHandoff(this.store, this.stats, STAR_NAG_COOLDOWN_MS)
this.promptVisible = false
this.promptSession = null
}
private async starOrcaFromNag(): Promise<boolean> {
@ -320,24 +324,11 @@ export class StarNagService {
}
private async runStarOrcaAttempt(session: StarNagPromptSession): Promise<boolean> {
trackStarNagSessionOutcome(session, 'star_clicked', { mode: 'gh' })
const starred = await starOrca()
if (!starred) {
trackStarNagSessionOutcome(session, 'direct_star_failed', { mode: 'gh' })
if (this.promptSession === session) {
session.mode = 'web'
}
return false
const starred = await runStarNagDirectStarAttempt(session)
if (starred) {
this.markCompleted()
}
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', {
source: 'star_nag',
...getCohortAtEmit()
})
this.markCompleted()
return true
return starred
}
/** User successfully starred or opted out → never nag again. */
@ -346,6 +337,8 @@ export class StarNagService {
this.promptVisible = false
this.promptSession = null
this.pendingForceShow = false
this.pendingOnboardingCompleted = false
this.agentValueMoment.clear()
}
private isCooldownActive(deferredUntil: number | null | undefined): boolean {

View File

@ -0,0 +1,45 @@
import { app } from 'electron'
import { STAR_NAG_INITIAL_THRESHOLD } from '../../shared/constants'
import type { Store } from '../persistence'
import type { StatsCollector } from '../stats/collector'
type ThresholdPromptInput = {
store: Store
stats: StatsCollector
total: number
promptVisible: boolean
evaluating: boolean
isCooldownActive: (deferredUntil: number | null | undefined) => boolean
}
export function ensureStarNagBaseline(store: Store, stats: StatsCollector): void {
const ui = store.getUI()
const currentVersion = app.getVersion()
if (ui.starNagAppVersion === currentVersion && ui.starNagBaselineAgents != null) {
return
}
// Why: after an update, completed users stay suppressed but everyone else
// gets a fresh countdown from the current agent total.
store.updateUI({
starNagAppVersion: currentVersion,
starNagBaselineAgents: stats.getTotalAgentsSpawned(),
starNagNextThreshold: STAR_NAG_INITIAL_THRESHOLD
})
}
export function shouldShowStarNagThresholdPrompt(input: ThresholdPromptInput): boolean {
if (input.promptVisible || input.evaluating) {
return false
}
const ui = input.store.getUI()
if (ui.starNagCompleted || input.isCooldownActive(ui.starNagDeferredUntil)) {
return false
}
if (ui.starNagAppVersion !== app.getVersion()) {
ensureStarNagBaseline(input.store, input.stats)
return false
}
const baseline = ui.starNagBaselineAgents ?? input.total
const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD
return input.total - baseline >= threshold
}

View File

@ -0,0 +1,17 @@
import { STAR_NAG_INITIAL_THRESHOLD } from '../../shared/constants'
import type { Store } from '../persistence'
import type { StatsCollector } from '../stats/collector'
export function deferAfterStarNagWebHandoff(
store: Store,
stats: StatsCollector,
cooldownMs: number
): void {
const ui = store.getUI()
const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD
store.updateUI({
starNagNextThreshold: threshold * 2,
starNagBaselineAgents: stats.getTotalAgentsSpawned(),
starNagDeferredUntil: Date.now() + cooldownMs
})
}

View File

@ -1721,7 +1721,10 @@ export type PreloadApi = {
listTransitions: (args: { key: string; siteId?: string }) => Promise<JiraTransition[]>
}
starNag: {
onShow: (callback: (payload?: { mode?: 'gh' | 'web' }) => void) => () => void
onShow: (
callback: (payload?: { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' }) => void
) => () => void
onHide: (callback: () => void) => () => void
dismiss: () => Promise<void>
later: () => Promise<void>
complete: () => Promise<void>
@ -1729,6 +1732,9 @@ export type PreloadApi = {
openWeb: () => Promise<void>
starOrca: () => Promise<boolean>
forceShow: () => Promise<void>
agentValueMoment: () => Promise<{ status: 'ready'; mode: 'gh' | 'web' } | { status: 'skipped' }>
showAgentValueMoment: () => Promise<void>
onboardingCompleted: () => Promise<void>
}
/** Fire-and-forget track. Loose typing at the IPC boundary on purpose
* the main-side validator is the single enforcement point. Renderer call

View File

@ -1526,21 +1526,33 @@ const api = {
},
starNag: {
onShow: (callback: (payload?: { mode?: 'gh' | 'web' }) => void): (() => void) => {
onShow: (
callback: (payload?: { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' }) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
payload?: { mode?: 'gh' | 'web' }
payload?: { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' }
): void => callback(payload)
ipcRenderer.on('star-nag:show', listener)
return () => ipcRenderer.removeListener('star-nag:show', listener)
},
onHide: (callback: () => void): (() => void) => {
const listener = (): void => callback()
ipcRenderer.on('star-nag:hide', listener)
return () => ipcRenderer.removeListener('star-nag:hide', 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')
forceShow: (): Promise<void> => ipcRenderer.invoke('star-nag:forceShow'),
agentValueMoment: (): Promise<
{ status: 'ready'; mode: 'gh' | 'web' } | { status: 'skipped' }
> => ipcRenderer.invoke('star-nag:agentValueMoment'),
showAgentValueMoment: (): Promise<void> => ipcRenderer.invoke('star-nag:showAgentValueMoment'),
onboardingCompleted: (): Promise<void> => ipcRenderer.invoke('star-nag:onboardingCompleted')
},
// Why: telemetry uses a loose untyped surface at the preload boundary on

View File

@ -49,6 +49,8 @@ import { dispatchWindowCloseRequest } from './components/window-close-request-co
import { useSystemPrefersDark } from './components/terminal-pane/use-system-prefers-dark'
import RightSidebar from './components/right-sidebar'
import { StarNagCard } from './components/StarNagCard'
import { StarNagAgentValueMomentObserver } from './components/star-nag/StarNagAgentValueMomentObserver'
import { StarNagToastHost } from './components/star-nag/StarNagToastHost'
import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface'
import { ZoomOverlay } from './components/ZoomOverlay'
import { onOnboardingReopened } from './components/onboarding/show-onboarding-event'
@ -2429,6 +2431,15 @@ function App(): React.JSX.Element {
>
<StarNagCard />
</RecoverableRenderErrorBoundary>
<RecoverableRenderErrorBoundary
boundaryId="overlay.star-nag-toast"
surface="overlay"
resetKey={activeView}
compact
>
<StarNagToastHost />
</RecoverableRenderErrorBoundary>
<StarNagAgentValueMomentObserver />
{/* Why: the existing-user opt-in banner mounts at App root so it
renders once per renderer session, not per view. It gates
internally on the cohort markers populated by the migration,

View File

@ -116,7 +116,6 @@ function GitHubStarButton({ hasRepos }: { hasRepos: boolean }): React.JSX.Elemen
}
if (state === 'web-fallback') {
await window.api.shell.openUrl(ORCA_STARGAZERS_URL)
await window.api.starNag.complete()
return
}
if (state !== 'not-starred') {

View File

@ -39,7 +39,13 @@ export function ShortcutKeyCombo({
return (
<span
className={cn('inline-flex items-center gap-1', className)}
title={doubleTap && keys.length > 0 ? translate("auto.components.ShortcutKeyCombo.07eb4985a1", "Double-tap {{value0}}", { value0: keys[0] }) : undefined}
title={
doubleTap && keys.length > 0
? translate('auto.components.ShortcutKeyCombo.07eb4985a1', 'Double-tap {{value0}}', {
value0: keys[0]
})
: undefined
}
>
{keys.map((key, index) => (
<React.Fragment key={`${key}-${index}`}>

View File

@ -0,0 +1,99 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { StarNagCard } from './StarNagCard'
type ShowPayload = { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' }
type ShowCallback = (payload?: ShowPayload) => void
type StarNagApi = {
onShow: (callback: ShowCallback) => () => void
onHide: (callback: () => void) => () => void
dismiss: ReturnType<typeof vi.fn>
later: ReturnType<typeof vi.fn>
openWeb: ReturnType<typeof vi.fn>
starOrca: ReturnType<typeof vi.fn>
}
type ShellApi = {
openUrl: ReturnType<typeof vi.fn>
}
function setApi(api: { starNag: StarNagApi; shell: ShellApi }): void {
;(window as unknown as { api: typeof api }).api = api
}
function renderCard(): { root: Root; container: HTMLDivElement } {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
act(() => {
root.render(<StarNagCard />)
})
return { root, container }
}
describe('StarNagCard', () => {
let root: Root | null = null
let container: HTMLDivElement | null = null
let showCallback: ShowCallback | null = null
let starNag: StarNagApi
let shell: ShellApi
beforeEach(() => {
showCallback = null
starNag = {
onShow: vi.fn((callback: ShowCallback) => {
showCallback = callback
return vi.fn()
}),
onHide: vi.fn(() => vi.fn()),
dismiss: vi.fn().mockResolvedValue(undefined),
later: vi.fn().mockResolvedValue(undefined),
openWeb: vi.fn().mockResolvedValue(undefined),
starOrca: vi.fn().mockResolvedValue(true)
}
shell = {
openUrl: vi.fn().mockResolvedValue(undefined)
}
setApi({ starNag, shell })
})
afterEach(() => {
if (root) {
act(() => root?.unmount())
}
container?.remove()
root = null
container = null
})
it('switches to the explicit GitHub fallback when direct starring fails', async () => {
starNag.starOrca.mockResolvedValueOnce(false)
;({ root, container } = renderCard())
act(() => showCallback?.({ mode: 'gh', surface: 'card' }))
expect(container.textContent).toContain('Star on GitHub')
const initialButton = Array.from(container.querySelectorAll('button')).find((candidate) =>
candidate.textContent?.includes('Star on GitHub')
)
expect(initialButton?.className).toContain('bg-amber-400/15')
expect(initialButton?.parentElement?.className).toContain('flex gap-2')
await act(async () => {
initialButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(starNag.starOrca).toHaveBeenCalledTimes(1)
expect(shell.openUrl).not.toHaveBeenCalled()
expect(starNag.openWeb).not.toHaveBeenCalled()
expect(container.textContent).toContain('Open GitHub')
const fallbackButton = Array.from(container.querySelectorAll('button')).find((candidate) =>
candidate.textContent?.includes('Open GitHub')
)
expect(fallbackButton?.className).toContain('bg-amber-400/15')
expect(fallbackButton?.parentElement?.textContent).toContain('Later')
})
})

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { ExternalLink, Star, X } from 'lucide-react'
import { Card } from './ui/card'
import { Button } from './ui/button'
@ -33,19 +33,35 @@ export function StarNagCard(): React.JSX.Element | null {
const updateCardVisible = updateStatus.state !== 'idle' && updateStatus.state !== 'not-available'
useEffect(() => {
return window.api.starNag.onShow((payload) => {
const unsubscribeShow = window.api.starNag.onShow((payload) => {
if (payload?.surface && payload.surface !== 'card') {
setBusy(false)
setVisible(false)
return
}
setMode(payload?.mode === 'web' ? 'web' : 'gh')
setVisible(true)
})
const unsubscribeHide = window.api.starNag.onHide(() => {
setBusy(false)
setVisible(false)
})
return () => {
unsubscribeShow()
unsubscribeHide()
}
}, [])
const handleClose = (): void => {
const handleClose = useCallback((): void => {
if (busy) {
return
}
setVisible(false)
// Why: fire-and-forget. If persisting the dismissal fails the worst case
// is we re-fire the same threshold on next launch — not worth blocking
// the close animation on.
void window.api.starNag.dismiss()
}
}, [busy])
const handleLater = (): void => {
if (busy) {
@ -66,29 +82,37 @@ export function StarNagCard(): React.JSX.Element | null {
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
// eslint-disable-next-line react-hooks/exhaustive-deps -- handleClose closes
// over stable refs; re-binding on each render is unnecessary.
}, [visible])
}, [handleClose, visible])
if (!visible) {
return null
}
const primaryActionClass =
'min-w-0 flex-1 gap-1.5 border-amber-400/60 bg-amber-400/15 text-amber-800 hover:bg-amber-400/25 dark:text-amber-100'
const handleStar = async (): Promise<void> => {
if (busy) {
return
}
if (mode === 'web') {
setBusy(true)
const openGithubFallback = async (): Promise<boolean> => {
try {
await window.api.shell.openUrl(ORCA_REPO_URL)
await window.api.starNag.openWeb()
if (mountedRef.current) {
setVisible(false)
}
return true
} catch {
// Why: failing to open the external browser is recoverable; keep the
// prompt available so the user can retry or choose another action.
return false
}
}
if (mode === 'web') {
setBusy(true)
try {
await openGithubFallback()
} finally {
if (mountedRef.current) {
setBusy(false)
@ -102,20 +126,24 @@ export function StarNagCard(): React.JSX.Element | null {
ok = await window.api.starNag.starOrca()
} catch {
ok = false
}
try {
if (!ok) {
// Why: preflight chooses whether direct starring should be offered. If
// the later star call fails, let the user choose the browser handoff.
if (mountedRef.current) {
setMode('web')
}
return
}
if (mountedRef.current) {
setVisible(false)
}
} finally {
if (mountedRef.current) {
setBusy(false)
}
}
if (!ok) {
if (mountedRef.current) {
setMode('web')
}
return
}
if (mountedRef.current) {
setVisible(false)
}
}
return (
@ -142,6 +170,7 @@ export function StarNagCard(): React.JSX.Element | null {
size="icon"
className="size-7 shrink-0"
onClick={handleClose}
disabled={busy}
aria-label={translate('auto.components.StarNagCard.b5e685e4d9', 'Dismiss')}
>
<X className="size-3.5" />
@ -155,31 +184,37 @@ export function StarNagCard(): React.JSX.Element | null {
)}
</p>
<Button
variant="default"
size="sm"
onClick={() => void handleStar()}
disabled={busy}
className="mt-0.5 w-full gap-1.5"
>
{mode === 'web' ? <ExternalLink className="size-3.5" /> : <Star className="size-3.5" />}
{busy
? mode === 'web'
? translate('auto.components.StarNagCard.d32015fec7', 'Opening...')
: translate('auto.components.StarNagCard.af3c9bbb37', 'Starring…')
: mode === 'web'
? translate('auto.components.StarNagCard.157bb5ecbb', 'Open GitHub')
: translate('auto.components.StarNagCard.2d67b6c849', 'Star on GitHub')}
</Button>
<Button
variant="secondary"
size="sm"
className="h-7 w-full"
onClick={handleLater}
disabled={busy}
>
{translate('auto.components.StarNagCard.8c967b4d15', 'Later')}
</Button>
<div className="mt-0.5 flex gap-2">
<Button
variant="default"
size="sm"
onClick={() => void handleStar()}
disabled={busy}
className={primaryActionClass}
>
{mode === 'web' ? (
<ExternalLink className="size-3.5" />
) : (
<Star className="size-3.5" />
)}
{busy
? mode === 'web'
? translate('auto.components.StarNagCard.d32015fec7', 'Opening...')
: translate('auto.components.StarNagCard.af3c9bbb37', 'Starring…')
: mode === 'web'
? translate('auto.components.StarNagCard.157bb5ecbb', 'Open GitHub')
: translate('auto.components.StarNagCard.2d67b6c849', 'Star on GitHub')}
</Button>
<Button
variant="secondary"
size="sm"
className="w-[84px]"
onClick={handleLater}
disabled={busy}
>
{translate('auto.components.StarNagCard.8c967b4d15', 'Later')}
</Button>
</div>
</div>
</Card>
</div>

View File

@ -1,4 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// @vitest-environment happy-dom
import { createElement, useEffect } from 'react'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultOnboardingState } from '../../../../shared/constants'
import type { OnboardingState } from '../../../../shared/types'
const trackMock = vi.hoisted(() => vi.fn())
@ -9,12 +16,79 @@ vi.mock('@/lib/telemetry', () => ({
import {
buildCompletedOnboardingNotificationSettings,
buildOnboardingDismissedPayload,
useCloseWith,
type DismissedExtras,
trackOnboardingDismissed
} from './use-onboarding-flow-persistence'
import type { StepNumber } from './use-onboarding-flow-types'
type CloseWithCallback = (
outcome: 'completed' | 'dismissed',
checklist: Partial<OnboardingState['checklist']>,
lastStepReached: StepNumber,
completedPath?: 'open_folder' | 'clone_url' | 'add_project_modal',
dismissedExtras?: DismissedExtras
) => Promise<boolean>
function makeOnboardingState(): OnboardingState {
return {
...getDefaultOnboardingState(),
closedAt: Date.now(),
outcome: 'completed',
lastCompletedStep: 5
}
}
function setApi(api: {
onboarding: { update: ReturnType<typeof vi.fn> }
starNag: { onboardingCompleted: ReturnType<typeof vi.fn> }
}): void {
;(window as unknown as { api: typeof api }).api = api
}
function CloseWithProbe(props: { onReady: (closeWith: CloseWithCallback) => void }): null {
const closeWith = useCloseWith({
onOnboardingChange: vi.fn(),
onboardingChecklist: makeOnboardingState().checklist,
startTimeRef: { current: Date.now() },
setError: vi.fn()
})
useEffect(() => props.onReady(closeWith), [closeWith, props])
return null
}
function renderCloseWithProbe(onReady: (closeWith: CloseWithCallback) => void): {
root: Root
container: HTMLDivElement
} {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
act(() => root.render(createElement(CloseWithProbe, { onReady })))
return { root, container }
}
describe('onboarding flow persistence', () => {
let root: Root | null = null
let container: HTMLDivElement | null = null
beforeEach(() => {
vi.useFakeTimers()
trackMock.mockClear()
setApi({
onboarding: { update: vi.fn().mockResolvedValue(makeOnboardingState()) },
starNag: { onboardingCompleted: vi.fn().mockResolvedValue(undefined) }
})
})
afterEach(() => {
if (root) {
act(() => root?.unmount())
}
container?.remove()
root = null
container = null
vi.useRealTimers()
})
it('builds dismissed telemetry with the triggering advance path', () => {
@ -64,4 +138,30 @@ describe('onboarding flow persistence', () => {
customSoundVolume: 60
})
})
it('schedules the star toast after every completed close path', async () => {
let closeWith: CloseWithCallback | null = null
;({ root, container } = renderCloseWithProbe((callback) => {
closeWith = callback
}))
await act(async () => {
await closeWith?.('completed', {}, 5)
})
const api = (
window as unknown as {
api: {
starNag: { onboardingCompleted: ReturnType<typeof vi.fn> }
}
}
).api
expect(api.starNag.onboardingCompleted).not.toHaveBeenCalled()
act(() => {
vi.advanceTimersByTime(0)
})
expect(api.starNag.onboardingCompleted).toHaveBeenCalledTimes(1)
})
})

View File

@ -126,6 +126,13 @@ export function useCloseWith({
time_since_completed_ms: 0
})
}
}
if (outcome === 'completed') {
// Why: closeWith updates parent state synchronously from this hook's
// perspective, but the modal unmounts on the next React commit.
window.setTimeout(() => {
void window.api.starNag.onboardingCompleted()
}, 0)
} else if (outcome === 'dismissed') {
trackOnboardingDismissed(lastStepReached, dismissedExtras)
}

View File

@ -59,7 +59,6 @@ export function GeneralSupportSection({
if (starState === 'web-fallback') {
setStarState('opening-github')
await window.api.shell.openUrl(ORCA_STARGAZERS_URL)
await window.api.starNag.complete()
if (mountedRef.current) {
setStarState('web-fallback')
}

View File

@ -0,0 +1,186 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { useAppStore } from '@/store'
import { StarNagAgentValueMomentObserver } from './StarNagAgentValueMomentObserver'
type StarNagApi = {
agentValueMoment: ReturnType<typeof vi.fn>
showAgentValueMoment: ReturnType<typeof vi.fn>
}
function setStarNagApi(api: StarNagApi): void {
;(window as unknown as { api: { starNag: StarNagApi } }).api = { starNag: api }
}
function entry(overrides: Partial<AgentStatusEntry>): AgentStatusEntry {
return {
state: 'working',
prompt: 'Review this change',
updatedAt: 1,
stateStartedAt: 1,
paneKey: 'tab-1:leaf-1',
stateHistory: [],
...overrides
}
}
function renderObserver(): { root: Root; container: HTMLDivElement } {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
act(() => {
root.render(<StarNagAgentValueMomentObserver />)
})
return { root, container }
}
function setAgentEntries(entries: Record<string, AgentStatusEntry>): void {
act(() => {
useAppStore.setState((state) => ({
agentStatusByPaneKey: entries,
agentStatusEpoch: state.agentStatusEpoch + 1
}))
})
}
function createDeferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
} {
let resolve!: (value: T) => void
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve
})
return { promise, resolve }
}
describe('StarNagAgentValueMomentObserver', () => {
let root: Root | null = null
let container: HTMLDivElement | null = null
let agentValueMoment: ReturnType<typeof vi.fn>
let showAgentValueMoment: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.useFakeTimers()
useAppStore.setState(useAppStore.getInitialState(), true)
useAppStore.setState({ agentStatusByPaneKey: {}, agentStatusEpoch: 0 })
agentValueMoment = vi.fn().mockResolvedValue({ status: 'ready', mode: 'gh' })
showAgentValueMoment = vi.fn().mockResolvedValue(undefined)
setStarNagApi({ agentValueMoment, showAgentValueMoment })
})
afterEach(() => {
if (root) {
act(() => root?.unmount())
}
container?.remove()
root = null
container = null
useAppStore.setState(useAppStore.getInitialState(), true)
vi.useRealTimers()
})
it('asks main after a prompted non-interrupted done transition and idle window', async () => {
;({ root, container } = renderObserver())
setAgentEntries({ pane: entry({ state: 'working' }) })
setAgentEntries({ pane: entry({ state: 'done' }) })
await act(async () => {
vi.advanceTimersByTime(1200)
})
expect(agentValueMoment).toHaveBeenCalledTimes(1)
expect(showAgentValueMoment).toHaveBeenCalledTimes(1)
})
it('ignores interrupted or empty-prompt completions', () => {
;({ root, container } = renderObserver())
setAgentEntries({ pane: entry({ state: 'working', prompt: '' }) })
setAgentEntries({ pane: entry({ state: 'done', prompt: '', interrupted: true }) })
act(() => {
vi.advanceTimersByTime(2400)
})
expect(agentValueMoment).not.toHaveBeenCalled()
expect(showAgentValueMoment).not.toHaveBeenCalled()
})
it('waits for other live agents and recent typing to quiet', async () => {
;({ root, container } = renderObserver())
setAgentEntries({
done: entry({ state: 'working', paneKey: 'tab-1:leaf-1' }),
active: entry({ state: 'working', paneKey: 'tab-2:leaf-1', prompt: 'Keep working' })
})
setAgentEntries({
done: entry({ state: 'done', paneKey: 'tab-1:leaf-1' }),
active: entry({ state: 'working', paneKey: 'tab-2:leaf-1', prompt: 'Keep working' })
})
await act(async () => {
vi.advanceTimersByTime(1200)
})
expect(agentValueMoment).not.toHaveBeenCalled()
expect(showAgentValueMoment).not.toHaveBeenCalled()
setAgentEntries({
done: entry({ state: 'done', paneKey: 'tab-1:leaf-1' }),
active: entry({ state: 'done', paneKey: 'tab-2:leaf-1', prompt: 'Keep working' })
})
await act(async () => {
vi.advanceTimersByTime(600)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', ctrlKey: true }))
vi.advanceTimersByTime(600)
})
expect(agentValueMoment).not.toHaveBeenCalled()
expect(showAgentValueMoment).not.toHaveBeenCalled()
await act(async () => {
vi.advanceTimersByTime(1200)
})
expect(agentValueMoment).toHaveBeenCalledTimes(1)
expect(showAgentValueMoment).toHaveBeenCalledTimes(1)
})
it('rechecks idle after main prepares the prompt', async () => {
const preparation = createDeferred<{ status: 'ready'; mode: 'gh' }>()
agentValueMoment.mockReturnValueOnce(preparation.promise)
;({ root, container } = renderObserver())
setAgentEntries({ pane: entry({ state: 'working' }) })
setAgentEntries({ pane: entry({ state: 'done' }) })
await act(async () => {
vi.advanceTimersByTime(1200)
})
expect(agentValueMoment).toHaveBeenCalledTimes(1)
setAgentEntries({
pane: entry({ state: 'done', paneKey: 'tab-1:leaf-1' }),
active: entry({ state: 'working', paneKey: 'tab-2:leaf-1' })
})
await act(async () => {
preparation.resolve({ status: 'ready', mode: 'gh' })
})
await act(async () => {
vi.advanceTimersByTime(1200)
})
expect(showAgentValueMoment).not.toHaveBeenCalled()
setAgentEntries({
pane: entry({ state: 'done', paneKey: 'tab-1:leaf-1' }),
active: entry({ state: 'done', paneKey: 'tab-2:leaf-1' })
})
await act(async () => {
vi.advanceTimersByTime(1200)
})
expect(agentValueMoment).toHaveBeenCalledTimes(1)
expect(showAgentValueMoment).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,143 @@
import { useCallback, useEffect, useRef } from 'react'
import { useShallow } from 'zustand/react/shallow'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { useAppStore } from '@/store'
// Why: leave a short quiet window after agents finish so the prompt does not
// interrupt follow-up typing or status churn from the completed run.
const QUIET_WINDOW_MS = 1200
const CHECK_DELAY_MS = 1200
const ACTIVE_AGENT_STATES = new Set(['working', 'waiting', 'blocked'])
const NON_TYPING_MODIFIER_KEYS = new Set(['Alt', 'Control', 'Meta', 'Shift'])
type AgentStatusSnapshot = Record<string, AgentStatusEntry>
type AgentValueMomentPreparation = Awaited<ReturnType<typeof window.api.starNag.agentValueMoment>>
function hasMeaningfulPrompt(entry: AgentStatusEntry): boolean {
if (entry.prompt.trim()) {
return true
}
return entry.stateHistory.some((history) => history.prompt.trim())
}
function hasActiveAgent(entries: AgentStatusSnapshot): boolean {
return Object.values(entries).some((entry) => ACTIVE_AGENT_STATES.has(entry.state))
}
function hasSuccessfulDoneTransition(
previous: AgentStatusSnapshot,
current: AgentStatusSnapshot
): boolean {
for (const [paneKey, entry] of Object.entries(current)) {
const previousEntry = previous[paneKey]
if (
previousEntry &&
previousEntry.state !== 'done' &&
entry.state === 'done' &&
!entry.interrupted &&
hasMeaningfulPrompt(entry)
) {
return true
}
}
return false
}
function isTypingKeyEvent(event: KeyboardEvent): boolean {
return !NON_TYPING_MODIFIER_KEYS.has(event.key)
}
export function StarNagAgentValueMomentObserver(): null {
const { agentStatusByPaneKey, agentStatusEpoch } = useAppStore(
useShallow((state) => ({
agentStatusByPaneKey: state.agentStatusByPaneKey,
agentStatusEpoch: state.agentStatusEpoch
}))
)
const previousEntriesRef = useRef<AgentStatusSnapshot | null>(null)
const latestEntriesRef = useRef(agentStatusByPaneKey)
const pendingRef = useRef(false)
const requestedRef = useRef(false)
const preparationRef = useRef<AgentValueMomentPreparation | null>(null)
const lastTypingAtRef = useRef(0)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const scheduleCheck = useCallback((): void => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
timerRef.current = setTimeout(() => {
timerRef.current = null
if (!pendingRef.current || requestedRef.current) {
return
}
const elapsedSinceTyping = Date.now() - lastTypingAtRef.current
if (hasActiveAgent(latestEntriesRef.current) || elapsedSinceTyping < QUIET_WINDOW_MS) {
scheduleCheck()
return
}
void (async () => {
if (!preparationRef.current) {
preparationRef.current = await window.api.starNag.agentValueMoment()
if (preparationRef.current.status !== 'ready') {
pendingRef.current = false
requestedRef.current = true
return
}
}
const freshElapsedSinceTyping = Date.now() - lastTypingAtRef.current
if (hasActiveAgent(latestEntriesRef.current) || freshElapsedSinceTyping < QUIET_WINDOW_MS) {
scheduleCheck()
return
}
pendingRef.current = false
requestedRef.current = true
await window.api.starNag.showAgentValueMoment()
})()
}, CHECK_DELAY_MS)
}, [])
useEffect(() => {
latestEntriesRef.current = agentStatusByPaneKey
}, [agentStatusByPaneKey, agentStatusEpoch])
useEffect(() => {
const markTyping = (event: Event): void => {
if (event instanceof KeyboardEvent) {
if (!isTypingKeyEvent(event)) {
return
}
}
lastTypingAtRef.current = Date.now()
}
window.addEventListener('keydown', markTyping, true)
window.addEventListener('input', markTyping, true)
return () => {
window.removeEventListener('keydown', markTyping, true)
window.removeEventListener('input', markTyping, true)
}
}, [])
useEffect(() => {
const previousEntries = previousEntriesRef.current
previousEntriesRef.current = agentStatusByPaneKey
if (!previousEntries || requestedRef.current) {
return
}
if (!hasSuccessfulDoneTransition(previousEntries, agentStatusByPaneKey)) {
return
}
pendingRef.current = true
scheduleCheck()
}, [agentStatusByPaneKey, agentStatusEpoch, scheduleCheck])
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
}
}, [])
return null
}

View File

@ -0,0 +1,295 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { StarNagToastHost } from './StarNagToastHost'
type ShowPayload = { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' }
type ShowCallback = (payload?: ShowPayload) => void
type CustomToastOptions = {
dismissible?: boolean
onDismiss?: () => void
}
const toastDismissMock = vi.hoisted(() => vi.fn())
const customToastMock = vi.hoisted(() => vi.fn())
vi.mock('sonner', () => ({
toast: {
custom: customToastMock,
dismiss: toastDismissMock
}
}))
type StarNagApi = {
onShow: (callback: ShowCallback) => () => void
onHide: (callback: () => void) => () => void
dismiss: ReturnType<typeof vi.fn>
later: ReturnType<typeof vi.fn>
openWeb: ReturnType<typeof vi.fn>
starOrca: ReturnType<typeof vi.fn>
}
type ShellApi = {
openUrl: ReturnType<typeof vi.fn>
}
function createDeferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
} {
let resolve!: (value: T) => void
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve
})
return { promise, resolve }
}
function setApi(api: { starNag: StarNagApi; shell: ShellApi }): void {
;(window as unknown as { api: typeof api }).api = api
}
function renderHost(): { root: Root; container: HTMLDivElement } {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
act(() => {
root.render(<StarNagToastHost />)
})
return { root, container }
}
function renderToastFromCustomCall(container: HTMLElement): void {
const render = customToastMock.mock.calls[0][0] as (id: string | number) => React.ReactElement
act(() => {
createRoot(container).render(render('toast-1'))
})
}
describe('StarNagToastHost', () => {
let root: Root | null = null
let container: HTMLDivElement | null = null
let toastContainer: HTMLDivElement | null = null
let showCallback: ShowCallback | null = null
let hideCallback: (() => void) | null = null
let starNag: StarNagApi
let shell: ShellApi
let toastIdCounter = 0
beforeEach(() => {
customToastMock.mockReset()
customToastMock.mockImplementation(() => `toast-${++toastIdCounter}`)
toastDismissMock.mockReset()
showCallback = null
hideCallback = null
toastIdCounter = 0
starNag = {
onShow: vi.fn((callback: ShowCallback) => {
showCallback = callback
return vi.fn()
}),
onHide: vi.fn((callback: () => void) => {
hideCallback = callback
return vi.fn()
}),
dismiss: vi.fn().mockResolvedValue(undefined),
later: vi.fn().mockResolvedValue(undefined),
openWeb: vi.fn().mockResolvedValue(undefined),
starOrca: vi.fn().mockResolvedValue(true)
}
shell = {
openUrl: vi.fn().mockResolvedValue(undefined)
}
setApi({ starNag, shell })
})
afterEach(() => {
if (root) {
act(() => root?.unmount())
}
container?.remove()
toastContainer?.remove()
root = null
container = null
toastContainer = null
})
it('renders exact onboarding toast copy and confirms only after direct star succeeds', async () => {
;({ root, container } = renderHost())
act(() => showCallback?.({ mode: 'gh', surface: 'toast' }))
toastContainer = document.createElement('div')
document.body.appendChild(toastContainer)
renderToastFromCustomCall(toastContainer)
expect(toastContainer.textContent).toContain('Onboarding completed!')
expect(toastContainer.textContent).toContain(
'If youre enjoying Orca so far, a GitHub star helps other developers discover it.'
)
expect(toastContainer.textContent).toContain('Star on GitHub')
expect((customToastMock.mock.calls[0][1] as CustomToastOptions).dismissible).toBe(false)
const button = Array.from(toastContainer.querySelectorAll('button')).find((candidate) =>
candidate.textContent?.includes('Star on GitHub')
)
expect(button?.className).toContain('flex-1')
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(starNag.starOrca).toHaveBeenCalledTimes(1)
expect(toastContainer.textContent).toContain('Starred — thank you!')
})
it('opens GitHub fallback without calling direct star success path', async () => {
;({ root, container } = renderHost())
act(() => showCallback?.({ mode: 'web', surface: 'toast' }))
toastContainer = document.createElement('div')
document.body.appendChild(toastContainer)
renderToastFromCustomCall(toastContainer)
const button = Array.from(toastContainer.querySelectorAll('button')).find((candidate) =>
candidate.textContent?.includes('Open GitHub')
)
expect(button?.className).toContain('bg-amber-400/15')
expect(button?.className).toContain('text-amber-800')
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(shell.openUrl).toHaveBeenCalledWith('https://github.com/stablyai/orca')
expect(starNag.openWeb).toHaveBeenCalledTimes(1)
expect(starNag.starOrca).not.toHaveBeenCalled()
expect(toastContainer.textContent).toContain('GitHub opened')
expect(toastContainer.textContent).not.toContain('GitHub opened in your browser.')
})
it('switches to the explicit GitHub fallback when direct star fails', async () => {
starNag.starOrca.mockResolvedValueOnce(false)
;({ root, container } = renderHost())
act(() => showCallback?.({ mode: 'gh', surface: 'toast' }))
toastContainer = document.createElement('div')
document.body.appendChild(toastContainer)
renderToastFromCustomCall(toastContainer)
const button = Array.from(toastContainer.querySelectorAll('button')).find((candidate) =>
candidate.textContent?.includes('Star on GitHub')
)
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(starNag.starOrca).toHaveBeenCalledTimes(1)
expect(shell.openUrl).not.toHaveBeenCalled()
expect(starNag.openWeb).not.toHaveBeenCalled()
expect(toastContainer.textContent).toContain('Open GitHub')
const fallbackButton = Array.from(toastContainer.querySelectorAll('button')).find((candidate) =>
candidate.textContent?.includes('Open GitHub')
)
expect(fallbackButton?.className).toContain('bg-amber-400/15')
expect(fallbackButton?.className).toContain('text-amber-800')
expect(toastContainer.textContent).toContain('Later')
})
it('routes Later and unresolved close through existing star nag paths', () => {
;({ root, container } = renderHost())
act(() => showCallback?.({ mode: 'gh', surface: 'toast' }))
toastContainer = document.createElement('div')
document.body.appendChild(toastContainer)
renderToastFromCustomCall(toastContainer)
const laterButton = Array.from(toastContainer.querySelectorAll('button')).find((candidate) =>
candidate.textContent?.includes('Later')
)
act(() => {
laterButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(starNag.later).toHaveBeenCalledTimes(1)
const options = customToastMock.mock.calls[0][1] as CustomToastOptions
act(() => options.onDismiss?.())
expect(starNag.dismiss).not.toHaveBeenCalled()
act(() => showCallback?.({ mode: 'gh', surface: 'toast' }))
const closeOptions = customToastMock.mock.calls[1][1] as CustomToastOptions
act(() => closeOptions.onDismiss?.())
expect(starNag.dismiss).toHaveBeenCalledTimes(1)
})
it('does not allow unresolved close while the primary action is busy', async () => {
const pendingStar = createDeferred<boolean>()
starNag.starOrca.mockReturnValueOnce(pendingStar.promise)
;({ root, container } = renderHost())
act(() => showCallback?.({ mode: 'gh', surface: 'toast' }))
toastContainer = document.createElement('div')
document.body.appendChild(toastContainer)
renderToastFromCustomCall(toastContainer)
const starButton = Array.from(toastContainer.querySelectorAll('button')).find((candidate) =>
candidate.textContent?.includes('Star on GitHub')
)
await act(async () => {
starButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
const closeButton = Array.from(toastContainer.querySelectorAll('button')).find(
(candidate) => candidate.getAttribute('aria-label') === 'Dismiss'
)
expect((closeButton as HTMLButtonElement | undefined)?.disabled).toBe(true)
act(() => {
closeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
const options = customToastMock.mock.calls[0][1] as CustomToastOptions
act(() => options.onDismiss?.())
expect(toastDismissMock).not.toHaveBeenCalled()
expect(starNag.dismiss).not.toHaveBeenCalled()
await act(async () => {
pendingStar.resolve(true)
await pendingStar.promise
})
})
it('does not dismiss the current main session when replacing an active toast', () => {
;({ root, container } = renderHost())
act(() => showCallback?.({ mode: 'gh', surface: 'toast' }))
const firstOptions = customToastMock.mock.calls[0][1] as CustomToastOptions
act(() => showCallback?.({ mode: 'web', surface: 'toast' }))
const secondOptions = customToastMock.mock.calls[1][1] as CustomToastOptions
act(() => firstOptions.onDismiss?.())
expect(toastDismissMock).toHaveBeenCalledWith('toast-1')
expect(starNag.dismiss).not.toHaveBeenCalled()
act(() => showCallback?.({ mode: 'gh', surface: 'toast' }))
const thirdOptions = customToastMock.mock.calls[2][1] as CustomToastOptions
act(() => secondOptions.onDismiss?.())
expect(starNag.dismiss).not.toHaveBeenCalled()
act(() => thirdOptions.onDismiss?.())
expect(starNag.dismiss).toHaveBeenCalledTimes(1)
})
it('dismisses active toast on hide without recording a user dismissal', () => {
;({ root, container } = renderHost())
act(() => showCallback?.({ mode: 'gh', surface: 'toast' }))
act(() => hideCallback?.())
const options = customToastMock.mock.calls[0][1] as CustomToastOptions
act(() => options.onDismiss?.())
expect(toastDismissMock).toHaveBeenCalledWith('toast-1')
expect(starNag.dismiss).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,238 @@
import { useEffect, useRef, useState } from 'react'
import { Check, ExternalLink, Loader2, Star, X } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
const ORCA_REPO_URL = 'https://github.com/stablyai/orca'
type StarNagMode = 'gh' | 'web'
type StarNagToastStatus = 'idle' | 'busy' | 'starred' | 'opened'
type StarNagToastProps = {
id: string | number
mode: StarNagMode
markResolved: () => void
setDismissSuppressed: (suppressed: boolean) => void
}
function StarNagToast({
id,
mode: initialMode,
markResolved,
setDismissSuppressed
}: StarNagToastProps): React.JSX.Element {
const [mode, setMode] = useState(initialMode)
const [status, setStatus] = useState<StarNagToastStatus>('idle')
const busy = status === 'busy'
const close = (): void => {
if (busy) {
return
}
toast.dismiss(id)
}
const later = (): void => {
if (busy) {
return
}
markResolved()
void window.api.starNag.later()
toast.dismiss(id)
}
const act = async (): Promise<void> => {
if (busy || status === 'starred') {
return
}
setStatus('busy')
setDismissSuppressed(true)
if (mode === 'web') {
try {
await window.api.shell.openUrl(ORCA_REPO_URL)
await window.api.starNag.openWeb()
markResolved()
setStatus('opened')
} catch {
setDismissSuppressed(false)
setStatus('idle')
}
return
}
let ok = false
try {
ok = await window.api.starNag.starOrca()
} catch {
ok = false
}
if (!ok) {
setMode('web')
setDismissSuppressed(false)
setStatus('idle')
return
}
markResolved()
setStatus('starred')
}
const actionLabel =
status === 'starred'
? translate('auto.components.star.nag.StarNagToastHost.starredThanks', 'Starred — thank you!')
: status === 'opened'
? translate('auto.components.star.nag.StarNagToastHost.githubOpened', 'GitHub opened')
: busy
? mode === 'web'
? translate('auto.components.star.nag.StarNagToastHost.opening', 'Opening…')
: translate('auto.components.star.nag.StarNagToastHost.starring', 'Starring…')
: mode === 'web'
? translate('auto.components.star.nag.StarNagToastHost.openGithub', 'Open GitHub')
: translate('auto.components.star.nag.StarNagToastHost.starOnGithub', 'Star on GitHub')
const completedStar = status === 'starred'
const primaryActionClass = completedStar
? 'min-w-0 flex-1 gap-1.5 border-amber-400/40 bg-amber-400/15 text-amber-700 hover:bg-amber-400/15 dark:text-amber-200'
: 'min-w-0 flex-1 gap-1.5 border-amber-400/60 bg-amber-400/15 text-amber-800 hover:bg-amber-400/25 dark:text-amber-100'
return (
<div className="relative w-[340px] max-w-[calc(100vw-32px)] overflow-hidden rounded-lg border border-border bg-popover p-3.5 text-popover-foreground shadow-xs">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1.5">
<div className="flex items-center gap-2">
<span
className={
completedStar
? 'flex size-6 shrink-0 items-center justify-center rounded-full border border-amber-400/40 bg-amber-400/10 text-amber-500'
: 'flex size-6 shrink-0 items-center justify-center rounded-full border border-status-success-border bg-status-success-background text-status-success'
}
aria-hidden="true"
>
{completedStar ? (
<Star className="size-3.5 fill-current" />
) : (
<Check className="size-3.5" />
)}
</span>
<div className="text-sm font-semibold">
{translate(
'auto.components.star.nag.StarNagToastHost.onboardingCompleted',
'Onboarding completed!'
)}
</div>
</div>
<p className="text-sm leading-5 text-muted-foreground">
{translate(
'auto.components.star.nag.StarNagToastHost.body',
'If youre enjoying Orca so far, a GitHub star helps other developers discover it.'
)}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={close}
disabled={busy}
aria-label={translate('auto.components.star.nag.StarNagToastHost.dismiss', 'Dismiss')}
>
<X className="size-3.5" />
</Button>
</div>
<div className="mt-3 flex gap-2">
<Button
variant="default"
size="sm"
className={primaryActionClass}
onClick={() => void act()}
disabled={busy || status === 'starred' || status === 'opened'}
>
{busy ? (
<Loader2 className="size-3.5 animate-spin" />
) : mode === 'web' ? (
<ExternalLink className="size-3.5" />
) : (
<Star className="size-3.5" />
)}
{actionLabel}
</Button>
<Button
variant="secondary"
size="sm"
className="w-[84px]"
onClick={later}
disabled={busy || status === 'starred' || status === 'opened'}
>
{translate('auto.components.star.nag.StarNagToastHost.later', 'Later')}
</Button>
</div>
</div>
)
}
export function StarNagToastHost(): null {
const activeToastIdRef = useRef<string | number | null>(null)
const activeToastResolvedRef = useRef<(() => void) | null>(null)
useEffect(() => {
const dismissActiveToast = (): void => {
if (activeToastIdRef.current === null) {
return
}
activeToastResolvedRef.current?.()
toast.dismiss(activeToastIdRef.current)
}
const unsubscribeShow = window.api.starNag.onShow((payload) => {
if (payload?.surface !== 'toast') {
return
}
dismissActiveToast()
let resolved = false
let dismissSuppressed = false
const markResolved = (): void => {
resolved = true
}
const setDismissSuppressed = (suppressed: boolean): void => {
dismissSuppressed = suppressed
}
activeToastResolvedRef.current = markResolved
const id = toast.custom(
(toastId) => (
<StarNagToast
id={toastId}
mode={payload.mode === 'web' ? 'web' : 'gh'}
markResolved={markResolved}
setDismissSuppressed={setDismissSuppressed}
/>
),
{
duration: Infinity,
closeButton: false,
dismissible: false,
unstyled: true,
onDismiss: () => {
if (activeToastIdRef.current === id) {
activeToastIdRef.current = null
activeToastResolvedRef.current = null
}
if (!resolved && !dismissSuppressed) {
void window.api.starNag.dismiss()
}
},
onAutoClose: () => {
if (activeToastIdRef.current === id) {
activeToastIdRef.current = null
activeToastResolvedRef.current = null
}
}
}
)
activeToastIdRef.current = id
})
const unsubscribeHide = window.api.starNag.onHide(dismissActiveToast)
return () => {
unsubscribeShow()
unsubscribeHide()
}
}, [])
return null
}

View File

@ -11477,6 +11477,22 @@
},
"ShortcutKeyCombo": {
"07eb4985a1": "Double-tap {{value0}}"
},
"star": {
"nag": {
"StarNagToastHost": {
"starredThanks": "Starred — thank you!",
"githubOpened": "GitHub opened",
"opening": "Opening…",
"starring": "Starring…",
"openGithub": "Open GitHub",
"starOnGithub": "Star on GitHub",
"onboardingCompleted": "Onboarding completed!",
"body": "If youre enjoying Orca so far, a GitHub star helps other developers discover it.",
"dismiss": "Dismiss",
"later": "Later"
}
}
}
},
"i18n": {

View File

@ -11477,6 +11477,22 @@
},
"ShortcutKeyCombo": {
"07eb4985a1": "Double-tap {{value0}}"
},
"star": {
"nag": {
"StarNagToastHost": {
"starredThanks": "Starred — thank you!",
"githubOpened": "GitHub opened",
"opening": "Opening…",
"starring": "Starring…",
"openGithub": "Open GitHub",
"starOnGithub": "Star on GitHub",
"onboardingCompleted": "Onboarding completed!",
"body": "If youre enjoying Orca so far, a GitHub star helps other developers discover it.",
"dismiss": "Dismiss",
"later": "Later"
}
}
}
},
"i18n": {

View File

@ -11477,6 +11477,22 @@
},
"ShortcutKeyCombo": {
"07eb4985a1": "Double-tap {{value0}}"
},
"star": {
"nag": {
"StarNagToastHost": {
"starredThanks": "Starred — thank you!",
"githubOpened": "GitHub opened",
"opening": "Opening…",
"starring": "Starring…",
"openGithub": "Open GitHub",
"starOnGithub": "Star on GitHub",
"onboardingCompleted": "Onboarding completed!",
"body": "If youre enjoying Orca so far, a GitHub star helps other developers discover it.",
"dismiss": "Dismiss",
"later": "Later"
}
}
}
},
"i18n": {

View File

@ -11477,6 +11477,22 @@
},
"ShortcutKeyCombo": {
"07eb4985a1": "Double-tap {{value0}}"
},
"star": {
"nag": {
"StarNagToastHost": {
"starredThanks": "Starred — thank you!",
"githubOpened": "GitHub opened",
"opening": "Opening…",
"starring": "Starring…",
"openGithub": "Open GitHub",
"starOnGithub": "Star on GitHub",
"onboardingCompleted": "Onboarding completed!",
"body": "If youre enjoying Orca so far, a GitHub star helps other developers discover it.",
"dismiss": "Dismiss",
"later": "Later"
}
}
}
},
"i18n": {

View File

@ -11477,6 +11477,22 @@
},
"ShortcutKeyCombo": {
"07eb4985a1": "Double-tap {{value0}}"
},
"star": {
"nag": {
"StarNagToastHost": {
"starredThanks": "Starred — thank you!",
"githubOpened": "GitHub opened",
"opening": "Opening…",
"starring": "Starring…",
"openGithub": "Open GitHub",
"starOnGithub": "Star on GitHub",
"onboardingCompleted": "Onboarding completed!",
"body": "If youre enjoying Orca so far, a GitHub star helps other developers discover it.",
"dismiss": "Dismiss",
"later": "Later"
}
}
}
},
"i18n": {

View File

@ -434,6 +434,20 @@ function createWebPreloadApi(): Partial<PreloadApi> {
pickFloatingMarkdownDocument: () => Promise.resolve(null),
pickFloatingWorkspaceDirectory: () => Promise.resolve(null)
},
starNag: {
onShow: () => noopUnsubscribe,
onHide: () => noopUnsubscribe,
dismiss: () => Promise.resolve(),
later: () => Promise.resolve(),
complete: () => Promise.resolve(),
disable: () => Promise.resolve(),
openWeb: () => Promise.resolve(),
starOrca: () => Promise.resolve(false),
forceShow: () => Promise.resolve(),
agentValueMoment: () => Promise.resolve({ status: 'skipped' }),
showAgentValueMoment: () => Promise.resolve(),
onboardingCompleted: () => Promise.resolve()
},
platform: {
get: () => ({
platform: getBrowserPlatform(),

View File

@ -1,6 +1,12 @@
import { z } from 'zod'
const APP_STAR_SOURCE_VALUES = ['star_nag', 'settings', 'landing'] as const
const APP_STAR_SOURCE_VALUES = [
'star_nag',
'agent_value_moment',
'onboarding_completed',
'settings',
'landing'
] as const
// Why: renderer-originated IPC is untrusted, so main validates against this
// closed enum before attaching source context to successful star telemetry.

View File

@ -128,8 +128,9 @@ describe('ModifierDoubleTapDetector', () => {
})
).toMatchObject({ modifier: 'Shift', isModifierOnly: false })
expect(
toModifierDoubleTapEvent({ type: 'keyDown', code: 'KeyA', key: 'a' })
).toMatchObject({ modifier: null, isModifierOnly: false })
expect(toModifierDoubleTapEvent({ type: 'keyDown', code: 'KeyA', key: 'a' })).toMatchObject({
modifier: null,
isModifierOnly: false
})
})
})

View File

@ -21,6 +21,7 @@ export const STAR_NAG_PROMPT_SOURCES = [
'threshold',
'force_show',
'agent_value_moment',
'onboarding_completed',
'update_flow',
'settings',
'legacy_threshold'

View File

@ -3142,6 +3142,9 @@ export type PersistedUIState = {
/** Timestamp until which nonterminal dismissals suppress threshold prompts.
* Force-show bypasses this for dev/testing. */
starNagDeferredUntil?: number | null
/** App version that already consumed the first successful-agent value-moment ask.
* Main-owned so remote/web clients cannot spoof the once-per-version cap. */
starNagAgentValueMomentAppVersion?: string | null
trustedOrcaHooks?: PersistedTrustedOrcaHooks
setupScriptPromptDismissedRepoIds?: string[]
/** Whether the experimental pet overlay is currently visible. Separate