Skip first-run education in dev launches (#4489)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-02 14:49:00 -04:00 committed by GitHub
parent 90fc65803d
commit 5a48af49f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 266 additions and 1 deletions

View File

@ -40,4 +40,4 @@ trap cleanup EXIT
echo "[dev-fresh-profile] using userData=$PROFILE_DIR"
# Don't exec — we need the EXIT trap to fire so the temp profile gets cleaned up.
ORCA_DEV_USER_DATA_PATH="$PROFILE_DIR" pnpm dev
ORCA_DEV_USER_DATA_PATH="$PROFILE_DIR" ORCA_DEV_SHOW_FIRST_RUN_EDUCATION=1 pnpm dev

View File

@ -55,6 +55,10 @@ import {
patchPackagedProcessPath,
shouldInstallManagedHooks
} from './startup/configure-process'
import {
shouldSuppressDevEducation,
suppressDevEducationForStore
} from './startup/dev-education-suppression'
import { maybeRedirectAppImageCliLaunch } from './startup/appimage-cli-redirect'
import { startFirstWindowStartupServices } from './startup/first-window-startup-services'
import { getDevInstanceIdentity } from './startup/dev-instance-identity'
@ -1035,6 +1039,9 @@ app.whenReady().then(async () => {
}
store = new Store()
if (shouldSuppressDevEducation({ isDev: is.dev })) {
suppressDevEducationForStore(store)
}
try {
// Why: Dock/Launchpad launches do not inherit shell proxy env vars, so the
// persisted proxy must be applied before any app-owned network fetchers run.

View File

@ -0,0 +1,169 @@
import { describe, expect, it, vi } from 'vitest'
import {
getDefaultOnboardingState,
getDefaultUIState,
ONBOARDING_FINAL_STEP,
ONBOARDING_FLOW_VERSION
} from '../../shared/constants'
import { CONTEXTUAL_TOUR_IDS } from '../../shared/contextual-tours'
import { FEATURE_INTERACTION_IDS } from '../../shared/feature-interactions'
import { FEATURE_TIP_IDS } from '../../shared/feature-tips'
import type { OnboardingState, PersistedUIState } from '../../shared/types'
import {
DEV_SHOW_FIRST_RUN_EDUCATION_ENV,
shouldSuppressDevEducation,
suppressDevEducationForStore
} from './dev-education-suppression'
function createStoreState(overrides?: {
onboarding?: Partial<OnboardingState>
ui?: Partial<PersistedUIState>
}) {
let onboarding: OnboardingState = {
...getDefaultOnboardingState(),
...overrides?.onboarding,
checklist: {
...getDefaultOnboardingState().checklist,
...overrides?.onboarding?.checklist
}
}
let ui: PersistedUIState = {
...getDefaultUIState(),
...overrides?.ui
}
return {
get onboarding() {
return onboarding
},
get ui() {
return ui
},
store: {
getOnboarding: vi.fn(() => onboarding),
updateOnboarding: vi.fn((updates: Partial<OnboardingState>) => {
onboarding = {
...onboarding,
...updates,
checklist: {
...onboarding.checklist,
...updates.checklist
}
}
return onboarding
}),
getUI: vi.fn(() => ui),
updateUI: vi.fn((updates: Partial<PersistedUIState>) => {
ui = { ...ui, ...updates }
})
}
}
}
describe('shouldSuppressDevEducation', () => {
it('suppresses first-run education for normal dev launches', () => {
expect(shouldSuppressDevEducation({ isDev: true, env: {} })).toBe(true)
})
it('does not suppress packaged launches', () => {
expect(shouldSuppressDevEducation({ isDev: false, env: {} })).toBe(false)
})
it('respects the first-run education env escape hatch', () => {
expect(
shouldSuppressDevEducation({
isDev: true,
env: { [DEV_SHOW_FIRST_RUN_EDUCATION_ENV]: '1' }
})
).toBe(false)
})
it('does not suppress E2E-controlled profiles', () => {
expect(
shouldSuppressDevEducation({
isDev: true,
env: { ORCA_E2E_USER_DATA_DIR: '/tmp/orca-e2e' }
})
).toBe(false)
})
})
describe('suppressDevEducationForStore', () => {
it('marks onboarding and first-run education complete', () => {
const state = createStoreState()
suppressDevEducationForStore(state.store, 1234)
expect(state.onboarding).toMatchObject({
flowVersion: ONBOARDING_FLOW_VERSION,
closedAt: 1234,
outcome: 'completed',
lastCompletedStep: ONBOARDING_FINAL_STEP
})
expect(state.ui.featureTipsSeenIds).toEqual(FEATURE_TIP_IDS)
expect(state.ui.contextualToursSeenIds).toEqual(CONTEXTUAL_TOUR_IDS)
expect(state.ui.contextualToursAutoEligible).toBe(false)
expect(Object.keys(state.ui.featureInteractions ?? {}).sort()).toEqual(
[...FEATURE_INTERACTION_IDS].sort()
)
})
it('preserves completed onboarding and existing education history', () => {
const state = createStoreState({
onboarding: {
closedAt: 99,
outcome: 'dismissed',
lastCompletedStep: 1
},
ui: {
featureTipsSeenIds: ['voice-dictation'],
contextualToursSeenIds: ['tasks'],
featureInteractions: {
tasks: { firstInteractedAt: 77, interactionCount: 3 }
}
}
})
suppressDevEducationForStore(state.store, 1234)
expect(state.store.updateOnboarding).not.toHaveBeenCalled()
expect(state.onboarding).toMatchObject({
closedAt: 99,
outcome: 'dismissed',
lastCompletedStep: 1
})
expect(state.ui.featureTipsSeenIds).toEqual(['voice-dictation', 'orca-cli'])
expect(state.ui.contextualToursSeenIds).toEqual([
'tasks',
...CONTEXTUAL_TOUR_IDS.filter((id) => id !== 'tasks')
])
expect(state.ui.featureInteractions?.tasks).toEqual({
firstInteractedAt: 77,
interactionCount: 3
})
})
it('does not write UI when education state is already suppressed', () => {
const featureInteractions = Object.fromEntries(
FEATURE_INTERACTION_IDS.map((id) => [id, { firstInteractedAt: 1, interactionCount: 1 }])
)
const state = createStoreState({
onboarding: {
closedAt: 1,
outcome: 'completed',
lastCompletedStep: ONBOARDING_FINAL_STEP
},
ui: {
featureTipsSeenIds: [...FEATURE_TIP_IDS],
contextualToursSeenIds: [...CONTEXTUAL_TOUR_IDS],
contextualToursAutoEligible: false,
featureInteractions
}
})
suppressDevEducationForStore(state.store, 1234)
expect(state.store.updateOnboarding).not.toHaveBeenCalled()
expect(state.store.updateUI).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,89 @@
import { ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION } from '../../shared/constants'
import { CONTEXTUAL_TOUR_IDS } from '../../shared/contextual-tours'
import {
FEATURE_INTERACTION_IDS,
type FeatureInteractionState
} from '../../shared/feature-interactions'
import { FEATURE_TIP_IDS } from '../../shared/feature-tips'
import type { PersistedUIState } from '../../shared/types'
import type { Store } from '../persistence'
export const DEV_SHOW_FIRST_RUN_EDUCATION_ENV = 'ORCA_DEV_SHOW_FIRST_RUN_EDUCATION'
type DevEducationStore = Pick<Store, 'getOnboarding' | 'updateOnboarding' | 'getUI' | 'updateUI'>
export function shouldSuppressDevEducation(args: {
isDev: boolean
env?: NodeJS.ProcessEnv
}): boolean {
const env = args.env ?? process.env
return (
args.isDev &&
env.ORCA_E2E_USER_DATA_DIR === undefined &&
env[DEV_SHOW_FIRST_RUN_EDUCATION_ENV] !== '1'
)
}
export function suppressDevEducationForStore(store: DevEducationStore, now = Date.now()): void {
const onboarding = store.getOnboarding()
if (onboarding.closedAt === null) {
// Why: default dev launches should behave like an already-productive
// profile, while the env escape hatch keeps first-run surfaces testable.
store.updateOnboarding({
flowVersion: ONBOARDING_FLOW_VERSION,
closedAt: now,
outcome: 'completed',
lastCompletedStep: ONBOARDING_FINAL_STEP
})
}
const ui = store.getUI()
const nextFeatureTipsSeenIds = mergeUnique(ui.featureTipsSeenIds, FEATURE_TIP_IDS)
const nextContextualToursSeenIds = mergeUnique(ui.contextualToursSeenIds, CONTEXTUAL_TOUR_IDS)
const nextFeatureInteractions = fillFeatureInteractions(ui.featureInteractions, now)
const updates: Partial<PersistedUIState> = {}
if (!sameArray(ui.featureTipsSeenIds, nextFeatureTipsSeenIds)) {
updates.featureTipsSeenIds = nextFeatureTipsSeenIds
}
if (!sameArray(ui.contextualToursSeenIds, nextContextualToursSeenIds)) {
updates.contextualToursSeenIds = nextContextualToursSeenIds
}
if (ui.contextualToursAutoEligible !== false) {
updates.contextualToursAutoEligible = false
}
if (
Object.keys(nextFeatureInteractions).length !== Object.keys(ui.featureInteractions ?? {}).length
) {
updates.featureInteractions = nextFeatureInteractions
}
if (Object.keys(updates).length > 0) {
store.updateUI(updates)
}
}
function mergeUnique<const T extends string>(
current: readonly T[] | undefined,
additions: readonly T[]
): T[] {
return [...new Set([...(current ?? []), ...additions])]
}
function fillFeatureInteractions(
current: FeatureInteractionState | undefined,
now: number
): FeatureInteractionState {
const next: FeatureInteractionState = { ...current }
for (const id of FEATURE_INTERACTION_IDS) {
next[id] ??= {
firstInteractedAt: now,
interactionCount: 1
}
}
return next
}
function sameArray<T>(a: readonly T[] | undefined, b: readonly T[]): boolean {
return (a ?? []).length === b.length && (a ?? []).every((value, index) => value === b[index])
}