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 (
+
+
+
+
+
+ {completedStar ? (
+
+ ) : (
+
+ )}
+
+
+ {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