diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 2c663e1d1..413117a7e 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -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() diff --git a/src/main/github/client.ts b/src/main/github/client.ts index b67e6a1ac..26b7f8799 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -236,8 +236,16 @@ function isNoPullRequestError(err: unknown): boolean { export async function checkOrcaStarred(): Promise { 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 diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index 0a77e91f2..687c0f8d0 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -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 } ]) diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index c7324d274..6805908dd 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -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 } diff --git a/src/main/star-nag/agent-value-moment.ts b/src/main/star-nag/agent-value-moment.ts new file mode 100644 index 000000000..ad785341a --- /dev/null +++ b/src/main/star-nag/agent-value-moment.ts @@ -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 { + 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() + } +} diff --git a/src/main/star-nag/app-star-source.ts b/src/main/star-nag/app-star-source.ts new file mode 100644 index 000000000..45f27a7cc --- /dev/null +++ b/src/main/star-nag/app-star-source.ts @@ -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' +} diff --git a/src/main/star-nag/console-events.ts b/src/main/star-nag/console-events.ts new file mode 100644 index 000000000..0cf04b4d6 --- /dev/null +++ b/src/main/star-nag/console-events.ts @@ -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 }) + }) +} diff --git a/src/main/star-nag/direct-star-attempt.ts b/src/main/star-nag/direct-star-attempt.ts new file mode 100644 index 000000000..6440e9ac4 --- /dev/null +++ b/src/main/star-nag/direct-star-attempt.ts @@ -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 { + 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 +} diff --git a/src/main/star-nag/onboarding-completed.ts b/src/main/star-nag/onboarding-completed.ts new file mode 100644 index 000000000..8831c3858 --- /dev/null +++ b/src/main/star-nag/onboarding-completed.ts @@ -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 +} + +export async function handleStarNagOnboardingCompleted( + deps: OnboardingCompletedDeps +): Promise { + 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() +} diff --git a/src/main/star-nag/prompt-context.ts b/src/main/star-nag/prompt-context.ts new file mode 100644 index 000000000..e50b585d3 --- /dev/null +++ b/src/main/star-nag/prompt-context.ts @@ -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() + } +} diff --git a/src/main/star-nag/service.test.ts b/src/main/star-nag/service.test.ts index a6ba32cef..60f166ff2 100644 --- a/src/main/star-nag/service.test.ts +++ b/src/main/star-nag/service.test.ts @@ -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() + 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() + 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()) }) }) diff --git a/src/main/star-nag/service.ts b/src/main/star-nag/service.ts index 93ac58f7a..d16e8ab65 100644 --- a/src/main/star-nag/service.ts +++ b/src/main/star-nag/service.ts @@ -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 { + private async maybeShow( + source: StarNagPromptSource, + surface: StarNagSurface = 'card' + ): Promise { 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 { + 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 { + 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 { @@ -320,24 +324,11 @@ export class StarNagService { } private async runStarOrcaAttempt(session: StarNagPromptSession): Promise { - 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 { diff --git a/src/main/star-nag/threshold-trigger.ts b/src/main/star-nag/threshold-trigger.ts new file mode 100644 index 000000000..bb487c7ab --- /dev/null +++ b/src/main/star-nag/threshold-trigger.ts @@ -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 +} diff --git a/src/main/star-nag/web-handoff.ts b/src/main/star-nag/web-handoff.ts new file mode 100644 index 000000000..04c06c001 --- /dev/null +++ b/src/main/star-nag/web-handoff.ts @@ -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 + }) +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index d9bbc9efa..5d1b470f4 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1721,7 +1721,10 @@ export type PreloadApi = { listTransitions: (args: { key: string; siteId?: string }) => Promise } 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 later: () => Promise complete: () => Promise @@ -1729,6 +1732,9 @@ export type PreloadApi = { openWeb: () => Promise starOrca: () => Promise forceShow: () => Promise + agentValueMoment: () => Promise<{ status: 'ready'; mode: 'gh' | 'web' } | { status: 'skipped' }> + showAgentValueMoment: () => Promise + onboardingCompleted: () => Promise } /** Fire-and-forget track. Loose typing at the IPC boundary on purpose — * the main-side validator is the single enforcement point. Renderer call diff --git a/src/preload/index.ts b/src/preload/index.ts index 1e6a7b25f..c1bfee59d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => 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') + forceShow: (): Promise => ipcRenderer.invoke('star-nag:forceShow'), + agentValueMoment: (): Promise< + { status: 'ready'; mode: 'gh' | 'web' } | { status: 'skipped' } + > => ipcRenderer.invoke('star-nag:agentValueMoment'), + showAgentValueMoment: (): Promise => ipcRenderer.invoke('star-nag:showAgentValueMoment'), + onboardingCompleted: (): Promise => ipcRenderer.invoke('star-nag:onboardingCompleted') }, // Why: telemetry uses a loose untyped surface at the preload boundary on diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1ec4d7856..4f33e9ff8 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 { > + + + + {/* 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, diff --git a/src/renderer/src/components/Landing.tsx b/src/renderer/src/components/Landing.tsx index 0bce8ca7d..514745989 100644 --- a/src/renderer/src/components/Landing.tsx +++ b/src/renderer/src/components/Landing.tsx @@ -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') { diff --git a/src/renderer/src/components/ShortcutKeyCombo.tsx b/src/renderer/src/components/ShortcutKeyCombo.tsx index 40c9d839c..f072e8298 100644 --- a/src/renderer/src/components/ShortcutKeyCombo.tsx +++ b/src/renderer/src/components/ShortcutKeyCombo.tsx @@ -39,7 +39,13 @@ export function ShortcutKeyCombo({ return ( 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) => ( diff --git a/src/renderer/src/components/StarNagCard.test.tsx b/src/renderer/src/components/StarNagCard.test.tsx new file mode 100644 index 000000000..7d0cb5287 --- /dev/null +++ b/src/renderer/src/components/StarNagCard.test.tsx @@ -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 + later: ReturnType + openWeb: ReturnType + starOrca: ReturnType +} + +type ShellApi = { + openUrl: ReturnType +} + +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() + }) + 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') + }) +}) diff --git a/src/renderer/src/components/StarNagCard.tsx b/src/renderer/src/components/StarNagCard.tsx index 7bbcfd2be..f0e184143 100644 --- a/src/renderer/src/components/StarNagCard.tsx +++ b/src/renderer/src/components/StarNagCard.tsx @@ -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 => { if (busy) { return } - if (mode === 'web') { - setBusy(true) + const openGithubFallback = async (): Promise => { 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')} > @@ -155,31 +184,37 @@ export function StarNagCard(): React.JSX.Element | null { )}

- - +
+ + +
diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts index 6a16417a7..a0fb9c00f 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts @@ -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, + lastStepReached: StepNumber, + completedPath?: 'open_folder' | 'clone_url' | 'add_project_modal', + dismissedExtras?: DismissedExtras +) => Promise + +function makeOnboardingState(): OnboardingState { + return { + ...getDefaultOnboardingState(), + closedAt: Date.now(), + outcome: 'completed', + lastCompletedStep: 5 + } +} + +function setApi(api: { + onboarding: { update: ReturnType } + starNag: { onboardingCompleted: ReturnType } +}): 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 } + } + } + ).api + expect(api.starNag.onboardingCompleted).not.toHaveBeenCalled() + + act(() => { + vi.advanceTimersByTime(0) + }) + + expect(api.starNag.onboardingCompleted).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts index 8c1f726eb..10c8c6cd6 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts @@ -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) } diff --git a/src/renderer/src/components/settings/GeneralSupportSection.tsx b/src/renderer/src/components/settings/GeneralSupportSection.tsx index 053960f71..1c7762580 100644 --- a/src/renderer/src/components/settings/GeneralSupportSection.tsx +++ b/src/renderer/src/components/settings/GeneralSupportSection.tsx @@ -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') } diff --git a/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.test.tsx b/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.test.tsx new file mode 100644 index 000000000..4e1f7f5f4 --- /dev/null +++ b/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.test.tsx @@ -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 + showAgentValueMoment: ReturnType +} + +function setStarNagApi(api: StarNagApi): void { + ;(window as unknown as { api: { starNag: StarNagApi } }).api = { starNag: api } +} + +function entry(overrides: Partial): 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() + }) + return { root, container } +} + +function setAgentEntries(entries: Record): void { + act(() => { + useAppStore.setState((state) => ({ + agentStatusByPaneKey: entries, + agentStatusEpoch: state.agentStatusEpoch + 1 + })) + }) +} + +function createDeferred(): { + promise: Promise + resolve: (value: T) => void +} { + let resolve!: (value: T) => void + const promise = new Promise((innerResolve) => { + resolve = innerResolve + }) + return { promise, resolve } +} + +describe('StarNagAgentValueMomentObserver', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + let agentValueMoment: ReturnType + let showAgentValueMoment: ReturnType + + 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) + }) +}) diff --git a/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx b/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx new file mode 100644 index 000000000..3dcd4b46c --- /dev/null +++ b/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx @@ -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 +type AgentValueMomentPreparation = Awaited> + +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(null) + const latestEntriesRef = useRef(agentStatusByPaneKey) + const pendingRef = useRef(false) + const requestedRef = useRef(false) + const preparationRef = useRef(null) + const lastTypingAtRef = useRef(0) + const timerRef = useRef | 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 +} diff --git a/src/renderer/src/components/star-nag/StarNagToastHost.test.tsx b/src/renderer/src/components/star-nag/StarNagToastHost.test.tsx new file mode 100644 index 000000000..def7fdba6 --- /dev/null +++ b/src/renderer/src/components/star-nag/StarNagToastHost.test.tsx @@ -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 + later: ReturnType + openWeb: ReturnType + starOrca: ReturnType +} + +type ShellApi = { + openUrl: ReturnType +} + +function createDeferred(): { + promise: Promise + resolve: (value: T) => void +} { + let resolve!: (value: T) => void + const promise = new Promise((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() + }) + 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 you’re 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() + 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() + }) +}) diff --git a/src/renderer/src/components/star-nag/StarNagToastHost.tsx b/src/renderer/src/components/star-nag/StarNagToastHost.tsx new file mode 100644 index 000000000..6f4d8b006 --- /dev/null +++ b/src/renderer/src/components/star-nag/StarNagToastHost.tsx @@ -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('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 => { + 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 ( +
+
+
+
+ +
+ {translate( + 'auto.components.star.nag.StarNagToastHost.onboardingCompleted', + 'Onboarding completed!' + )} +
+
+

+ {translate( + 'auto.components.star.nag.StarNagToastHost.body', + 'If you’re enjoying Orca so far, a GitHub star helps other developers discover it.' + )} +

+
+ +
+
+ + +
+
+ ) +} + +export function StarNagToastHost(): null { + const activeToastIdRef = useRef(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) => ( + + ), + { + 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 +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9ac6629a4..30028a849 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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 you’re enjoying Orca so far, a GitHub star helps other developers discover it.", + "dismiss": "Dismiss", + "later": "Later" + } + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 48e3dd1f0..e07815d85 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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 you’re enjoying Orca so far, a GitHub star helps other developers discover it.", + "dismiss": "Dismiss", + "later": "Later" + } + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index da80ebe7c..257eac51c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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 you’re enjoying Orca so far, a GitHub star helps other developers discover it.", + "dismiss": "Dismiss", + "later": "Later" + } + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index fea586b0d..40b22afb9 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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 you’re enjoying Orca so far, a GitHub star helps other developers discover it.", + "dismiss": "Dismiss", + "later": "Later" + } + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index bbcd2191a..ae354cfd8 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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 you’re enjoying Orca so far, a GitHub star helps other developers discover it.", + "dismiss": "Dismiss", + "later": "Later" + } + } } }, "i18n": { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 0e5e0b1ce..2046a58cf 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -434,6 +434,20 @@ function createWebPreloadApi(): Partial { 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(), diff --git a/src/shared/gh-star-source.ts b/src/shared/gh-star-source.ts index 26df51823..1b52e6c92 100644 --- a/src/shared/gh-star-source.ts +++ b/src/shared/gh-star-source.ts @@ -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. diff --git a/src/shared/modifier-double-tap-detector.test.ts b/src/shared/modifier-double-tap-detector.test.ts index 6bafb6d4a..7a0446e9b 100644 --- a/src/shared/modifier-double-tap-detector.test.ts +++ b/src/shared/modifier-double-tap-detector.test.ts @@ -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 + }) }) }) diff --git a/src/shared/star-nag-telemetry.ts b/src/shared/star-nag-telemetry.ts index 0b7bd3b99..5426537b0 100644 --- a/src/shared/star-nag-telemetry.ts +++ b/src/shared/star-nag-telemetry.ts @@ -21,6 +21,7 @@ export const STAR_NAG_PROMPT_SOURCES = [ 'threshold', 'force_show', 'agent_value_moment', + 'onboarding_completed', 'update_flow', 'settings', 'legacy_threshold' diff --git a/src/shared/types.ts b/src/shared/types.ts index a798f8f1e..29b6ddb0f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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