feat: onboarding flow for new users (#1596)

* wip

* WIP: Changes before auto-review fixes

Co-authored-by: Orca <help@stably.ai>

* WIP: Changes before auto-review fixes

Co-authored-by: Orca <help@stably.ai>

* WIP: Changes before auto-review fixes

Co-authored-by: Orca <help@stably.ai>

* fix: address auto-review findings (iteration 1)

Co-authored-by: Orca <help@stably.ai>

* fix: address auto-review findings (iteration 2)

Co-authored-by: Orca <help@stably.ai>

* fix: archive review context and improve agent detection on wizard mount

Co-authored-by: Orca <help@stably.ai>

* fix: address CI lint failures and split use-onboarding-flow.ts

Co-authored-by: Orca <help@stably.ai>

* fix: mock ./onboarding in register-core-handlers test

Co-authored-by: Orca <help@stably.ai>

* fix: also toggle light class on documentElement so onboarding e2e theme wait resolves

The onboarding e2e calls waitForFunction(() => classList.contains('dark') || classList.contains('light')) before snapshotting the starting theme. applyDocumentTheme only toggled 'dark', so on a host that resolves system to light the wait timed out (CI Linux headless). Toggle 'light' as the inverse class so consumers can observe the resolved theme symmetrically; Tailwind keys only on 'dark' so styling is unchanged.

Co-authored-by: Orca <help@stably.ai>

* fix: add braces to Landing menu close-on-outside-click handler

oxlint config requires braces for all if statements.

Co-authored-by: Orca <help@stably.ai>

* chore: trigger CI

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-05-08 14:17:50 -07:00
committed by GitHub
co-authored by Orca
parent 2050fa87a0
commit e0851ec722
28 changed files with 2365 additions and 51 deletions
+30 -1
View File
@@ -1,6 +1,8 @@
import type {
GlobalSettings,
NotificationSettings,
OnboardingChecklistState,
OnboardingState,
PersistedState,
PersistedUIState,
RepoHookSettings,
@@ -13,6 +15,10 @@ import { DEFAULT_TERMINAL_FONT_WEIGHT } from './terminal-fonts'
export const SCHEMA_VERSION = 1
export const DEFAULT_APP_FONT_FAMILY = 'Geist'
// Why: the onboarding wizard's last step index. Centralized so backfill,
// clamps, and UI step references all agree on the same upper bound.
export const ONBOARDING_FINAL_STEP = 4
export const ORCA_BROWSER_PARTITION = 'persist:orca-browser'
// Why: blank browser tabs must start from an inert guest URL that does not
// navigate the privileged main window to about:blank. Renderer and main both
@@ -111,6 +117,28 @@ export function getDefaultNotificationSettings(): NotificationSettings {
}
}
export function getDefaultOnboardingState(): OnboardingState {
return {
closedAt: null,
outcome: null,
lastCompletedStep: -1,
checklist: {
addedRepo: false,
choseAgent: false,
ranFirstAgent: false,
ranSecondAgentOnSameTask: false,
triedCmdJ: false,
shapedSidebar: false,
reviewedDiff: false,
openedPr: false,
addedFolder: false,
openedFile: false,
ranAgentOnFile: false,
dismissed: false
} satisfies OnboardingChecklistState
}
}
export function getDefaultSettings(homedir: string): GlobalSettings {
return {
workspaceDir: `${homedir}/orca/workspaces`,
@@ -240,7 +268,8 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
ui: getDefaultUIState(),
githubCache: { pr: {}, issue: {} },
workspaceSession: getDefaultWorkspaceSession(),
sshTargets: []
sshTargets: [],
onboarding: getDefaultOnboardingState()
}
}
+95 -2
View File
@@ -14,7 +14,8 @@
import { z } from 'zod'
import type { GlobalSettings } from './types'
import { ONBOARDING_FINAL_STEP } from './constants'
import type { GlobalSettings, OnboardingChecklistState } from './types'
// ── Shared property enums ───────────────────────────────────────────────
@@ -108,6 +109,7 @@ export const workspaceSourceSchema = z.enum([
'sidebar',
'shortcut',
'drag_drop',
'onboarding',
'unknown'
])
export type WorkspaceSource = z.infer<typeof workspaceSourceSchema>
@@ -120,6 +122,7 @@ export const launchSourceSchema = z.enum([
'new_workspace_composer',
'workspace_jump_palette',
'shortcut',
'onboarding',
'diff_notes_send',
'unknown'
])
@@ -246,6 +249,86 @@ const workspaceCreateFailedSchema = z
})
.strict()
// ── Onboarding ──────────────────────────────────────────────────────────
//
// Closed enums only — no raw paths, repo names, clone URLs, or error
// strings. The funnel exists to measure activation, not to debug specific
// user repos.
// Why: bound is derived from ONBOARDING_FINAL_STEP so adding a wizard step
// only requires bumping the constant. Zod can't build a literal-union from a
// numeric constant without runtime gymnastics, so we use a clamped int range.
const onboardingStepSchema = z.number().int().min(1).max(ONBOARDING_FINAL_STEP)
const onboardingPathSchema = z.enum(['open_folder', 'clone_url'])
const onboardingFailureReasonSchema = z.enum([
'invalid_path',
'clone_failed',
'cancelled',
'unknown'
])
const onboardingValueKindSchema = z.enum(['agent', 'theme', 'notifications', 'repo'])
// `dismissed` from `OnboardingChecklistState` is intentionally excluded —
// it is a UI panel-visibility flag, not an activation event, so it never
// fires `activation_checklist_item_completed`. Keep this list in sync with
// the activation keys of `OnboardingChecklistState` in shared/types.ts.
const onboardingChecklistItemSchema = z.enum([
'addedRepo',
'addedFolder',
'choseAgent',
'ranFirstAgent',
'ranSecondAgentOnSameTask',
'triedCmdJ',
'shapedSidebar',
'reviewedDiff',
'openedPr',
'openedFile',
'ranAgentOnFile'
])
// Why: compile-time guard that the enum above stays in lockstep with the
// activation keys of OnboardingChecklistState (everything except the UI-only
// `dismissed` flag). Adding/removing a checklist key without updating this
// schema breaks the build here rather than silently dropping telemetry.
type _OnboardingChecklistItemSync =
z.infer<typeof onboardingChecklistItemSchema> extends Exclude<
keyof OnboardingChecklistState,
'dismissed'
>
? Exclude<keyof OnboardingChecklistState, 'dismissed'> extends z.infer<
typeof onboardingChecklistItemSchema
>
? true
: never
: never
const _onboardingChecklistItemSyncCheck: _OnboardingChecklistItemSync = true
void _onboardingChecklistItemSyncCheck
const onboardingStartedSchema = z
.object({ resumed_from_step: onboardingStepSchema.optional() })
.strict()
const onboardingStepViewedSchema = z.object({ step: onboardingStepSchema }).strict()
const onboardingStepCompletedSchema = z
.object({ step: onboardingStepSchema, value_kind: onboardingValueKindSchema })
.strict()
const onboardingStepSkippedSchema = z.object({ step: onboardingStepSchema }).strict()
const onboardingStep4PathClickedSchema = z.object({ path: onboardingPathSchema }).strict()
const onboardingStep4PathFailedSchema = z
.object({ path: onboardingPathSchema, reason: onboardingFailureReasonSchema })
.strict()
const onboardingCompletedSchema = z
.object({
path: onboardingPathSchema,
is_git_repo: z.boolean(),
total_duration_ms: z.number().int().nonnegative()
})
.strict()
const onboardingDismissedSchema = z.object({ last_step: onboardingStepSchema }).strict()
const activationChecklistItemCompletedSchema = z
.object({
item: onboardingChecklistItemSchema,
time_since_completed_ms: z.number().int().nonnegative()
})
.strict()
// ── Event registry: the one record the validator consumes ───────────────
//
// The validator does `eventSchemas[name].safeParse(props)`. `EventMap` is
@@ -273,7 +356,17 @@ export const eventSchemas = {
settings_changed: settingsChangedSchema,
telemetry_opted_in: telemetryOptedInSchema,
telemetry_opted_out: telemetryOptedOutSchema
telemetry_opted_out: telemetryOptedOutSchema,
onboarding_started: onboardingStartedSchema,
onboarding_step_viewed: onboardingStepViewedSchema,
onboarding_step_completed: onboardingStepCompletedSchema,
onboarding_step_skipped: onboardingStepSkippedSchema,
onboarding_step4_path_clicked: onboardingStep4PathClickedSchema,
onboarding_step4_path_failed: onboardingStep4PathFailedSchema,
onboarding_completed: onboardingCompletedSchema,
onboarding_dismissed: onboardingDismissedSchema,
activation_checklist_item_completed: activationChecklistItemCompletedSchema
} as const
export type EventMap = { [N in keyof typeof eventSchemas]: z.infer<(typeof eventSchemas)[N]> }
+36
View File
@@ -1316,6 +1316,41 @@ export type NotificationSoundPathResult =
| { ok: true; path: string }
| { ok: false; reason: 'missing-path' | 'invalid-path' | 'unsupported-type' }
export type OnboardingOutcome = 'completed' | 'dismissed'
export type OnboardingChecklistState = {
addedRepo: boolean
choseAgent: boolean
ranFirstAgent: boolean
ranSecondAgentOnSameTask: boolean
triedCmdJ: boolean
shapedSidebar: boolean
reviewedDiff: boolean
openedPr: boolean
addedFolder: boolean
openedFile: boolean
ranAgentOnFile: boolean
// Why: UI state flag (panel visibility), not an activation event. The
// telemetry checklist enum in telemetry-events.ts intentionally omits this.
dismissed: boolean
}
export type OnboardingState = {
closedAt: number | null
outcome: OnboardingOutcome | null
// Sentinel `-1` = not started; `1..4` = highest wizard step the user
// finished. Kept as `number` (not a literal union) because callers clamp
// via `Math.max`/`Math.min` against arbitrary numerics.
lastCompletedStep: number
checklist: OnboardingChecklistState
}
export type NotificationPermissionStatusResult = {
supported: boolean
platform: NodeJS.Platform
requested: boolean
}
export type WorktreeCardProperty =
| 'status'
| 'unread'
@@ -1534,6 +1569,7 @@ export type PersistedState = {
}
workspaceSession: WorkspaceSessionState
sshTargets: SshTarget[]
onboarding: OnboardingState
}
// ─── Filesystem ─────────────────────────────────────────────