Celebrate onboarding and agent wins with GitHub star prompts (#5642)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
4a3ec0232f
commit
fd9e79a77a
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -236,8 +236,16 @@ function isNoPullRequestError(err: unknown): boolean {
|
|||
export async function checkOrcaStarred(): Promise<boolean | null> {
|
||||
await acquire()
|
||||
try {
|
||||
await execFileAsync('gh', ['api', `user/starred/${ORCA_REPO}`], { encoding: 'utf-8' })
|
||||
return true
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
'gh',
|
||||
['api', '--include', `user/starred/${ORCA_REPO}`],
|
||||
{ encoding: 'utf-8' }
|
||||
)
|
||||
const response = `${stdout ?? ''}\n${stderr ?? ''}`
|
||||
if (/HTTP\/\S+\s+(?:200|204)\b/.test(response)) {
|
||||
return true
|
||||
}
|
||||
return null
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
// 404 means the user hasn't starred — the only expected "no" answer
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
import { app } from 'electron'
|
||||
import { checkOrcaStarred } from '../github/client'
|
||||
import type { Store } from '../persistence'
|
||||
import type { StarNagPromptMode } from '../../shared/star-nag-telemetry'
|
||||
|
||||
export type AgentValueMomentPreparation =
|
||||
| { status: 'ready'; mode: StarNagPromptMode }
|
||||
| { status: 'skipped' }
|
||||
|
||||
type StarNagAgentValueMomentDeps = {
|
||||
store: Store
|
||||
isEvaluating: () => boolean
|
||||
setEvaluating: (value: boolean) => void
|
||||
isPromptVisible: () => boolean
|
||||
isCooldownActive: (deferredUntil: number | null | undefined) => boolean
|
||||
markCompleted: () => void
|
||||
trackAlreadyStarredSuppressed: () => void
|
||||
broadcastShow: (mode: StarNagPromptMode) => boolean
|
||||
}
|
||||
|
||||
export class StarNagAgentValueMoment {
|
||||
private readonly deps: StarNagAgentValueMomentDeps
|
||||
private pendingMode: StarNagPromptMode | null = null
|
||||
|
||||
constructor(deps: StarNagAgentValueMomentDeps) {
|
||||
this.deps = deps
|
||||
}
|
||||
|
||||
async prepare(): Promise<AgentValueMomentPreparation> {
|
||||
if (this.wasConsumed() || this.deps.isEvaluating()) {
|
||||
return { status: 'skipped' }
|
||||
}
|
||||
const ui = this.deps.store.getUI()
|
||||
if (
|
||||
ui.starNagCompleted ||
|
||||
this.deps.isCooldownActive(ui.starNagDeferredUntil) ||
|
||||
this.deps.isPromptVisible()
|
||||
) {
|
||||
// Why: each app version gets at most one completion-moment attempt, even if
|
||||
// an existing prompt/cooldown blocks the extra agent-finished trigger.
|
||||
this.consumeVersion()
|
||||
return { status: 'skipped' }
|
||||
}
|
||||
this.deps.setEvaluating(true)
|
||||
try {
|
||||
const starred = await checkOrcaStarred()
|
||||
if (this.deps.store.getUI().starNagCompleted) {
|
||||
return { status: 'skipped' }
|
||||
}
|
||||
if (starred === null) {
|
||||
this.pendingMode = 'web'
|
||||
return { status: 'ready', mode: 'web' }
|
||||
}
|
||||
if (starred) {
|
||||
this.deps.trackAlreadyStarredSuppressed()
|
||||
this.deps.markCompleted()
|
||||
// Why: already-starred users should not be rechecked on every agent
|
||||
// completion after this version has been resolved.
|
||||
this.consumeVersion()
|
||||
return { status: 'skipped' }
|
||||
}
|
||||
this.pendingMode = 'gh'
|
||||
return { status: 'ready', mode: 'gh' }
|
||||
} finally {
|
||||
this.deps.setEvaluating(false)
|
||||
}
|
||||
}
|
||||
|
||||
showPrepared(): void {
|
||||
const mode = this.pendingMode
|
||||
if (!mode || this.wasConsumed()) {
|
||||
return
|
||||
}
|
||||
const ui = this.deps.store.getUI()
|
||||
if (
|
||||
ui.starNagCompleted ||
|
||||
this.deps.isCooldownActive(ui.starNagDeferredUntil) ||
|
||||
this.deps.isPromptVisible() ||
|
||||
this.deps.isEvaluating()
|
||||
) {
|
||||
// Why: the prepared moment can go stale before display; consuming prevents
|
||||
// repeated prompts from the same app-version completion moment.
|
||||
this.consumeVersion()
|
||||
this.pendingMode = null
|
||||
return
|
||||
}
|
||||
const delivered = this.deps.broadcastShow(mode)
|
||||
if (delivered || this.deps.store.getUI().starNagCompleted) {
|
||||
// Why: once a prompt is delivered or completion wins the race, this app
|
||||
// version's agent-value moment has been spent.
|
||||
this.consumeVersion()
|
||||
}
|
||||
if (delivered) {
|
||||
this.pendingMode = null
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.pendingMode = null
|
||||
}
|
||||
|
||||
private consumeVersion(): void {
|
||||
this.deps.store.updateUI({ starNagAgentValueMomentAppVersion: app.getVersion() })
|
||||
}
|
||||
|
||||
private wasConsumed(): boolean {
|
||||
return this.deps.store.getUI().starNagAgentValueMomentAppVersion === app.getVersion()
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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 })
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { starOrca } from '../github/client'
|
||||
import { track } from '../telemetry/client'
|
||||
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
|
||||
import { type StarNagPromptSession, trackStarNagSessionOutcome } from './prompt-session-telemetry'
|
||||
import { getStarNagAppStarSource } from './app-star-source'
|
||||
|
||||
export async function runStarNagDirectStarAttempt(session: StarNagPromptSession): Promise<boolean> {
|
||||
trackStarNagSessionOutcome(session, 'star_clicked', { mode: 'gh' })
|
||||
const starred = await starOrca()
|
||||
if (!starred) {
|
||||
trackStarNagSessionOutcome(session, 'direct_star_failed', { mode: 'gh' })
|
||||
session.mode = 'web'
|
||||
return false
|
||||
}
|
||||
trackStarNagSessionOutcome(session, 'direct_star_succeeded', { mode: 'gh' })
|
||||
// Why: app_starred_orca remains the canonical cross-surface success event;
|
||||
// star_nag_outcome is only the nag-funnel companion.
|
||||
track('app_starred_orca', {
|
||||
source: getStarNagAppStarSource(session.source),
|
||||
...getCohortAtEmit()
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import type { Store } from '../persistence'
|
||||
|
||||
type OnboardingCompletedDeps = {
|
||||
store: Store
|
||||
isCooldownActive: (deferredUntil: number | null | undefined) => boolean
|
||||
isEvaluating: () => boolean
|
||||
queueAfterEvaluation: () => void
|
||||
isPromptVisible: () => boolean
|
||||
clearVisiblePrompt: () => void
|
||||
showToast: () => Promise<boolean>
|
||||
}
|
||||
|
||||
export async function handleStarNagOnboardingCompleted(
|
||||
deps: OnboardingCompletedDeps
|
||||
): Promise<void> {
|
||||
const ui = deps.store.getUI()
|
||||
const cooldownActive = deps.isCooldownActive(ui.starNagDeferredUntil)
|
||||
if (ui.starNagCompleted || cooldownActive || deps.isEvaluating()) {
|
||||
if (!ui.starNagCompleted && !cooldownActive && deps.isEvaluating()) {
|
||||
deps.queueAfterEvaluation()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (deps.isPromptVisible()) {
|
||||
deps.clearVisiblePrompt()
|
||||
}
|
||||
await deps.showToast()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -176,7 +176,8 @@ describe('StarNagService', () => {
|
|||
|
||||
expect(window.webContents.send).toHaveBeenCalledTimes(1)
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
|
||||
mode: 'gh'
|
||||
mode: 'gh',
|
||||
surface: 'card'
|
||||
})
|
||||
expect(consoleInfoMock).toHaveBeenCalledTimes(1)
|
||||
expect(consoleInfoMock).toHaveBeenCalledWith({
|
||||
|
|
@ -199,7 +200,8 @@ describe('StarNagService', () => {
|
|||
await flushAsyncWork()
|
||||
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
|
||||
mode: 'web'
|
||||
mode: 'web',
|
||||
surface: 'card'
|
||||
})
|
||||
expect(trackMock).toHaveBeenCalledWith('star_nag_outcome', {
|
||||
outcome: 'shown',
|
||||
|
|
@ -249,7 +251,8 @@ describe('StarNagService', () => {
|
|||
await flushAsyncWork()
|
||||
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
|
||||
mode: 'gh'
|
||||
mode: 'gh',
|
||||
surface: 'card'
|
||||
})
|
||||
expect(consoleInfoMock).toHaveBeenCalledWith({
|
||||
event: 'star_nag_shown',
|
||||
|
|
@ -299,6 +302,198 @@ describe('StarNagService', () => {
|
|||
expect(trackMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows agent value moment prompts once per app version after eligibility passes', async () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
const { service, ui } = createHarness()
|
||||
|
||||
service.registerIpcHandlers()
|
||||
await expect(getIpcHandler('star-nag:agentValueMoment')()).resolves.toEqual({
|
||||
status: 'ready',
|
||||
mode: 'gh'
|
||||
})
|
||||
await getIpcHandler('star-nag:showAgentValueMoment')()
|
||||
getIpcHandler('star-nag:dismiss')()
|
||||
await expect(getIpcHandler('star-nag:agentValueMoment')()).resolves.toEqual({
|
||||
status: 'skipped'
|
||||
})
|
||||
|
||||
expect(window.webContents.send).toHaveBeenCalledTimes(1)
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
|
||||
mode: 'gh',
|
||||
surface: 'card'
|
||||
})
|
||||
expect(ui.starNagAgentValueMomentAppVersion).toBe('1.2.3')
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'shown', source: 'agent_value_moment' })
|
||||
)
|
||||
})
|
||||
|
||||
it('consumes agent value moment for cooldown suppression without showing later in the same version', async () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
const { service, ui } = createHarness({
|
||||
starNagDeferredUntil: Date.now() + 3 * 24 * 60 * 60 * 1000
|
||||
})
|
||||
|
||||
service.registerIpcHandlers()
|
||||
await expect(getIpcHandler('star-nag:agentValueMoment')()).resolves.toEqual({
|
||||
status: 'skipped'
|
||||
})
|
||||
ui.starNagDeferredUntil = null
|
||||
await expect(getIpcHandler('star-nag:agentValueMoment')()).resolves.toEqual({
|
||||
status: 'skipped'
|
||||
})
|
||||
|
||||
expect(window.webContents.send).not.toHaveBeenCalled()
|
||||
expect(ui.starNagAgentValueMomentAppVersion).toBe('1.2.3')
|
||||
})
|
||||
|
||||
it('does not consume agent value moment when no window can receive the card', async () => {
|
||||
const { service, ui } = createHarness()
|
||||
|
||||
service.registerIpcHandlers()
|
||||
await expect(getIpcHandler('star-nag:agentValueMoment')()).resolves.toEqual({
|
||||
status: 'ready',
|
||||
mode: 'gh'
|
||||
})
|
||||
await getIpcHandler('star-nag:showAgentValueMoment')()
|
||||
|
||||
expect(ui.starNagAgentValueMomentAppVersion).toBeUndefined()
|
||||
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
await getIpcHandler('star-nag:showAgentValueMoment')()
|
||||
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
|
||||
mode: 'gh',
|
||||
surface: 'card'
|
||||
})
|
||||
expect(ui.starNagAgentValueMomentAppVersion).toBe('1.2.3')
|
||||
})
|
||||
|
||||
it('shows onboarding completed prompts on the toast surface', async () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
const { service } = createHarness()
|
||||
|
||||
service.registerIpcHandlers()
|
||||
await getIpcHandler('star-nag:onboardingCompleted')()
|
||||
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
|
||||
mode: 'gh',
|
||||
surface: 'toast'
|
||||
})
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'shown', source: 'onboarding_completed' })
|
||||
)
|
||||
})
|
||||
|
||||
it('lets onboarding completed supersede an already visible threshold card', async () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
const { service, ui } = createHarness()
|
||||
|
||||
service.registerIpcHandlers()
|
||||
getIpcHandler('star-nag:forceShow')()
|
||||
await getIpcHandler('star-nag:onboardingCompleted')()
|
||||
|
||||
expect(window.webContents.send).toHaveBeenNthCalledWith(1, 'star-nag:show', {
|
||||
mode: 'gh',
|
||||
surface: 'card'
|
||||
})
|
||||
expect(window.webContents.send).toHaveBeenNthCalledWith(2, 'star-nag:hide')
|
||||
expect(window.webContents.send).toHaveBeenNthCalledWith(3, 'star-nag:show', {
|
||||
mode: 'gh',
|
||||
surface: 'toast'
|
||||
})
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'shown', source: 'onboarding_completed' })
|
||||
)
|
||||
expect(ui.starNagCompleted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('hides a superseded visible card when onboarding completion detects an existing star', async () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
checkOrcaStarredMock.mockResolvedValueOnce(true)
|
||||
const { service, ui } = createHarness()
|
||||
|
||||
service.registerIpcHandlers()
|
||||
getIpcHandler('star-nag:forceShow')()
|
||||
await getIpcHandler('star-nag:onboardingCompleted')()
|
||||
|
||||
expect(window.webContents.send).toHaveBeenNthCalledWith(1, 'star-nag:show', {
|
||||
mode: 'gh',
|
||||
surface: 'card'
|
||||
})
|
||||
expect(window.webContents.send).toHaveBeenNthCalledWith(2, 'star-nag:hide')
|
||||
expect(window.webContents.send).toHaveBeenCalledTimes(2)
|
||||
expect(ui.starNagCompleted).toBe(true)
|
||||
})
|
||||
|
||||
it('queues onboarding completed while a threshold star check is in flight', async () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
const deferredStarCheck = createDeferred<boolean | null>()
|
||||
checkOrcaStarredMock.mockReturnValueOnce(deferredStarCheck.promise).mockResolvedValueOnce(null)
|
||||
const { service, emitAgentStarted, ui } = createHarness()
|
||||
|
||||
service.start()
|
||||
service.registerIpcHandlers()
|
||||
emitAgentStarted(45)
|
||||
await getIpcHandler('star-nag:onboardingCompleted')()
|
||||
|
||||
expect(window.webContents.send).not.toHaveBeenCalled()
|
||||
|
||||
deferredStarCheck.resolve(false)
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(window.webContents.send).toHaveBeenNthCalledWith(1, 'star-nag:show', {
|
||||
mode: 'gh',
|
||||
surface: 'card'
|
||||
})
|
||||
expect(window.webContents.send).toHaveBeenNthCalledWith(2, 'star-nag:hide')
|
||||
expect(window.webContents.send).toHaveBeenNthCalledWith(3, 'star-nag:show', {
|
||||
mode: 'web',
|
||||
surface: 'toast'
|
||||
})
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'shown', source: 'onboarding_completed', mode: 'web' })
|
||||
)
|
||||
expect(ui.starNagCompleted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('queues onboarding completed while an agent value moment star check is in flight', async () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
const deferredStarCheck = createDeferred<boolean | null>()
|
||||
checkOrcaStarredMock.mockReturnValueOnce(deferredStarCheck.promise).mockResolvedValueOnce(null)
|
||||
const { service, ui } = createHarness()
|
||||
|
||||
service.registerIpcHandlers()
|
||||
const agentValueMoment = getIpcHandler('star-nag:agentValueMoment')()
|
||||
await getIpcHandler('star-nag:onboardingCompleted')()
|
||||
|
||||
deferredStarCheck.resolve(false)
|
||||
await expect(agentValueMoment).resolves.toEqual({ status: 'ready', mode: 'gh' })
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
|
||||
mode: 'web',
|
||||
surface: 'toast'
|
||||
})
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'shown', source: 'onboarding_completed', mode: 'web' })
|
||||
)
|
||||
expect(ui.starNagCompleted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('allows force_show to bypass the persisted cooldown', () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
|
|
@ -310,7 +505,8 @@ describe('StarNagService', () => {
|
|||
getIpcHandler('star-nag:forceShow')()
|
||||
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
|
||||
mode: 'gh'
|
||||
mode: 'gh',
|
||||
surface: 'card'
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -355,7 +551,8 @@ describe('StarNagService', () => {
|
|||
forceShow()
|
||||
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
|
||||
mode: 'gh'
|
||||
mode: 'gh',
|
||||
surface: 'card'
|
||||
})
|
||||
expect(consoleInfoMock).toHaveBeenCalledWith({
|
||||
event: 'star_nag_shown',
|
||||
|
|
@ -480,7 +677,8 @@ describe('StarNagService', () => {
|
|||
expect(window.webContents.send).toHaveBeenCalledTimes(1)
|
||||
expect(consoleInfoMock).toHaveBeenCalledTimes(1)
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
|
||||
mode: 'web'
|
||||
mode: 'web',
|
||||
surface: 'card'
|
||||
})
|
||||
expect(consoleInfoMock).toHaveBeenCalledWith({
|
||||
event: 'star_nag_shown',
|
||||
|
|
@ -616,8 +814,9 @@ describe('StarNagService', () => {
|
|||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'opened_repo', mode: 'web' })
|
||||
)
|
||||
expect(opened.ui.starNagCompleted).toBe(true)
|
||||
expect(opened.ui.starNagDeferredUntil).toBeNull()
|
||||
expect(opened.ui.starNagCompleted).toBeUndefined()
|
||||
expect(opened.ui.starNagDeferredUntil).toBeGreaterThan(Date.now())
|
||||
expect(opened.ui.starNagNextThreshold).toBe(STAR_NAG_INITIAL_THRESHOLD * 2)
|
||||
})
|
||||
|
||||
it('emits opened_repo at most once for one prompt session', () => {
|
||||
|
|
@ -682,6 +881,43 @@ describe('StarNagService', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('uses the source moment for confirmed direct-star success telemetry', async () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
const { service } = createHarness()
|
||||
|
||||
service.registerIpcHandlers()
|
||||
await getIpcHandler('star-nag:onboardingCompleted')()
|
||||
await getIpcHandler('star-nag:starOrca')()
|
||||
|
||||
expect(trackMock).toHaveBeenCalledWith('app_starred_orca', {
|
||||
source: 'onboarding_completed',
|
||||
nth_repo_added: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('does not emit confirmed star telemetry for web fallback handoff', async () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
checkOrcaStarredMock.mockResolvedValue(null)
|
||||
const { service, ui } = createHarness()
|
||||
|
||||
service.registerIpcHandlers()
|
||||
await getIpcHandler('star-nag:onboardingCompleted')()
|
||||
getIpcHandler('star-nag:openWeb')()
|
||||
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'opened_repo', source: 'onboarding_completed' })
|
||||
)
|
||||
expect(trackMock).not.toHaveBeenCalledWith(
|
||||
'app_starred_orca',
|
||||
expect.objectContaining({ source: 'onboarding_completed' })
|
||||
)
|
||||
expect(ui.starNagCompleted).toBeUndefined()
|
||||
expect(ui.starNagDeferredUntil).toBeGreaterThan(Date.now())
|
||||
})
|
||||
|
||||
it('uses fresh cohort context for canonical app_starred_orca success telemetry', async () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
|
|
@ -808,7 +1044,7 @@ describe('StarNagService', () => {
|
|||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'opened_repo', mode: 'web' })
|
||||
)
|
||||
expect(ui.starNagCompleted).toBe(true)
|
||||
expect(ui.starNagDeferredUntil).toBeNull()
|
||||
expect(ui.starNagCompleted).toBeUndefined()
|
||||
expect(ui.starNagDeferredUntil).toBeGreaterThan(Date.now())
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,34 +1,27 @@
|
|||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import { STAR_NAG_INITIAL_THRESHOLD } from '../../shared/constants'
|
||||
import { checkOrcaStarred, starOrca } from '../github/client'
|
||||
import { checkOrcaStarred } from '../github/client'
|
||||
import type { Store } from '../persistence'
|
||||
import type { StatsCollector } from '../stats/collector'
|
||||
import { track } from '../telemetry/client'
|
||||
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
|
||||
import {
|
||||
bucketStarNagAgentsSinceBaseline,
|
||||
type StarNagOutcome,
|
||||
type StarNagPromptMode,
|
||||
type StarNagPromptSource
|
||||
import type {
|
||||
StarNagOutcome,
|
||||
StarNagPromptMode,
|
||||
StarNagPromptSource
|
||||
} from '../../shared/star-nag-telemetry'
|
||||
import {
|
||||
type StarNagPromptContext,
|
||||
type StarNagPromptSession,
|
||||
trackStarNagSessionOutcome
|
||||
} from './prompt-session-telemetry'
|
||||
import { type StarNagPromptSession, trackStarNagSessionOutcome } from './prompt-session-telemetry'
|
||||
import { createStarNagPromptContext } from './prompt-context'
|
||||
import { logStarNagConsoleEvent } from './console-events'
|
||||
import { StarNagAgentValueMoment, type AgentValueMomentPreparation } from './agent-value-moment'
|
||||
import { deferAfterStarNagWebHandoff } from './web-handoff'
|
||||
import { runStarNagDirectStarAttempt } from './direct-star-attempt'
|
||||
import { handleStarNagOnboardingCompleted } from './onboarding-completed'
|
||||
import { ensureStarNagBaseline, shouldShowStarNagThresholdPrompt } from './threshold-trigger'
|
||||
|
||||
const STAR_NAG_COOLDOWN_DAYS = 3
|
||||
const STAR_NAG_COOLDOWN_MS = STAR_NAG_COOLDOWN_DAYS * 24 * 60 * 60 * 1000
|
||||
type StarNagSurface = 'card' | 'toast'
|
||||
|
||||
/**
|
||||
* Service that decides when to prompt the user with the "star Orca on GitHub"
|
||||
* notification. Counts agents spawned since the current app version was first
|
||||
* seen; crosses a doubling threshold (default 35 → 70 → 140 …) to fire the
|
||||
* renderer notification via 'star-nag:show'.
|
||||
*
|
||||
* State lives in PersistedUIState so it survives restarts alongside the rest
|
||||
* of the UI preferences (dismissed update versions, etc).
|
||||
*/
|
||||
export class StarNagService {
|
||||
private store: Store
|
||||
private stats: StatsCollector
|
||||
|
|
@ -45,23 +38,32 @@ export class StarNagService {
|
|||
// resolving.
|
||||
private evaluating = false
|
||||
private pendingForceShow = false
|
||||
private pendingOnboardingCompleted = false
|
||||
// Why: dismissal backoff and action telemetry must use the prompt context
|
||||
// that was delivered, not whatever threshold/source happens to be current
|
||||
// when the renderer later reports a user action.
|
||||
private promptSession: StarNagPromptSession | null = null
|
||||
private agentValueMoment: StarNagAgentValueMoment
|
||||
|
||||
constructor(store: Store, stats: StatsCollector) {
|
||||
this.store = store
|
||||
this.stats = stats
|
||||
this.agentValueMoment = new StarNagAgentValueMoment({
|
||||
store,
|
||||
isEvaluating: () => this.evaluating,
|
||||
setEvaluating: (value) => {
|
||||
this.setEvaluating(value)
|
||||
},
|
||||
isPromptVisible: () => this.promptVisible,
|
||||
isCooldownActive: (deferredUntil) => this.isCooldownActive(deferredUntil),
|
||||
markCompleted: () => this.markCompleted(),
|
||||
trackAlreadyStarredSuppressed: () => this.trackAlreadyStarredSuppressed('agent_value_moment'),
|
||||
broadcastShow: (mode) => this.broadcastShow('agent_value_moment', mode)
|
||||
})
|
||||
}
|
||||
|
||||
start(): void {
|
||||
// Why: capture the baseline eagerly on first boot after an update so the
|
||||
// "agents since update" counter doesn't include pre-update spawns. We do
|
||||
// this here instead of waiting for the next agent_start so that a brand
|
||||
// new install with a pre-existing stats file (unusual, but possible via
|
||||
// copy of userData) starts from a sensible baseline.
|
||||
this.ensureBaseline()
|
||||
ensureStarNagBaseline(this.store, this.stats)
|
||||
this.disposeStatsListener = this.stats.onAgentStarted((total) => {
|
||||
this.handleAgentSpawned(total)
|
||||
})
|
||||
|
|
@ -80,61 +82,37 @@ export class StarNagService {
|
|||
ipcMain.handle('star-nag:openWeb', () => this.openWeb())
|
||||
ipcMain.handle('star-nag:starOrca', () => this.starOrcaFromNag())
|
||||
ipcMain.handle('star-nag:forceShow', () => this.forceShow())
|
||||
ipcMain.handle('star-nag:agentValueMoment', () => this.prepareAgentValueMoment())
|
||||
ipcMain.handle('star-nag:showAgentValueMoment', () => this.showPreparedAgentValueMoment())
|
||||
ipcMain.handle('star-nag:onboardingCompleted', () => this.onboardingCompleted())
|
||||
}
|
||||
|
||||
// ── State helpers ─────────────────────────────────────────────────
|
||||
|
||||
private ensureBaseline(): void {
|
||||
const ui = this.store.getUI()
|
||||
const currentVersion = app.getVersion()
|
||||
if (ui.starNagAppVersion === currentVersion && ui.starNagBaselineAgents != null) {
|
||||
return
|
||||
}
|
||||
// Why: reset both the baseline and the threshold so the user gets a fresh
|
||||
// nag countdown after each update. Past dismissal state is intentionally
|
||||
// discarded — shipping new value is the whole reason we bother asking
|
||||
// again. `starNagCompleted` is preserved so we never re-ask someone who
|
||||
// already starred.
|
||||
this.store.updateUI({
|
||||
starNagAppVersion: currentVersion,
|
||||
starNagBaselineAgents: this.stats.getTotalAgentsSpawned(),
|
||||
starNagNextThreshold: STAR_NAG_INITIAL_THRESHOLD
|
||||
})
|
||||
}
|
||||
|
||||
private handleAgentSpawned(total: number): void {
|
||||
if (this.promptVisible || this.evaluating) {
|
||||
return
|
||||
}
|
||||
const ui = this.store.getUI()
|
||||
if (ui.starNagCompleted) {
|
||||
return
|
||||
}
|
||||
if (this.isCooldownActive(ui.starNagDeferredUntil)) {
|
||||
return
|
||||
}
|
||||
// Guard against drift: if the version changed since last boot but we
|
||||
// haven't rehydrated yet (e.g. in-process update on Linux AppImage), fix
|
||||
// the baseline before evaluating the threshold so we don't instantly fire.
|
||||
const currentVersion = app.getVersion()
|
||||
if (ui.starNagAppVersion !== currentVersion) {
|
||||
this.ensureBaseline()
|
||||
return
|
||||
}
|
||||
const baseline = ui.starNagBaselineAgents ?? total
|
||||
const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD
|
||||
const sinceBaseline = total - baseline
|
||||
if (sinceBaseline < threshold) {
|
||||
if (
|
||||
!shouldShowStarNagThresholdPrompt({
|
||||
store: this.store,
|
||||
stats: this.stats,
|
||||
total,
|
||||
promptVisible: this.promptVisible,
|
||||
evaluating: this.evaluating,
|
||||
isCooldownActive: (deferredUntil) => this.isCooldownActive(deferredUntil)
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
void this.maybeShow('threshold')
|
||||
}
|
||||
|
||||
private async maybeShow(source: StarNagPromptSource): Promise<void> {
|
||||
private async maybeShow(
|
||||
source: StarNagPromptSource,
|
||||
surface: StarNagSurface = 'card'
|
||||
): Promise<boolean> {
|
||||
if (this.promptVisible || this.evaluating) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
this.evaluating = true
|
||||
this.setEvaluating(true)
|
||||
try {
|
||||
// Why: checkOrcaStarred lets us skip users who already starred outside
|
||||
// the app. When gh cannot tell us, keep the prompt available but route
|
||||
|
|
@ -143,29 +121,35 @@ export class StarNagService {
|
|||
const starred = await checkOrcaStarred()
|
||||
if (this.store.getUI().starNagCompleted) {
|
||||
this.pendingForceShow = false
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (starred === null) {
|
||||
this.broadcastShow(source, 'web')
|
||||
return
|
||||
return this.broadcastShow(source, 'web', surface)
|
||||
}
|
||||
if (starred) {
|
||||
this.trackAlreadyStarredSuppressed(source)
|
||||
// Already starred somewhere — lock in the permanent suppression so we
|
||||
// stop recomputing thresholds on every spawn.
|
||||
this.markCompleted()
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (this.promptVisible) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
this.broadcastShow(source, 'gh')
|
||||
return this.broadcastShow(source, 'gh', surface)
|
||||
} finally {
|
||||
this.evaluating = false
|
||||
this.setEvaluating(false)
|
||||
this.flushPendingForceShow()
|
||||
}
|
||||
}
|
||||
|
||||
private setEvaluating(value: boolean): void {
|
||||
this.evaluating = value
|
||||
if (!value) {
|
||||
this.flushPendingOnboardingCompleted()
|
||||
}
|
||||
}
|
||||
|
||||
private flushPendingForceShow(): void {
|
||||
if (!this.pendingForceShow || this.evaluating) {
|
||||
return
|
||||
|
|
@ -177,39 +161,39 @@ export class StarNagService {
|
|||
this.broadcastShow('force_show', 'gh')
|
||||
}
|
||||
|
||||
private broadcastShow(source: StarNagPromptSource, mode: StarNagPromptMode): boolean {
|
||||
private flushPendingOnboardingCompleted(): void {
|
||||
if (!this.pendingOnboardingCompleted || this.evaluating) {
|
||||
return
|
||||
}
|
||||
this.pendingOnboardingCompleted = false
|
||||
void this.onboardingCompleted()
|
||||
}
|
||||
|
||||
private broadcastShow(
|
||||
source: StarNagPromptSource,
|
||||
mode: StarNagPromptMode,
|
||||
surface: StarNagSurface = 'card'
|
||||
): boolean {
|
||||
const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed())
|
||||
if (!win) {
|
||||
this.promptVisible = false
|
||||
this.promptSession = null
|
||||
return false
|
||||
}
|
||||
const context = this.createPromptContext(source, mode)
|
||||
win.webContents.send('star-nag:show', { mode })
|
||||
const context = createStarNagPromptContext(this.store, this.stats, source, mode)
|
||||
win.webContents.send('star-nag:show', { mode, surface })
|
||||
this.promptVisible = true
|
||||
this.promptSession = context
|
||||
this.trackOutcome('shown')
|
||||
this.logConsoleEvent('star_nag_shown', source)
|
||||
logStarNagConsoleEvent(this.store, this.stats, 'star_nag_shown', source)
|
||||
return true
|
||||
}
|
||||
|
||||
private createPromptContext(
|
||||
source: StarNagPromptSource,
|
||||
mode: StarNagPromptMode
|
||||
): StarNagPromptContext {
|
||||
const ui = this.store.getUI()
|
||||
const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD
|
||||
const agentsSinceBaseline = Math.max(
|
||||
0,
|
||||
this.stats.getTotalAgentsSpawned() - (ui.starNagBaselineAgents ?? 0)
|
||||
)
|
||||
return {
|
||||
source,
|
||||
mode,
|
||||
threshold,
|
||||
agents_since_baseline: agentsSinceBaseline,
|
||||
agents_since_baseline_bucket: bucketStarNagAgentsSinceBaseline(agentsSinceBaseline),
|
||||
...getCohortAtEmit()
|
||||
private broadcastHide(): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('star-nag:hide')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -226,31 +210,45 @@ export class StarNagService {
|
|||
|
||||
private trackAlreadyStarredSuppressed(source: StarNagPromptSource): void {
|
||||
track('star_nag_outcome', {
|
||||
...this.createPromptContext(source, 'gh'),
|
||||
...createStarNagPromptContext(this.store, this.stats, source, 'gh'),
|
||||
outcome: 'already_starred_suppressed'
|
||||
})
|
||||
}
|
||||
|
||||
private logConsoleEvent(
|
||||
event: 'star_nag_shown' | 'star_nag_dismissed' | 'star_nag_later',
|
||||
source: StarNagPromptSource,
|
||||
nextThreshold?: number
|
||||
): void {
|
||||
const ui = this.store.getUI()
|
||||
const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD
|
||||
const agentsSinceBaseline = this.stats.getTotalAgentsSpawned() - (ui.starNagBaselineAgents ?? 0)
|
||||
// ── Public actions (invoked from IPC) ─────────────────────────────
|
||||
|
||||
console.info({
|
||||
event,
|
||||
app_version: app.getVersion(),
|
||||
threshold,
|
||||
agents_since_baseline: agentsSinceBaseline,
|
||||
source,
|
||||
...(nextThreshold === undefined ? {} : { next_threshold: nextThreshold })
|
||||
private async prepareAgentValueMoment(): Promise<AgentValueMomentPreparation> {
|
||||
return this.agentValueMoment.prepare()
|
||||
}
|
||||
|
||||
private showPreparedAgentValueMoment(): void {
|
||||
// Why: renderer re-confirms "not typing / no active agent" after the slow
|
||||
// gh check before invoking this show step.
|
||||
this.agentValueMoment.showPrepared()
|
||||
}
|
||||
|
||||
private async onboardingCompleted(): Promise<void> {
|
||||
await handleStarNagOnboardingCompleted({
|
||||
store: this.store,
|
||||
isCooldownActive: (deferredUntil) => this.isCooldownActive(deferredUntil),
|
||||
isEvaluating: () => this.evaluating,
|
||||
queueAfterEvaluation: () => {
|
||||
this.pendingOnboardingCompleted = true
|
||||
},
|
||||
isPromptVisible: () => this.promptVisible,
|
||||
clearVisiblePrompt: () => this.clearVisiblePromptForOnboarding(),
|
||||
showToast: () => this.maybeShow('onboarding_completed', 'toast')
|
||||
})
|
||||
}
|
||||
|
||||
// ── Public actions (invoked from IPC) ─────────────────────────────
|
||||
private clearVisiblePromptForOnboarding(): void {
|
||||
// Why: onboarding completion is a stronger app-level value moment than a
|
||||
// threshold card that may have fired behind the wizard.
|
||||
this.promptVisible = false
|
||||
this.promptSession = null
|
||||
this.agentValueMoment.clear()
|
||||
this.broadcastHide()
|
||||
}
|
||||
|
||||
/**
|
||||
* User closed the notification without starring → defer threshold prompts
|
||||
|
|
@ -271,7 +269,9 @@ export class StarNagService {
|
|||
const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD
|
||||
const nextThreshold = threshold * 2
|
||||
this.trackOutcome(outcome, { nextThreshold, cooldownDays: STAR_NAG_COOLDOWN_DAYS })
|
||||
this.logConsoleEvent(
|
||||
logStarNagConsoleEvent(
|
||||
this.store,
|
||||
this.stats,
|
||||
outcome === 'later' ? 'star_nag_later' : 'star_nag_dismissed',
|
||||
session.source,
|
||||
nextThreshold
|
||||
|
|
@ -297,7 +297,11 @@ export class StarNagService {
|
|||
}
|
||||
session.openedRepoTracked = true
|
||||
trackStarNagSessionOutcome(session, 'opened_repo', { mode: 'web' })
|
||||
this.markCompleted()
|
||||
// Why: opening GitHub is only a handoff, not verified star success. Keep the
|
||||
// ask quiet for the normal cooldown, but do not set starNagCompleted.
|
||||
deferAfterStarNagWebHandoff(this.store, this.stats, STAR_NAG_COOLDOWN_MS)
|
||||
this.promptVisible = false
|
||||
this.promptSession = null
|
||||
}
|
||||
|
||||
private async starOrcaFromNag(): Promise<boolean> {
|
||||
|
|
@ -320,24 +324,11 @@ export class StarNagService {
|
|||
}
|
||||
|
||||
private async runStarOrcaAttempt(session: StarNagPromptSession): Promise<boolean> {
|
||||
trackStarNagSessionOutcome(session, 'star_clicked', { mode: 'gh' })
|
||||
const starred = await starOrca()
|
||||
if (!starred) {
|
||||
trackStarNagSessionOutcome(session, 'direct_star_failed', { mode: 'gh' })
|
||||
if (this.promptSession === session) {
|
||||
session.mode = 'web'
|
||||
}
|
||||
return false
|
||||
const starred = await runStarNagDirectStarAttempt(session)
|
||||
if (starred) {
|
||||
this.markCompleted()
|
||||
}
|
||||
trackStarNagSessionOutcome(session, 'direct_star_succeeded', { mode: 'gh' })
|
||||
// Why: app_starred_orca remains the canonical cross-surface success event;
|
||||
// star_nag_outcome is only the nag-funnel companion.
|
||||
track('app_starred_orca', {
|
||||
source: 'star_nag',
|
||||
...getCohortAtEmit()
|
||||
})
|
||||
this.markCompleted()
|
||||
return true
|
||||
return starred
|
||||
}
|
||||
|
||||
/** User successfully starred or opted out → never nag again. */
|
||||
|
|
@ -346,6 +337,8 @@ export class StarNagService {
|
|||
this.promptVisible = false
|
||||
this.promptSession = null
|
||||
this.pendingForceShow = false
|
||||
this.pendingOnboardingCompleted = false
|
||||
this.agentValueMoment.clear()
|
||||
}
|
||||
|
||||
private isCooldownActive(deferredUntil: number | null | undefined): boolean {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -1721,7 +1721,10 @@ export type PreloadApi = {
|
|||
listTransitions: (args: { key: string; siteId?: string }) => Promise<JiraTransition[]>
|
||||
}
|
||||
starNag: {
|
||||
onShow: (callback: (payload?: { mode?: 'gh' | 'web' }) => void) => () => void
|
||||
onShow: (
|
||||
callback: (payload?: { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' }) => void
|
||||
) => () => void
|
||||
onHide: (callback: () => void) => () => void
|
||||
dismiss: () => Promise<void>
|
||||
later: () => Promise<void>
|
||||
complete: () => Promise<void>
|
||||
|
|
@ -1729,6 +1732,9 @@ export type PreloadApi = {
|
|||
openWeb: () => Promise<void>
|
||||
starOrca: () => Promise<boolean>
|
||||
forceShow: () => Promise<void>
|
||||
agentValueMoment: () => Promise<{ status: 'ready'; mode: 'gh' | 'web' } | { status: 'skipped' }>
|
||||
showAgentValueMoment: () => Promise<void>
|
||||
onboardingCompleted: () => Promise<void>
|
||||
}
|
||||
/** Fire-and-forget track. Loose typing at the IPC boundary on purpose —
|
||||
* the main-side validator is the single enforcement point. Renderer call
|
||||
|
|
|
|||
|
|
@ -1526,21 +1526,33 @@ const api = {
|
|||
},
|
||||
|
||||
starNag: {
|
||||
onShow: (callback: (payload?: { mode?: 'gh' | 'web' }) => void): (() => void) => {
|
||||
onShow: (
|
||||
callback: (payload?: { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' }) => void
|
||||
): (() => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
payload?: { mode?: 'gh' | 'web' }
|
||||
payload?: { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' }
|
||||
): void => callback(payload)
|
||||
ipcRenderer.on('star-nag:show', listener)
|
||||
return () => ipcRenderer.removeListener('star-nag:show', listener)
|
||||
},
|
||||
onHide: (callback: () => void): (() => void) => {
|
||||
const listener = (): void => callback()
|
||||
ipcRenderer.on('star-nag:hide', listener)
|
||||
return () => ipcRenderer.removeListener('star-nag:hide', listener)
|
||||
},
|
||||
dismiss: (): Promise<void> => ipcRenderer.invoke('star-nag:dismiss'),
|
||||
later: (): Promise<void> => ipcRenderer.invoke('star-nag:later'),
|
||||
complete: (): Promise<void> => ipcRenderer.invoke('star-nag:complete'),
|
||||
disable: (): Promise<void> => ipcRenderer.invoke('star-nag:disable'),
|
||||
openWeb: (): Promise<void> => ipcRenderer.invoke('star-nag:openWeb'),
|
||||
starOrca: (): Promise<boolean> => ipcRenderer.invoke('star-nag:starOrca'),
|
||||
forceShow: (): Promise<void> => ipcRenderer.invoke('star-nag:forceShow')
|
||||
forceShow: (): Promise<void> => ipcRenderer.invoke('star-nag:forceShow'),
|
||||
agentValueMoment: (): Promise<
|
||||
{ status: 'ready'; mode: 'gh' | 'web' } | { status: 'skipped' }
|
||||
> => ipcRenderer.invoke('star-nag:agentValueMoment'),
|
||||
showAgentValueMoment: (): Promise<void> => ipcRenderer.invoke('star-nag:showAgentValueMoment'),
|
||||
onboardingCompleted: (): Promise<void> => ipcRenderer.invoke('star-nag:onboardingCompleted')
|
||||
},
|
||||
|
||||
// Why: telemetry uses a loose untyped surface at the preload boundary on
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ import { dispatchWindowCloseRequest } from './components/window-close-request-co
|
|||
import { useSystemPrefersDark } from './components/terminal-pane/use-system-prefers-dark'
|
||||
import RightSidebar from './components/right-sidebar'
|
||||
import { StarNagCard } from './components/StarNagCard'
|
||||
import { StarNagAgentValueMomentObserver } from './components/star-nag/StarNagAgentValueMomentObserver'
|
||||
import { StarNagToastHost } from './components/star-nag/StarNagToastHost'
|
||||
import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface'
|
||||
import { ZoomOverlay } from './components/ZoomOverlay'
|
||||
import { onOnboardingReopened } from './components/onboarding/show-onboarding-event'
|
||||
|
|
@ -2429,6 +2431,15 @@ function App(): React.JSX.Element {
|
|||
>
|
||||
<StarNagCard />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
<RecoverableRenderErrorBoundary
|
||||
boundaryId="overlay.star-nag-toast"
|
||||
surface="overlay"
|
||||
resetKey={activeView}
|
||||
compact
|
||||
>
|
||||
<StarNagToastHost />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
<StarNagAgentValueMomentObserver />
|
||||
{/* Why: the existing-user opt-in banner mounts at App root so it
|
||||
renders once per renderer session, not per view. It gates
|
||||
internally on the cohort markers populated by the migration,
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,13 @@ export function ShortcutKeyCombo({
|
|||
return (
|
||||
<span
|
||||
className={cn('inline-flex items-center gap-1', className)}
|
||||
title={doubleTap && keys.length > 0 ? translate("auto.components.ShortcutKeyCombo.07eb4985a1", "Double-tap {{value0}}", { value0: keys[0] }) : undefined}
|
||||
title={
|
||||
doubleTap && keys.length > 0
|
||||
? translate('auto.components.ShortcutKeyCombo.07eb4985a1', 'Double-tap {{value0}}', {
|
||||
value0: keys[0]
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{keys.map((key, index) => (
|
||||
<React.Fragment key={`${key}-${index}`}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { StarNagCard } from './StarNagCard'
|
||||
|
||||
type ShowPayload = { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' }
|
||||
type ShowCallback = (payload?: ShowPayload) => void
|
||||
|
||||
type StarNagApi = {
|
||||
onShow: (callback: ShowCallback) => () => void
|
||||
onHide: (callback: () => void) => () => void
|
||||
dismiss: ReturnType<typeof vi.fn>
|
||||
later: ReturnType<typeof vi.fn>
|
||||
openWeb: ReturnType<typeof vi.fn>
|
||||
starOrca: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
type ShellApi = {
|
||||
openUrl: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function setApi(api: { starNag: StarNagApi; shell: ShellApi }): void {
|
||||
;(window as unknown as { api: typeof api }).api = api
|
||||
}
|
||||
|
||||
function renderCard(): { root: Root; container: HTMLDivElement } {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(<StarNagCard />)
|
||||
})
|
||||
return { root, container }
|
||||
}
|
||||
|
||||
describe('StarNagCard', () => {
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
let showCallback: ShowCallback | null = null
|
||||
let starNag: StarNagApi
|
||||
let shell: ShellApi
|
||||
|
||||
beforeEach(() => {
|
||||
showCallback = null
|
||||
starNag = {
|
||||
onShow: vi.fn((callback: ShowCallback) => {
|
||||
showCallback = callback
|
||||
return vi.fn()
|
||||
}),
|
||||
onHide: vi.fn(() => vi.fn()),
|
||||
dismiss: vi.fn().mockResolvedValue(undefined),
|
||||
later: vi.fn().mockResolvedValue(undefined),
|
||||
openWeb: vi.fn().mockResolvedValue(undefined),
|
||||
starOrca: vi.fn().mockResolvedValue(true)
|
||||
}
|
||||
shell = {
|
||||
openUrl: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
setApi({ starNag, shell })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount())
|
||||
}
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
})
|
||||
|
||||
it('switches to the explicit GitHub fallback when direct starring fails', async () => {
|
||||
starNag.starOrca.mockResolvedValueOnce(false)
|
||||
;({ root, container } = renderCard())
|
||||
|
||||
act(() => showCallback?.({ mode: 'gh', surface: 'card' }))
|
||||
expect(container.textContent).toContain('Star on GitHub')
|
||||
const initialButton = Array.from(container.querySelectorAll('button')).find((candidate) =>
|
||||
candidate.textContent?.includes('Star on GitHub')
|
||||
)
|
||||
expect(initialButton?.className).toContain('bg-amber-400/15')
|
||||
expect(initialButton?.parentElement?.className).toContain('flex gap-2')
|
||||
|
||||
await act(async () => {
|
||||
initialButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(starNag.starOrca).toHaveBeenCalledTimes(1)
|
||||
expect(shell.openUrl).not.toHaveBeenCalled()
|
||||
expect(starNag.openWeb).not.toHaveBeenCalled()
|
||||
expect(container.textContent).toContain('Open GitHub')
|
||||
const fallbackButton = Array.from(container.querySelectorAll('button')).find((candidate) =>
|
||||
candidate.textContent?.includes('Open GitHub')
|
||||
)
|
||||
expect(fallbackButton?.className).toContain('bg-amber-400/15')
|
||||
expect(fallbackButton?.parentElement?.textContent).toContain('Later')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ExternalLink, Star, X } from 'lucide-react'
|
||||
import { Card } from './ui/card'
|
||||
import { Button } from './ui/button'
|
||||
|
|
@ -33,19 +33,35 @@ export function StarNagCard(): React.JSX.Element | null {
|
|||
const updateCardVisible = updateStatus.state !== 'idle' && updateStatus.state !== 'not-available'
|
||||
|
||||
useEffect(() => {
|
||||
return window.api.starNag.onShow((payload) => {
|
||||
const unsubscribeShow = window.api.starNag.onShow((payload) => {
|
||||
if (payload?.surface && payload.surface !== 'card') {
|
||||
setBusy(false)
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
setMode(payload?.mode === 'web' ? 'web' : 'gh')
|
||||
setVisible(true)
|
||||
})
|
||||
const unsubscribeHide = window.api.starNag.onHide(() => {
|
||||
setBusy(false)
|
||||
setVisible(false)
|
||||
})
|
||||
return () => {
|
||||
unsubscribeShow()
|
||||
unsubscribeHide()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleClose = (): void => {
|
||||
const handleClose = useCallback((): void => {
|
||||
if (busy) {
|
||||
return
|
||||
}
|
||||
setVisible(false)
|
||||
// Why: fire-and-forget. If persisting the dismissal fails the worst case
|
||||
// is we re-fire the same threshold on next launch — not worth blocking
|
||||
// the close animation on.
|
||||
void window.api.starNag.dismiss()
|
||||
}
|
||||
}, [busy])
|
||||
|
||||
const handleLater = (): void => {
|
||||
if (busy) {
|
||||
|
|
@ -66,29 +82,37 @@ export function StarNagCard(): React.JSX.Element | null {
|
|||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- handleClose closes
|
||||
// over stable refs; re-binding on each render is unnecessary.
|
||||
}, [visible])
|
||||
}, [handleClose, visible])
|
||||
|
||||
if (!visible) {
|
||||
return null
|
||||
}
|
||||
|
||||
const primaryActionClass =
|
||||
'min-w-0 flex-1 gap-1.5 border-amber-400/60 bg-amber-400/15 text-amber-800 hover:bg-amber-400/25 dark:text-amber-100'
|
||||
|
||||
const handleStar = async (): Promise<void> => {
|
||||
if (busy) {
|
||||
return
|
||||
}
|
||||
if (mode === 'web') {
|
||||
setBusy(true)
|
||||
const openGithubFallback = async (): Promise<boolean> => {
|
||||
try {
|
||||
await window.api.shell.openUrl(ORCA_REPO_URL)
|
||||
await window.api.starNag.openWeb()
|
||||
if (mountedRef.current) {
|
||||
setVisible(false)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
// Why: failing to open the external browser is recoverable; keep the
|
||||
// prompt available so the user can retry or choose another action.
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (mode === 'web') {
|
||||
setBusy(true)
|
||||
try {
|
||||
await openGithubFallback()
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setBusy(false)
|
||||
|
|
@ -102,20 +126,24 @@ export function StarNagCard(): React.JSX.Element | null {
|
|||
ok = await window.api.starNag.starOrca()
|
||||
} catch {
|
||||
ok = false
|
||||
}
|
||||
try {
|
||||
if (!ok) {
|
||||
// Why: preflight chooses whether direct starring should be offered. If
|
||||
// the later star call fails, let the user choose the browser handoff.
|
||||
if (mountedRef.current) {
|
||||
setMode('web')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (mountedRef.current) {
|
||||
setVisible(false)
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
if (!ok) {
|
||||
if (mountedRef.current) {
|
||||
setMode('web')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (mountedRef.current) {
|
||||
setVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -142,6 +170,7 @@ export function StarNagCard(): React.JSX.Element | null {
|
|||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={handleClose}
|
||||
disabled={busy}
|
||||
aria-label={translate('auto.components.StarNagCard.b5e685e4d9', 'Dismiss')}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
|
|
@ -155,31 +184,37 @@ export function StarNagCard(): React.JSX.Element | null {
|
|||
)}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => void handleStar()}
|
||||
disabled={busy}
|
||||
className="mt-0.5 w-full gap-1.5"
|
||||
>
|
||||
{mode === 'web' ? <ExternalLink className="size-3.5" /> : <Star className="size-3.5" />}
|
||||
{busy
|
||||
? mode === 'web'
|
||||
? translate('auto.components.StarNagCard.d32015fec7', 'Opening...')
|
||||
: translate('auto.components.StarNagCard.af3c9bbb37', 'Starring…')
|
||||
: mode === 'web'
|
||||
? translate('auto.components.StarNagCard.157bb5ecbb', 'Open GitHub')
|
||||
: translate('auto.components.StarNagCard.2d67b6c849', 'Star on GitHub')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 w-full"
|
||||
onClick={handleLater}
|
||||
disabled={busy}
|
||||
>
|
||||
{translate('auto.components.StarNagCard.8c967b4d15', 'Later')}
|
||||
</Button>
|
||||
<div className="mt-0.5 flex gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => void handleStar()}
|
||||
disabled={busy}
|
||||
className={primaryActionClass}
|
||||
>
|
||||
{mode === 'web' ? (
|
||||
<ExternalLink className="size-3.5" />
|
||||
) : (
|
||||
<Star className="size-3.5" />
|
||||
)}
|
||||
{busy
|
||||
? mode === 'web'
|
||||
? translate('auto.components.StarNagCard.d32015fec7', 'Opening...')
|
||||
: translate('auto.components.StarNagCard.af3c9bbb37', 'Starring…')
|
||||
: mode === 'web'
|
||||
? translate('auto.components.StarNagCard.157bb5ecbb', 'Open GitHub')
|
||||
: translate('auto.components.StarNagCard.2d67b6c849', 'Star on GitHub')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-[84px]"
|
||||
onClick={handleLater}
|
||||
disabled={busy}
|
||||
>
|
||||
{translate('auto.components.StarNagCard.8c967b4d15', 'Later')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createElement, useEffect } from 'react'
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultOnboardingState } from '../../../../shared/constants'
|
||||
import type { OnboardingState } from '../../../../shared/types'
|
||||
|
||||
const trackMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
|
|
@ -9,12 +16,79 @@ vi.mock('@/lib/telemetry', () => ({
|
|||
import {
|
||||
buildCompletedOnboardingNotificationSettings,
|
||||
buildOnboardingDismissedPayload,
|
||||
useCloseWith,
|
||||
type DismissedExtras,
|
||||
trackOnboardingDismissed
|
||||
} from './use-onboarding-flow-persistence'
|
||||
import type { StepNumber } from './use-onboarding-flow-types'
|
||||
|
||||
type CloseWithCallback = (
|
||||
outcome: 'completed' | 'dismissed',
|
||||
checklist: Partial<OnboardingState['checklist']>,
|
||||
lastStepReached: StepNumber,
|
||||
completedPath?: 'open_folder' | 'clone_url' | 'add_project_modal',
|
||||
dismissedExtras?: DismissedExtras
|
||||
) => Promise<boolean>
|
||||
|
||||
function makeOnboardingState(): OnboardingState {
|
||||
return {
|
||||
...getDefaultOnboardingState(),
|
||||
closedAt: Date.now(),
|
||||
outcome: 'completed',
|
||||
lastCompletedStep: 5
|
||||
}
|
||||
}
|
||||
|
||||
function setApi(api: {
|
||||
onboarding: { update: ReturnType<typeof vi.fn> }
|
||||
starNag: { onboardingCompleted: ReturnType<typeof vi.fn> }
|
||||
}): void {
|
||||
;(window as unknown as { api: typeof api }).api = api
|
||||
}
|
||||
|
||||
function CloseWithProbe(props: { onReady: (closeWith: CloseWithCallback) => void }): null {
|
||||
const closeWith = useCloseWith({
|
||||
onOnboardingChange: vi.fn(),
|
||||
onboardingChecklist: makeOnboardingState().checklist,
|
||||
startTimeRef: { current: Date.now() },
|
||||
setError: vi.fn()
|
||||
})
|
||||
useEffect(() => props.onReady(closeWith), [closeWith, props])
|
||||
return null
|
||||
}
|
||||
|
||||
function renderCloseWithProbe(onReady: (closeWith: CloseWithCallback) => void): {
|
||||
root: Root
|
||||
container: HTMLDivElement
|
||||
} {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
act(() => root.render(createElement(CloseWithProbe, { onReady })))
|
||||
return { root, container }
|
||||
}
|
||||
|
||||
describe('onboarding flow persistence', () => {
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
trackMock.mockClear()
|
||||
setApi({
|
||||
onboarding: { update: vi.fn().mockResolvedValue(makeOnboardingState()) },
|
||||
starNag: { onboardingCompleted: vi.fn().mockResolvedValue(undefined) }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount())
|
||||
}
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('builds dismissed telemetry with the triggering advance path', () => {
|
||||
|
|
@ -64,4 +138,30 @@ describe('onboarding flow persistence', () => {
|
|||
customSoundVolume: 60
|
||||
})
|
||||
})
|
||||
|
||||
it('schedules the star toast after every completed close path', async () => {
|
||||
let closeWith: CloseWithCallback | null = null
|
||||
;({ root, container } = renderCloseWithProbe((callback) => {
|
||||
closeWith = callback
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await closeWith?.('completed', {}, 5)
|
||||
})
|
||||
|
||||
const api = (
|
||||
window as unknown as {
|
||||
api: {
|
||||
starNag: { onboardingCompleted: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
}
|
||||
).api
|
||||
expect(api.starNag.onboardingCompleted).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(0)
|
||||
})
|
||||
|
||||
expect(api.starNag.onboardingCompleted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,186 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { StarNagAgentValueMomentObserver } from './StarNagAgentValueMomentObserver'
|
||||
|
||||
type StarNagApi = {
|
||||
agentValueMoment: ReturnType<typeof vi.fn>
|
||||
showAgentValueMoment: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function setStarNagApi(api: StarNagApi): void {
|
||||
;(window as unknown as { api: { starNag: StarNagApi } }).api = { starNag: api }
|
||||
}
|
||||
|
||||
function entry(overrides: Partial<AgentStatusEntry>): AgentStatusEntry {
|
||||
return {
|
||||
state: 'working',
|
||||
prompt: 'Review this change',
|
||||
updatedAt: 1,
|
||||
stateStartedAt: 1,
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
stateHistory: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderObserver(): { root: Root; container: HTMLDivElement } {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(<StarNagAgentValueMomentObserver />)
|
||||
})
|
||||
return { root, container }
|
||||
}
|
||||
|
||||
function setAgentEntries(entries: Record<string, AgentStatusEntry>): void {
|
||||
act(() => {
|
||||
useAppStore.setState((state) => ({
|
||||
agentStatusByPaneKey: entries,
|
||||
agentStatusEpoch: state.agentStatusEpoch + 1
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
function createDeferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('StarNagAgentValueMomentObserver', () => {
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
let agentValueMoment: ReturnType<typeof vi.fn>
|
||||
let showAgentValueMoment: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
useAppStore.setState({ agentStatusByPaneKey: {}, agentStatusEpoch: 0 })
|
||||
agentValueMoment = vi.fn().mockResolvedValue({ status: 'ready', mode: 'gh' })
|
||||
showAgentValueMoment = vi.fn().mockResolvedValue(undefined)
|
||||
setStarNagApi({ agentValueMoment, showAgentValueMoment })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount())
|
||||
}
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('asks main after a prompted non-interrupted done transition and idle window', async () => {
|
||||
;({ root, container } = renderObserver())
|
||||
|
||||
setAgentEntries({ pane: entry({ state: 'working' }) })
|
||||
setAgentEntries({ pane: entry({ state: 'done' }) })
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1200)
|
||||
})
|
||||
|
||||
expect(agentValueMoment).toHaveBeenCalledTimes(1)
|
||||
expect(showAgentValueMoment).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores interrupted or empty-prompt completions', () => {
|
||||
;({ root, container } = renderObserver())
|
||||
|
||||
setAgentEntries({ pane: entry({ state: 'working', prompt: '' }) })
|
||||
setAgentEntries({ pane: entry({ state: 'done', prompt: '', interrupted: true }) })
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2400)
|
||||
})
|
||||
|
||||
expect(agentValueMoment).not.toHaveBeenCalled()
|
||||
expect(showAgentValueMoment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('waits for other live agents and recent typing to quiet', async () => {
|
||||
;({ root, container } = renderObserver())
|
||||
|
||||
setAgentEntries({
|
||||
done: entry({ state: 'working', paneKey: 'tab-1:leaf-1' }),
|
||||
active: entry({ state: 'working', paneKey: 'tab-2:leaf-1', prompt: 'Keep working' })
|
||||
})
|
||||
setAgentEntries({
|
||||
done: entry({ state: 'done', paneKey: 'tab-1:leaf-1' }),
|
||||
active: entry({ state: 'working', paneKey: 'tab-2:leaf-1', prompt: 'Keep working' })
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1200)
|
||||
})
|
||||
expect(agentValueMoment).not.toHaveBeenCalled()
|
||||
expect(showAgentValueMoment).not.toHaveBeenCalled()
|
||||
|
||||
setAgentEntries({
|
||||
done: entry({ state: 'done', paneKey: 'tab-1:leaf-1' }),
|
||||
active: entry({ state: 'done', paneKey: 'tab-2:leaf-1', prompt: 'Keep working' })
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(600)
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', ctrlKey: true }))
|
||||
vi.advanceTimersByTime(600)
|
||||
})
|
||||
expect(agentValueMoment).not.toHaveBeenCalled()
|
||||
expect(showAgentValueMoment).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1200)
|
||||
})
|
||||
|
||||
expect(agentValueMoment).toHaveBeenCalledTimes(1)
|
||||
expect(showAgentValueMoment).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rechecks idle after main prepares the prompt', async () => {
|
||||
const preparation = createDeferred<{ status: 'ready'; mode: 'gh' }>()
|
||||
agentValueMoment.mockReturnValueOnce(preparation.promise)
|
||||
;({ root, container } = renderObserver())
|
||||
|
||||
setAgentEntries({ pane: entry({ state: 'working' }) })
|
||||
setAgentEntries({ pane: entry({ state: 'done' }) })
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1200)
|
||||
})
|
||||
expect(agentValueMoment).toHaveBeenCalledTimes(1)
|
||||
|
||||
setAgentEntries({
|
||||
pane: entry({ state: 'done', paneKey: 'tab-1:leaf-1' }),
|
||||
active: entry({ state: 'working', paneKey: 'tab-2:leaf-1' })
|
||||
})
|
||||
await act(async () => {
|
||||
preparation.resolve({ status: 'ready', mode: 'gh' })
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1200)
|
||||
})
|
||||
expect(showAgentValueMoment).not.toHaveBeenCalled()
|
||||
|
||||
setAgentEntries({
|
||||
pane: entry({ state: 'done', paneKey: 'tab-1:leaf-1' }),
|
||||
active: entry({ state: 'done', paneKey: 'tab-2:leaf-1' })
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1200)
|
||||
})
|
||||
|
||||
expect(agentValueMoment).toHaveBeenCalledTimes(1)
|
||||
expect(showAgentValueMoment).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
// Why: leave a short quiet window after agents finish so the prompt does not
|
||||
// interrupt follow-up typing or status churn from the completed run.
|
||||
const QUIET_WINDOW_MS = 1200
|
||||
const CHECK_DELAY_MS = 1200
|
||||
const ACTIVE_AGENT_STATES = new Set(['working', 'waiting', 'blocked'])
|
||||
const NON_TYPING_MODIFIER_KEYS = new Set(['Alt', 'Control', 'Meta', 'Shift'])
|
||||
|
||||
type AgentStatusSnapshot = Record<string, AgentStatusEntry>
|
||||
type AgentValueMomentPreparation = Awaited<ReturnType<typeof window.api.starNag.agentValueMoment>>
|
||||
|
||||
function hasMeaningfulPrompt(entry: AgentStatusEntry): boolean {
|
||||
if (entry.prompt.trim()) {
|
||||
return true
|
||||
}
|
||||
return entry.stateHistory.some((history) => history.prompt.trim())
|
||||
}
|
||||
|
||||
function hasActiveAgent(entries: AgentStatusSnapshot): boolean {
|
||||
return Object.values(entries).some((entry) => ACTIVE_AGENT_STATES.has(entry.state))
|
||||
}
|
||||
|
||||
function hasSuccessfulDoneTransition(
|
||||
previous: AgentStatusSnapshot,
|
||||
current: AgentStatusSnapshot
|
||||
): boolean {
|
||||
for (const [paneKey, entry] of Object.entries(current)) {
|
||||
const previousEntry = previous[paneKey]
|
||||
if (
|
||||
previousEntry &&
|
||||
previousEntry.state !== 'done' &&
|
||||
entry.state === 'done' &&
|
||||
!entry.interrupted &&
|
||||
hasMeaningfulPrompt(entry)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isTypingKeyEvent(event: KeyboardEvent): boolean {
|
||||
return !NON_TYPING_MODIFIER_KEYS.has(event.key)
|
||||
}
|
||||
|
||||
export function StarNagAgentValueMomentObserver(): null {
|
||||
const { agentStatusByPaneKey, agentStatusEpoch } = useAppStore(
|
||||
useShallow((state) => ({
|
||||
agentStatusByPaneKey: state.agentStatusByPaneKey,
|
||||
agentStatusEpoch: state.agentStatusEpoch
|
||||
}))
|
||||
)
|
||||
const previousEntriesRef = useRef<AgentStatusSnapshot | null>(null)
|
||||
const latestEntriesRef = useRef(agentStatusByPaneKey)
|
||||
const pendingRef = useRef(false)
|
||||
const requestedRef = useRef(false)
|
||||
const preparationRef = useRef<AgentValueMomentPreparation | null>(null)
|
||||
const lastTypingAtRef = useRef(0)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const scheduleCheck = useCallback((): void => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null
|
||||
if (!pendingRef.current || requestedRef.current) {
|
||||
return
|
||||
}
|
||||
const elapsedSinceTyping = Date.now() - lastTypingAtRef.current
|
||||
if (hasActiveAgent(latestEntriesRef.current) || elapsedSinceTyping < QUIET_WINDOW_MS) {
|
||||
scheduleCheck()
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
if (!preparationRef.current) {
|
||||
preparationRef.current = await window.api.starNag.agentValueMoment()
|
||||
if (preparationRef.current.status !== 'ready') {
|
||||
pendingRef.current = false
|
||||
requestedRef.current = true
|
||||
return
|
||||
}
|
||||
}
|
||||
const freshElapsedSinceTyping = Date.now() - lastTypingAtRef.current
|
||||
if (hasActiveAgent(latestEntriesRef.current) || freshElapsedSinceTyping < QUIET_WINDOW_MS) {
|
||||
scheduleCheck()
|
||||
return
|
||||
}
|
||||
pendingRef.current = false
|
||||
requestedRef.current = true
|
||||
await window.api.starNag.showAgentValueMoment()
|
||||
})()
|
||||
}, CHECK_DELAY_MS)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
latestEntriesRef.current = agentStatusByPaneKey
|
||||
}, [agentStatusByPaneKey, agentStatusEpoch])
|
||||
|
||||
useEffect(() => {
|
||||
const markTyping = (event: Event): void => {
|
||||
if (event instanceof KeyboardEvent) {
|
||||
if (!isTypingKeyEvent(event)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
lastTypingAtRef.current = Date.now()
|
||||
}
|
||||
window.addEventListener('keydown', markTyping, true)
|
||||
window.addEventListener('input', markTyping, true)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', markTyping, true)
|
||||
window.removeEventListener('input', markTyping, true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const previousEntries = previousEntriesRef.current
|
||||
previousEntriesRef.current = agentStatusByPaneKey
|
||||
if (!previousEntries || requestedRef.current) {
|
||||
return
|
||||
}
|
||||
if (!hasSuccessfulDoneTransition(previousEntries, agentStatusByPaneKey)) {
|
||||
return
|
||||
}
|
||||
pendingRef.current = true
|
||||
scheduleCheck()
|
||||
}, [agentStatusByPaneKey, agentStatusEpoch, scheduleCheck])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { StarNagToastHost } from './StarNagToastHost'
|
||||
|
||||
type ShowPayload = { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' }
|
||||
type ShowCallback = (payload?: ShowPayload) => void
|
||||
type CustomToastOptions = {
|
||||
dismissible?: boolean
|
||||
onDismiss?: () => void
|
||||
}
|
||||
|
||||
const toastDismissMock = vi.hoisted(() => vi.fn())
|
||||
const customToastMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
custom: customToastMock,
|
||||
dismiss: toastDismissMock
|
||||
}
|
||||
}))
|
||||
|
||||
type StarNagApi = {
|
||||
onShow: (callback: ShowCallback) => () => void
|
||||
onHide: (callback: () => void) => () => void
|
||||
dismiss: ReturnType<typeof vi.fn>
|
||||
later: ReturnType<typeof vi.fn>
|
||||
openWeb: ReturnType<typeof vi.fn>
|
||||
starOrca: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
type ShellApi = {
|
||||
openUrl: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function createDeferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function setApi(api: { starNag: StarNagApi; shell: ShellApi }): void {
|
||||
;(window as unknown as { api: typeof api }).api = api
|
||||
}
|
||||
|
||||
function renderHost(): { root: Root; container: HTMLDivElement } {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(<StarNagToastHost />)
|
||||
})
|
||||
return { root, container }
|
||||
}
|
||||
|
||||
function renderToastFromCustomCall(container: HTMLElement): void {
|
||||
const render = customToastMock.mock.calls[0][0] as (id: string | number) => React.ReactElement
|
||||
act(() => {
|
||||
createRoot(container).render(render('toast-1'))
|
||||
})
|
||||
}
|
||||
|
||||
describe('StarNagToastHost', () => {
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
let toastContainer: HTMLDivElement | null = null
|
||||
let showCallback: ShowCallback | null = null
|
||||
let hideCallback: (() => void) | null = null
|
||||
let starNag: StarNagApi
|
||||
let shell: ShellApi
|
||||
let toastIdCounter = 0
|
||||
|
||||
beforeEach(() => {
|
||||
customToastMock.mockReset()
|
||||
customToastMock.mockImplementation(() => `toast-${++toastIdCounter}`)
|
||||
toastDismissMock.mockReset()
|
||||
showCallback = null
|
||||
hideCallback = null
|
||||
toastIdCounter = 0
|
||||
starNag = {
|
||||
onShow: vi.fn((callback: ShowCallback) => {
|
||||
showCallback = callback
|
||||
return vi.fn()
|
||||
}),
|
||||
onHide: vi.fn((callback: () => void) => {
|
||||
hideCallback = callback
|
||||
return vi.fn()
|
||||
}),
|
||||
dismiss: vi.fn().mockResolvedValue(undefined),
|
||||
later: vi.fn().mockResolvedValue(undefined),
|
||||
openWeb: vi.fn().mockResolvedValue(undefined),
|
||||
starOrca: vi.fn().mockResolvedValue(true)
|
||||
}
|
||||
shell = {
|
||||
openUrl: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
setApi({ starNag, shell })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount())
|
||||
}
|
||||
container?.remove()
|
||||
toastContainer?.remove()
|
||||
root = null
|
||||
container = null
|
||||
toastContainer = null
|
||||
})
|
||||
|
||||
it('renders exact onboarding toast copy and confirms only after direct star succeeds', async () => {
|
||||
;({ root, container } = renderHost())
|
||||
|
||||
act(() => showCallback?.({ mode: 'gh', surface: 'toast' }))
|
||||
toastContainer = document.createElement('div')
|
||||
document.body.appendChild(toastContainer)
|
||||
renderToastFromCustomCall(toastContainer)
|
||||
|
||||
expect(toastContainer.textContent).toContain('Onboarding completed!')
|
||||
expect(toastContainer.textContent).toContain(
|
||||
'If 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<boolean>()
|
||||
starNag.starOrca.mockReturnValueOnce(pendingStar.promise)
|
||||
;({ root, container } = renderHost())
|
||||
|
||||
act(() => showCallback?.({ mode: 'gh', surface: 'toast' }))
|
||||
toastContainer = document.createElement('div')
|
||||
document.body.appendChild(toastContainer)
|
||||
renderToastFromCustomCall(toastContainer)
|
||||
|
||||
const starButton = Array.from(toastContainer.querySelectorAll('button')).find((candidate) =>
|
||||
candidate.textContent?.includes('Star on GitHub')
|
||||
)
|
||||
await act(async () => {
|
||||
starButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
const closeButton = Array.from(toastContainer.querySelectorAll('button')).find(
|
||||
(candidate) => candidate.getAttribute('aria-label') === 'Dismiss'
|
||||
)
|
||||
expect((closeButton as HTMLButtonElement | undefined)?.disabled).toBe(true)
|
||||
act(() => {
|
||||
closeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
const options = customToastMock.mock.calls[0][1] as CustomToastOptions
|
||||
act(() => options.onDismiss?.())
|
||||
|
||||
expect(toastDismissMock).not.toHaveBeenCalled()
|
||||
expect(starNag.dismiss).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
pendingStar.resolve(true)
|
||||
await pendingStar.promise
|
||||
})
|
||||
})
|
||||
|
||||
it('does not dismiss the current main session when replacing an active toast', () => {
|
||||
;({ root, container } = renderHost())
|
||||
|
||||
act(() => showCallback?.({ mode: 'gh', surface: 'toast' }))
|
||||
const firstOptions = customToastMock.mock.calls[0][1] as CustomToastOptions
|
||||
|
||||
act(() => showCallback?.({ mode: 'web', surface: 'toast' }))
|
||||
const secondOptions = customToastMock.mock.calls[1][1] as CustomToastOptions
|
||||
act(() => firstOptions.onDismiss?.())
|
||||
|
||||
expect(toastDismissMock).toHaveBeenCalledWith('toast-1')
|
||||
expect(starNag.dismiss).not.toHaveBeenCalled()
|
||||
|
||||
act(() => showCallback?.({ mode: 'gh', surface: 'toast' }))
|
||||
const thirdOptions = customToastMock.mock.calls[2][1] as CustomToastOptions
|
||||
act(() => secondOptions.onDismiss?.())
|
||||
|
||||
expect(starNag.dismiss).not.toHaveBeenCalled()
|
||||
|
||||
act(() => thirdOptions.onDismiss?.())
|
||||
|
||||
expect(starNag.dismiss).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dismisses active toast on hide without recording a user dismissal', () => {
|
||||
;({ root, container } = renderHost())
|
||||
|
||||
act(() => showCallback?.({ mode: 'gh', surface: 'toast' }))
|
||||
act(() => hideCallback?.())
|
||||
const options = customToastMock.mock.calls[0][1] as CustomToastOptions
|
||||
act(() => options.onDismiss?.())
|
||||
|
||||
expect(toastDismissMock).toHaveBeenCalledWith('toast-1')
|
||||
expect(starNag.dismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, ExternalLink, Loader2, Star, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const ORCA_REPO_URL = 'https://github.com/stablyai/orca'
|
||||
type StarNagMode = 'gh' | 'web'
|
||||
type StarNagToastStatus = 'idle' | 'busy' | 'starred' | 'opened'
|
||||
|
||||
type StarNagToastProps = {
|
||||
id: string | number
|
||||
mode: StarNagMode
|
||||
markResolved: () => void
|
||||
setDismissSuppressed: (suppressed: boolean) => void
|
||||
}
|
||||
|
||||
function StarNagToast({
|
||||
id,
|
||||
mode: initialMode,
|
||||
markResolved,
|
||||
setDismissSuppressed
|
||||
}: StarNagToastProps): React.JSX.Element {
|
||||
const [mode, setMode] = useState(initialMode)
|
||||
const [status, setStatus] = useState<StarNagToastStatus>('idle')
|
||||
const busy = status === 'busy'
|
||||
|
||||
const close = (): void => {
|
||||
if (busy) {
|
||||
return
|
||||
}
|
||||
toast.dismiss(id)
|
||||
}
|
||||
|
||||
const later = (): void => {
|
||||
if (busy) {
|
||||
return
|
||||
}
|
||||
markResolved()
|
||||
void window.api.starNag.later()
|
||||
toast.dismiss(id)
|
||||
}
|
||||
|
||||
const act = async (): Promise<void> => {
|
||||
if (busy || status === 'starred') {
|
||||
return
|
||||
}
|
||||
setStatus('busy')
|
||||
setDismissSuppressed(true)
|
||||
if (mode === 'web') {
|
||||
try {
|
||||
await window.api.shell.openUrl(ORCA_REPO_URL)
|
||||
await window.api.starNag.openWeb()
|
||||
markResolved()
|
||||
setStatus('opened')
|
||||
} catch {
|
||||
setDismissSuppressed(false)
|
||||
setStatus('idle')
|
||||
}
|
||||
return
|
||||
}
|
||||
let ok = false
|
||||
try {
|
||||
ok = await window.api.starNag.starOrca()
|
||||
} catch {
|
||||
ok = false
|
||||
}
|
||||
if (!ok) {
|
||||
setMode('web')
|
||||
setDismissSuppressed(false)
|
||||
setStatus('idle')
|
||||
return
|
||||
}
|
||||
markResolved()
|
||||
setStatus('starred')
|
||||
}
|
||||
|
||||
const actionLabel =
|
||||
status === 'starred'
|
||||
? translate('auto.components.star.nag.StarNagToastHost.starredThanks', 'Starred — thank you!')
|
||||
: status === 'opened'
|
||||
? translate('auto.components.star.nag.StarNagToastHost.githubOpened', 'GitHub opened')
|
||||
: busy
|
||||
? mode === 'web'
|
||||
? translate('auto.components.star.nag.StarNagToastHost.opening', 'Opening…')
|
||||
: translate('auto.components.star.nag.StarNagToastHost.starring', 'Starring…')
|
||||
: mode === 'web'
|
||||
? translate('auto.components.star.nag.StarNagToastHost.openGithub', 'Open GitHub')
|
||||
: translate('auto.components.star.nag.StarNagToastHost.starOnGithub', 'Star on GitHub')
|
||||
|
||||
const completedStar = status === 'starred'
|
||||
const primaryActionClass = completedStar
|
||||
? 'min-w-0 flex-1 gap-1.5 border-amber-400/40 bg-amber-400/15 text-amber-700 hover:bg-amber-400/15 dark:text-amber-200'
|
||||
: 'min-w-0 flex-1 gap-1.5 border-amber-400/60 bg-amber-400/15 text-amber-800 hover:bg-amber-400/25 dark:text-amber-100'
|
||||
|
||||
return (
|
||||
<div className="relative w-[340px] max-w-[calc(100vw-32px)] overflow-hidden rounded-lg border border-border bg-popover p-3.5 text-popover-foreground shadow-xs">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={
|
||||
completedStar
|
||||
? 'flex size-6 shrink-0 items-center justify-center rounded-full border border-amber-400/40 bg-amber-400/10 text-amber-500'
|
||||
: 'flex size-6 shrink-0 items-center justify-center rounded-full border border-status-success-border bg-status-success-background text-status-success'
|
||||
}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{completedStar ? (
|
||||
<Star className="size-3.5 fill-current" />
|
||||
) : (
|
||||
<Check className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
<div className="text-sm font-semibold">
|
||||
{translate(
|
||||
'auto.components.star.nag.StarNagToastHost.onboardingCompleted',
|
||||
'Onboarding completed!'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-5 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.star.nag.StarNagToastHost.body',
|
||||
'If you’re enjoying Orca so far, a GitHub star helps other developers discover it.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={close}
|
||||
disabled={busy}
|
||||
aria-label={translate('auto.components.star.nag.StarNagToastHost.dismiss', 'Dismiss')}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className={primaryActionClass}
|
||||
onClick={() => void act()}
|
||||
disabled={busy || status === 'starred' || status === 'opened'}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : mode === 'web' ? (
|
||||
<ExternalLink className="size-3.5" />
|
||||
) : (
|
||||
<Star className="size-3.5" />
|
||||
)}
|
||||
{actionLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-[84px]"
|
||||
onClick={later}
|
||||
disabled={busy || status === 'starred' || status === 'opened'}
|
||||
>
|
||||
{translate('auto.components.star.nag.StarNagToastHost.later', 'Later')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StarNagToastHost(): null {
|
||||
const activeToastIdRef = useRef<string | number | null>(null)
|
||||
const activeToastResolvedRef = useRef<(() => void) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const dismissActiveToast = (): void => {
|
||||
if (activeToastIdRef.current === null) {
|
||||
return
|
||||
}
|
||||
activeToastResolvedRef.current?.()
|
||||
toast.dismiss(activeToastIdRef.current)
|
||||
}
|
||||
const unsubscribeShow = window.api.starNag.onShow((payload) => {
|
||||
if (payload?.surface !== 'toast') {
|
||||
return
|
||||
}
|
||||
dismissActiveToast()
|
||||
let resolved = false
|
||||
let dismissSuppressed = false
|
||||
const markResolved = (): void => {
|
||||
resolved = true
|
||||
}
|
||||
const setDismissSuppressed = (suppressed: boolean): void => {
|
||||
dismissSuppressed = suppressed
|
||||
}
|
||||
activeToastResolvedRef.current = markResolved
|
||||
const id = toast.custom(
|
||||
(toastId) => (
|
||||
<StarNagToast
|
||||
id={toastId}
|
||||
mode={payload.mode === 'web' ? 'web' : 'gh'}
|
||||
markResolved={markResolved}
|
||||
setDismissSuppressed={setDismissSuppressed}
|
||||
/>
|
||||
),
|
||||
{
|
||||
duration: Infinity,
|
||||
closeButton: false,
|
||||
dismissible: false,
|
||||
unstyled: true,
|
||||
onDismiss: () => {
|
||||
if (activeToastIdRef.current === id) {
|
||||
activeToastIdRef.current = null
|
||||
activeToastResolvedRef.current = null
|
||||
}
|
||||
if (!resolved && !dismissSuppressed) {
|
||||
void window.api.starNag.dismiss()
|
||||
}
|
||||
},
|
||||
onAutoClose: () => {
|
||||
if (activeToastIdRef.current === id) {
|
||||
activeToastIdRef.current = null
|
||||
activeToastResolvedRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
activeToastIdRef.current = id
|
||||
})
|
||||
const unsubscribeHide = window.api.starNag.onHide(dismissActiveToast)
|
||||
return () => {
|
||||
unsubscribeShow()
|
||||
unsubscribeHide()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -434,6 +434,20 @@ function createWebPreloadApi(): Partial<PreloadApi> {
|
|||
pickFloatingMarkdownDocument: () => Promise.resolve(null),
|
||||
pickFloatingWorkspaceDirectory: () => Promise.resolve(null)
|
||||
},
|
||||
starNag: {
|
||||
onShow: () => noopUnsubscribe,
|
||||
onHide: () => noopUnsubscribe,
|
||||
dismiss: () => Promise.resolve(),
|
||||
later: () => Promise.resolve(),
|
||||
complete: () => Promise.resolve(),
|
||||
disable: () => Promise.resolve(),
|
||||
openWeb: () => Promise.resolve(),
|
||||
starOrca: () => Promise.resolve(false),
|
||||
forceShow: () => Promise.resolve(),
|
||||
agentValueMoment: () => Promise.resolve({ status: 'skipped' }),
|
||||
showAgentValueMoment: () => Promise.resolve(),
|
||||
onboardingCompleted: () => Promise.resolve()
|
||||
},
|
||||
platform: {
|
||||
get: () => ({
|
||||
platform: getBrowserPlatform(),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export const STAR_NAG_PROMPT_SOURCES = [
|
|||
'threshold',
|
||||
'force_show',
|
||||
'agent_value_moment',
|
||||
'onboarding_completed',
|
||||
'update_flow',
|
||||
'settings',
|
||||
'legacy_threshold'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue