fix(desktop): skip the film, keep the guided onboarding
HERMES_SKIP_INTRO=1 turned off the intro film but also silently killed the entire guided first launch: the guide can only queue on the film's completion edge (queueGuideAfterIntro requires hasSeenIntroReveal), so with the film disabled nothing ever fired and the app booted straight into the normal shell with the free-tier flag on. The film-to-guide seam now fires without the film: beginOnboardingFlowWithout Intro records the film as watched and queues the guide directly, so HERMES_SKIP_INTRO skips exactly the film. A relaunch adopts the persisted guide (loadGate sees cinematic + seen), and a later launch without the flag cannot replay the film over the completed flow. Verified: tsc -p tsconfig.json and tsconfig.electron.json clean; vitest ui (onboarding-gate, onboarding, onboarding-never-forces-sign-in: 30 passed incl. 2 new invariant tests) and electron (guest-onboarding-flag, preload-flags: 6 passed) green.
This commit is contained in:
@@ -8,7 +8,8 @@ export const GUEST_ONBOARDING_ENV = 'HERMES_GUEST_ONBOARDING'
|
||||
export const GUEST_ONBOARDING_FLAG = '--guest-onboarding'
|
||||
// Skip the first-run film. A rehearsal aid: the intro is a one-time reveal,
|
||||
// so anyone iterating on the guided chat behind it otherwise sits through it
|
||||
// on every fresh HERMES_HOME. Renderer-only; the backend never sees it.
|
||||
// on every fresh HERMES_HOME. The guide still runs — only the film is
|
||||
// skipped. Renderer-only; the backend never sees it.
|
||||
export const SKIP_INTRO_ENV = 'HERMES_SKIP_INTRO'
|
||||
export const SKIP_INTRO_FLAG = '--skip-intro'
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ Eligibility is `guestOnboardingEnabled && !firstRunSkipped && !hasSeenIntroRevea
|
||||
Electron sets the flag from `HERMES_GUEST_ONBOARDING=1` or `--guest-onboarding`.
|
||||
The gate queues the guided chat on completion; the chat gate acknowledges the
|
||||
free-tier notice as the cinematic starts. With the flag off, neither gate starts.
|
||||
`HERMES_SKIP_INTRO=1` turns the film off but keeps the guided chat: the gate
|
||||
records the film as watched and queues the guide directly.
|
||||
|
||||
| Piece | Path |
|
||||
| --- | --- |
|
||||
|
||||
@@ -7,12 +7,13 @@ import {
|
||||
finishIntroReveal,
|
||||
installIntroRevealBridgeListeners,
|
||||
isIntroRevealEnabled,
|
||||
isIntroRevealSkipped,
|
||||
leaveIntroReveal,
|
||||
shouldPlayFirstRunIntro,
|
||||
startIntroReveal
|
||||
} from '@/store/intro-reveal'
|
||||
import { $desktopOnboarding } from '@/store/onboarding'
|
||||
import { beginOnboardingFlow, queueGuideAfterIntro } from '@/store/onboarding-gate'
|
||||
import { beginOnboardingFlow, beginOnboardingFlowWithoutIntro, queueGuideAfterIntro } from '@/store/onboarding-gate'
|
||||
|
||||
import { INTRO_DEADMAN_MS, INTRO_EXIT_MS } from './timeline'
|
||||
|
||||
@@ -48,7 +49,23 @@ export function IntroRevealGate({ enabled }: IntroRevealGateProps) {
|
||||
}, [enabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled && intro.phase === 'hidden' && shouldPlayFirstRunIntro(onboarding.firstRunSkipped)) {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
// skipIntro turns the film off; the guided chat behind it must still run.
|
||||
// Take the guide's shape on this tick, exactly like the film's completion
|
||||
// edge, so no full-size shell paints while the guide session comes up.
|
||||
if (isIntroRevealSkipped()) {
|
||||
if (intro.phase === 'hidden') {
|
||||
beginOnboardingFlowWithoutIntro(onboarding.firstRunSkipped)
|
||||
takeGuideShape()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (intro.phase === 'hidden' && shouldPlayFirstRunIntro(onboarding.firstRunSkipped)) {
|
||||
beginOnboardingFlow()
|
||||
startIntroReveal()
|
||||
}
|
||||
|
||||
@@ -31,10 +31,24 @@ export function hasSeenIntroReveal(): boolean {
|
||||
return readKey(SEEN_KEY) === '1'
|
||||
}
|
||||
|
||||
/** The film will never play for this identity (HERMES_SKIP_INTRO). Record it
|
||||
* as seen so a later launch without the flag cannot replay it over the
|
||||
* guided flow that ran in its place. */
|
||||
export function markIntroRevealSeen(): void {
|
||||
writeKey(SEEN_KEY, '1')
|
||||
}
|
||||
|
||||
export function isIntroRevealEnabled(): boolean {
|
||||
return isOnboardingEnabled() && window.hermesDesktop?.skipIntro !== true
|
||||
}
|
||||
|
||||
/** The guided flow is on but its intro film was turned off at launch
|
||||
* (HERMES_SKIP_INTRO). Distinct from isIntroRevealEnabled(): false there also
|
||||
* covers onboarding being off entirely, where nothing may queue. */
|
||||
export function isIntroRevealSkipped(): boolean {
|
||||
return isOnboardingEnabled() && window.hermesDesktop?.skipIntro === true
|
||||
}
|
||||
|
||||
export function shouldPlayFirstRunIntro(firstRunSkipped: boolean): boolean {
|
||||
return isIntroRevealEnabled() && !firstRunSkipped && !hasSeenIntroReveal()
|
||||
}
|
||||
|
||||
@@ -35,3 +35,40 @@ it('restores every persisted onboarding phase and rejects unknown phases', async
|
||||
|
||||
expect($onboardingGate.get().phase).toBe('idle')
|
||||
})
|
||||
|
||||
it('a skipped intro still queues the guided flow, and never replays the film', async () => {
|
||||
const { beginOnboardingFlowWithoutIntro } = await import('./onboarding-gate')
|
||||
const { hasSeenIntroReveal } = await import('./intro-reveal')
|
||||
|
||||
storage.clear()
|
||||
beginOnboardingFlowWithoutIntro(false)
|
||||
const { $onboardingGate } = await import('./onboarding-gate')
|
||||
|
||||
expect($onboardingGate.get()).toEqual({ phase: 'cinematic', guideQueued: true })
|
||||
// The film is recorded as watched: a later launch without HERMES_SKIP_INTRO
|
||||
// must adopt the persisted guide, not play the film over it.
|
||||
expect(hasSeenIntroReveal()).toBe(true)
|
||||
|
||||
// A relaunch boots loadGate() over the same persisted state and re-queues the guide.
|
||||
vi.resetModules()
|
||||
const { $onboardingGate: relaunched } = await import('./onboarding-gate')
|
||||
|
||||
expect(relaunched.get().guideQueued).toBe(true)
|
||||
expect(relaunched.get().phase).toBe('cinematic')
|
||||
})
|
||||
|
||||
it('a skipped intro does nothing when onboarding is off or the first run was skipped', async () => {
|
||||
vi.doMock('@/lib/onboarding-enabled', () => ({ isOnboardingEnabled: () => false }))
|
||||
vi.resetModules()
|
||||
const off = await import('./onboarding-gate')
|
||||
|
||||
off.beginOnboardingFlowWithoutIntro(false)
|
||||
expect(off.$onboardingGate.get().phase).toBe('idle')
|
||||
|
||||
vi.doUnmock('@/lib/onboarding-enabled')
|
||||
vi.resetModules()
|
||||
const on = await import('./onboarding-gate')
|
||||
|
||||
on.beginOnboardingFlowWithoutIntro(true)
|
||||
expect(on.$onboardingGate.get().phase).toBe('idle')
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { atom } from 'nanostores'
|
||||
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
|
||||
import { readKey, writeKey } from '@/lib/storage'
|
||||
|
||||
import { hasSeenIntroReveal } from './intro-reveal'
|
||||
import { hasSeenIntroReveal, markIntroRevealSeen } from './intro-reveal'
|
||||
import { DEFAULT_ANSWERS, setOnboardingAnswers } from './onboarding-answers'
|
||||
|
||||
const PHASE_KEY = 'hermes-onboarding-phase-v1'
|
||||
@@ -62,6 +62,28 @@ export function beginOnboardingFlow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** The guided first launch without its intro film (HERMES_SKIP_INTRO). Same
|
||||
* eligibility as the film path minus the film itself: the film is recorded as
|
||||
* watched and the film-to-guide seam fires immediately, instead of waiting
|
||||
* for a completion that never comes. */
|
||||
export function beginOnboardingFlowWithoutIntro(firstRunSkipped: boolean): void {
|
||||
if (!isOnboardingEnabled() || firstRunSkipped) {
|
||||
return
|
||||
}
|
||||
|
||||
beginOnboardingFlow()
|
||||
|
||||
// A prior launch quit mid-film and left the phase at cinematic; the guide
|
||||
// is owed directly. Everything else (guided/skipped/handoff/done) already
|
||||
// had its turn and must not re-queue.
|
||||
if ($onboardingGate.get().phase !== 'cinematic') {
|
||||
return
|
||||
}
|
||||
|
||||
markIntroRevealSeen()
|
||||
queueGuideAfterIntro()
|
||||
}
|
||||
|
||||
export function queueGuideAfterIntro(): void {
|
||||
const state = $onboardingGate.get()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user