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
parent 2050fa87a0
commit e0851ec722
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 2365 additions and 51 deletions

View File

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Launch `pn dev` with a fresh, isolated userData profile so the app behaves
# like a first-time install (onboarding overlay paints, no persisted repos,
# no saved sessions). Your real `orca-dev` profile is left untouched.
#
# Usage:
# ./config/scripts/dev-fresh-profile.sh # ephemeral temp profile, deleted on exit
# ./config/scripts/dev-fresh-profile.sh --keep # keep the profile dir after exit
# ORCA_FRESH_PROFILE_DIR=/some/path ./config/scripts/dev-fresh-profile.sh # use a fixed dir
set -euo pipefail
KEEP=0
for arg in "$@"; do
case "$arg" in
--keep) KEEP=1 ;;
-h|--help)
sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'
exit 0 ;;
*)
echo "Unknown argument: $arg" >&2
echo "Usage: $0 [--keep] [--help]" >&2
exit 2 ;;
esac
done
PROFILE_DIR="${ORCA_FRESH_PROFILE_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/orca-fresh-profile.XXXXXXXX")}"
mkdir -p "$PROFILE_DIR"
cleanup() {
if [[ "$KEEP" -eq 0 && -z "${ORCA_FRESH_PROFILE_DIR:-}" ]]; then
# Guard rm -rf against accidental empty/unrelated PROFILE_DIR.
[[ -n "${PROFILE_DIR:-}" && -d "$PROFILE_DIR" && "$PROFILE_DIR" == */orca-fresh-profile* ]] || return 0
rm -rf "$PROFILE_DIR"
echo "[dev-fresh-profile] removed $PROFILE_DIR"
else
echo "[dev-fresh-profile] kept $PROFILE_DIR"
fi
}
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

View File

@ -614,7 +614,15 @@ app.whenReady().then(async () => {
// dialog either doesn't appear or gets immediately covered by the maximized
// window, making it impossible for the user to click "Allow".
win.once('show', () => {
triggerStartupNotificationRegistration(store!)
// Why: store can be null if init failed earlier; bail rather than risk a
// throw inside an Electron event listener.
if (!store) {
return
}
const onboarding = store.getOnboarding()
if (onboarding.closedAt !== null) {
triggerStartupNotificationRegistration(store)
}
})
app.on('activate', () => {

View File

@ -5,6 +5,7 @@ import type { Store } from '../persistence'
import type {
NotificationDispatchRequest,
NotificationDispatchResult,
NotificationPermissionStatusResult,
NotificationSoundDataResult
} from '../../shared/types'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
@ -31,6 +32,8 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
const recentNotifications = new Map<string, number>()
ipcMain.removeHandler('notifications:openSystemSettings')
ipcMain.removeHandler('notifications:getPermissionStatus')
ipcMain.removeHandler('notifications:requestPermission')
ipcMain.handle('notifications:openSystemSettings', (): void => {
if (process.platform === 'darwin') {
// Deep-link into the macOS Notifications settings pane.
@ -40,6 +43,25 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
}
})
// Why: Electron's main-process `Notification` class exposes no synchronous
// way to read macOS auth status — the renderer-side `Notification.permission`
// does not exist here. We expose what we can reliably observe: whether the
// platform supports notifications and whether we've already kicked off the
// first-permission prompt. A 'denied' OS result is invisible to us; the
// dispatch path simply won't deliver in that case, which the user can
// diagnose via the System Settings deep-link.
const getPermissionStatus = (): NotificationPermissionStatusResult => ({
supported: Notification.isSupported(),
platform: process.platform,
requested: store.getUI().notificationPermissionRequested === true
})
ipcMain.handle('notifications:getPermissionStatus', getPermissionStatus)
ipcMain.handle('notifications:requestPermission', (): NotificationPermissionStatusResult => {
triggerStartupNotificationRegistration(store)
return getPermissionStatus()
})
ipcMain.removeHandler('notifications:dispatch')
ipcMain.handle(
'notifications:dispatch',

View File

@ -0,0 +1,16 @@
import { ipcMain } from 'electron'
import { sanitizeOnboardingUpdate, type Store } from '../persistence'
import type { OnboardingState } from '../../shared/types'
export function registerOnboardingHandlers(store: Store): void {
ipcMain.removeHandler('onboarding:get')
ipcMain.removeHandler('onboarding:update')
ipcMain.handle('onboarding:get', (): OnboardingState => store.getOnboarding())
// Why: never trust renderer input — a compromised/buggy caller could send
// unknown keys or wrong-typed values that would poison persisted state.
// Run every update through the shared whitelist sanitizer.
ipcMain.handle('onboarding:update', (_event, updates: unknown): OnboardingState => {
return store.updateOnboarding(sanitizeOnboardingUpdate(updates))
})
}

View File

@ -32,7 +32,8 @@ const {
registerFilesystemWatcherHandlersMock,
registerAppHandlersMock,
registerLinearHandlersMock,
registerExportHandlersMock
registerExportHandlersMock,
registerOnboardingHandlersMock
} = vi.hoisted(() => ({
registerCliHandlersMock: vi.fn(),
registerPreflightHandlersMock: vi.fn(),
@ -65,7 +66,12 @@ const {
registerFilesystemWatcherHandlersMock: vi.fn(),
registerAppHandlersMock: vi.fn(),
registerLinearHandlersMock: vi.fn(),
registerExportHandlersMock: vi.fn()
registerExportHandlersMock: vi.fn(),
registerOnboardingHandlersMock: vi.fn()
}))
vi.mock('./onboarding', () => ({
registerOnboardingHandlers: registerOnboardingHandlersMock
}))
vi.mock('./cli', () => ({

View File

@ -17,6 +17,7 @@ import { registerMemoryHandlers } from './memory'
import { registerRateLimitHandlers } from './rate-limits'
import { registerRuntimeHandlers } from './runtime'
import { registerNotificationHandlers } from './notifications'
import { registerOnboardingHandlers } from './onboarding'
import { registerDeveloperPermissionHandlers } from './developer-permissions'
import { setTrustedBrowserRendererWebContentsId, setAgentBrowserBridgeRef } from './browser'
import { registerSessionHandlers } from './session'
@ -83,6 +84,7 @@ export function registerCoreHandlers(
registerStatsHandlers(stats)
registerMemoryHandlers(store)
registerNotificationHandlers(store, runtime)
registerOnboardingHandlers(store)
registerDeveloperPermissionHandlers()
registerSettingsHandlers(store)
registerTelemetryHandlers(store)

View File

@ -12,7 +12,10 @@ import type {
Repo,
SparsePreset,
WorktreeMeta,
GlobalSettings
GlobalSettings,
OnboardingChecklistState,
OnboardingOutcome,
OnboardingState
} from '../shared/types'
import type { SshTarget } from '../shared/ssh-types'
import { isFolderRepo } from '../shared/repo-kind'
@ -20,9 +23,11 @@ import { getGitUsername } from './git/repo'
import {
getDefaultPersistedState,
getDefaultNotificationSettings,
getDefaultOnboardingState,
getDefaultUIState,
getDefaultRepoHookSettings,
getDefaultWorkspaceSession
getDefaultWorkspaceSession,
ONBOARDING_FINAL_STEP
} from '../shared/constants'
import { parseWorkspaceSession } from '../shared/workspace-session-schema'
@ -103,6 +108,68 @@ function normalizeSshTarget(t: SshTarget): SshTarget {
return { ...t, configHost: t.configHost ?? t.label ?? t.host }
}
// Why: shared by load-time merge and the IPC update handler so the same
// strict whitelist guards every entry into onboarding state — arbitrary
// renderer/disk input cannot inject unknown keys or wrong-typed values.
// Returns only validated fields; unknown keys are dropped silently.
// Why: returns Partial<...> with a partial checklist so the IPC update path
// merges over current state without wiping previously-true keys. Invalid
// top-level fields are OMITTED (not coerced to fallbacks) so partial updates
// don't clobber valid persisted state; the load-path caller spreads defaults.
export function sanitizeOnboardingUpdate(
input: unknown
): Partial<Omit<OnboardingState, 'checklist'>> & { checklist?: Partial<OnboardingChecklistState> } {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
return {}
}
const raw = input as Record<string, unknown>
const out: Partial<Omit<OnboardingState, 'checklist'>> & {
checklist?: Partial<OnboardingChecklistState>
} = {}
if ('closedAt' in raw) {
if (typeof raw.closedAt === 'number') {
out.closedAt = raw.closedAt
} else if (raw.closedAt === null) {
out.closedAt = null
}
// else: omit — preserve existing persisted value on merge.
}
if ('outcome' in raw) {
const v = raw.outcome
if (v === 'completed' || v === 'dismissed') {
out.outcome = v as OnboardingOutcome
} else if (v === null) {
out.outcome = null
}
// else: omit.
}
if ('lastCompletedStep' in raw) {
const v = raw.lastCompletedStep
if (typeof v === 'number' && Number.isInteger(v) && v >= -1 && v <= ONBOARDING_FINAL_STEP) {
out.lastCompletedStep = v
}
// else: omit.
}
if ('checklist' in raw) {
const rawChecklist = raw.checklist
if (rawChecklist && typeof rawChecklist === 'object' && !Array.isArray(rawChecklist)) {
// Why: copy ONLY caller-sent boolean keys so partial updates (e.g.
// `{ addedRepo: true }`) don't reset other checklist items to false.
const defaults = getDefaultOnboardingState().checklist
const rc = rawChecklist as Record<string, unknown>
const checklist: Partial<OnboardingChecklistState> = {}
for (const key of Object.keys(defaults) as (keyof OnboardingChecklistState)[]) {
if (key in rc && typeof rc[key] === 'boolean') {
checklist[key] = rc[key] as boolean
}
}
out.checklist = checklist
}
}
return out
}
// Why: read a settings field that was removed from the GlobalSettings type
// but still round-trips on disk via the ...parsed.settings spread. One-shot
// use only — for the inline-agents default-on migration's Case B discriminator.
@ -284,7 +351,37 @@ export class Store {
}
return { ...defaults.workspaceSession, ...result.value }
})(),
sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget)
sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget),
onboarding: (() => {
// Why: if we successfully parsed an existing orca-data.json that
// lacks an onboarding block, this is an upgrade-cohort user —
// backfill as completed (not dismissed) so they don't get dropped
// into the wizard regardless of whether they currently have repos,
// SSH targets, or just non-default settings. Analytics still
// distinguish this from users who explicitly bailed mid-funnel.
if (!parsed.onboarding) {
return {
...defaults.onboarding,
closedAt: Date.now(),
outcome: 'completed' as const,
lastCompletedStep: ONBOARDING_FINAL_STEP
}
}
// Why: validate every persisted onboarding key explicitly via the
// shared sanitizer instead of spreading raw values. A type-flipped
// field on disk (string where number expected, unknown checklist
// key) is dropped or coerced to the default rather than poisoning
// in-memory state.
const sanitized = sanitizeOnboardingUpdate(parsed.onboarding)
return {
...defaults.onboarding,
...sanitized,
checklist: {
...defaults.onboarding.checklist,
...sanitized.checklist
}
}
})()
}
}
} catch (err) {
@ -671,6 +768,38 @@ export class Store {
this.scheduleSave()
}
// ── Onboarding ────────────────────────────────────────────────────
getOnboarding(): PersistedState['onboarding'] {
const defaults = getDefaultOnboardingState()
return {
...defaults,
...this.state.onboarding,
checklist: {
...defaults.checklist,
...this.state.onboarding?.checklist
}
}
}
updateOnboarding(
updates: Partial<Omit<PersistedState['onboarding'], 'checklist'>> & {
checklist?: Partial<OnboardingChecklistState>
}
): PersistedState['onboarding'] {
const current = this.getOnboarding()
this.state.onboarding = {
...current,
...updates,
checklist: {
...current.checklist,
...updates.checklist
}
}
this.scheduleSave()
return this.getOnboarding()
}
// ── GitHub Cache ──────────────────────────────────────────────────
getGitHubCache(): PersistedState['githubCache'] {

View File

@ -45,7 +45,9 @@ import type {
GetRateLimitResult,
NotificationDispatchRequest,
NotificationDispatchResult,
NotificationPermissionStatusResult,
NotificationSoundResult,
OnboardingState,
OrcaHooks,
PersistedUIState,
PRCheckDetail,
@ -750,8 +752,21 @@ export type PreloadApi = {
notifications: {
dispatch: (args: NotificationDispatchRequest) => Promise<NotificationDispatchResult>
openSystemSettings: () => Promise<void>
getPermissionStatus: () => Promise<NotificationPermissionStatusResult>
requestPermission: () => Promise<NotificationPermissionStatusResult>
playSound: (options?: { force?: boolean }) => Promise<NotificationSoundResult>
}
onboarding: {
get: () => Promise<OnboardingState>
// Why: main-process `updateOnboarding` merges checklist field-by-field, so
// callers can pass a partial checklist (e.g. just `{ addedRepo: true }`)
// without re-supplying every flag.
update: (
updates: Partial<Omit<OnboardingState, 'checklist'>> & {
checklist?: Partial<OnboardingState['checklist']>
}
) => Promise<OnboardingState>
}
developerPermissions: {
getStatus: () => Promise<DeveloperPermissionState[]>
request: (args: { id: DeveloperPermissionId }) => Promise<DeveloperPermissionRequestResult>

View File

@ -21,9 +21,11 @@ import type {
ListWorkItemsResult,
MemorySnapshot,
NotificationDispatchResult,
NotificationPermissionStatusResult,
NotificationSoundDataResult,
NotificationSoundPathResult,
NotificationSoundResult,
OnboardingState,
SearchResult
} from '../shared/types'
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types'
@ -910,6 +912,10 @@ const api = {
dispatch: (args: Record<string, unknown>): Promise<NotificationDispatchResult> =>
ipcRenderer.invoke('notifications:dispatch', args),
openSystemSettings: (): Promise<void> => ipcRenderer.invoke('notifications:openSystemSettings'),
getPermissionStatus: (): Promise<NotificationPermissionStatusResult> =>
ipcRenderer.invoke('notifications:getPermissionStatus'),
requestPermission: (): Promise<NotificationPermissionStatusResult> =>
ipcRenderer.invoke('notifications:requestPermission'),
playSound: async (options?: { force?: boolean }): Promise<NotificationSoundResult> => {
try {
// Why: drop replays while the sound is still ringing. The "test"
@ -971,6 +977,15 @@ const api = {
}
},
onboarding: {
get: (): Promise<OnboardingState> => ipcRenderer.invoke('onboarding:get'),
update: (
updates: Partial<Omit<OnboardingState, 'checklist'>> & {
checklist?: Partial<OnboardingState['checklist']>
}
): Promise<OnboardingState> => ipcRenderer.invoke('onboarding:update', updates)
},
developerPermissions: {
getStatus: (): Promise<unknown> => ipcRenderer.invoke('developerPermissions:getStatus'),
request: (args: { id: string }): Promise<unknown> =>

View File

@ -31,6 +31,7 @@ import { UpdateCard } from './components/UpdateCard'
import { StarNagCard } from './components/StarNagCard'
import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface'
import { ZoomOverlay } from './components/ZoomOverlay'
import { shouldShowOnboarding } from './components/onboarding/should-show-onboarding'
import { SshPassphraseDialog } from './components/settings/SshPassphraseDialog'
import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling'
import { useEditorExternalWatch } from './hooks/useEditorExternalWatch'
@ -45,12 +46,14 @@ import { buildWorkspaceSessionPayload } from './lib/workspace-session'
import { countWorkingAgents, getWorkingAgentsPerWorktree } from './lib/agent-status'
import { activateAndRevealWorktree } from './lib/worktree-activation'
import { applyDocumentTheme } from './lib/document-theme'
import { isEditableTarget } from './lib/editable-target'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { findWorktreeById, getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers'
import {
canGoBackWorktreeHistory,
canGoForwardWorktreeHistory
} from '@/store/slices/worktree-nav-history'
import type { OnboardingState } from '../../shared/types'
const isMac = navigator.userAgent.includes('Mac')
const isWindows = !isMac && navigator.userAgent.includes('Windows')
@ -117,28 +120,10 @@ const NewWorkspaceComposerModal = lazy(() => import('./components/NewWorkspaceCo
// Why: lazy-loaded so the WebP asset + overlay module aren't fetched unless
// the user opts into the experimental flag.
const PetOverlay = lazy(() => import('./components/pet/PetOverlay'))
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false
}
// xterm.js focuses a hidden <textarea class="xterm-helper-textarea"> for
// keyboard input. That element IS an editable target, but we must NOT
// suppress global shortcuts when the terminal itself is focused — otherwise
// Cmd/Ctrl+P and other app-level keybindings become unreachable.
if (target.classList.contains('xterm-helper-textarea')) {
return false
}
if (target.isContentEditable) {
return true
}
return (
target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]') !==
null
)
}
// Why: lazy so onboarding's step modules + assets aren't fetched for users
// past first-launch. The gate `shouldShowOnboarding` lives in its own tiny
// module so no eager import path pulls OnboardingFlow into the main chunk.
const OnboardingFlow = lazy(() => import('./components/onboarding/OnboardingFlow'))
function App(): React.JSX.Element {
// Why: Zustand actions are referentially stable, but each individual
@ -213,6 +198,7 @@ function App(): React.JSX.Element {
const titlebarLeftControlsRef = useRef<HTMLDivElement | null>(null)
const [collapsedSidebarHeaderWidth, setCollapsedSidebarHeaderWidth] = useState(0)
const [mountedLazyModalIds, setMountedLazyModalIds] = useState(() => new Set<string>())
const [onboarding, setOnboarding] = useState<OnboardingState | null>(null)
// Subscribe to IPC push events
useIpcEvents()
@ -285,6 +271,10 @@ function App(): React.JSX.Element {
actions.pruneLastVisitedTimestamps()
actions.seedActiveWorktreeLastVisitedIfMissing()
await actions.fetchBrowserSessionProfiles()
const onboardingState = await window.api.onboarding.get()
if (!cancelled) {
setOnboarding(onboardingState)
}
// Why: SSH connections must be re-established BEFORE terminal
// reconnect so that reconnectPersistedTerminals can route SSH-backed
@ -1162,6 +1152,11 @@ function App(): React.JSX.Element {
<TelemetryFirstLaunchSurface />
<ZoomOverlay />
<SshPassphraseDialog />
{onboarding && shouldShowOnboarding(onboarding) ? (
<Suspense fallback={null}>
<OnboardingFlow onboarding={onboarding} onOnboardingChange={setOnboarding} />
</Suspense>
) : null}
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
{/* Why: rendered last so it sits after all -webkit-app-region:drag elements
in DOM order. Electron's hit-test for drag regions is DOM-order-based and

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { AlertTriangle, ExternalLink, FolderPlus, GitBranchPlus, Star } from 'lucide-react'
import { cn } from '../lib/utils'
import { useAppStore } from '../store'
@ -61,6 +61,8 @@ type StarState = 'loading' | 'starred' | 'not-starred' | 'hidden'
function GitHubStarButton({ hasRepos }: { hasRepos: boolean }): React.JSX.Element | null {
const [state, setState] = useState<StarState>('loading')
const [menuOpen, setMenuOpen] = useState(false)
const wrapperRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
let cancelled = false
@ -79,7 +81,24 @@ function GitHubStarButton({ hasRepos }: { hasRepos: boolean }): React.JSX.Elemen
}
}, [])
useEffect(() => {
if (!menuOpen) {
return
}
const onDocClick = (e: MouseEvent): void => {
if (!wrapperRef.current?.contains(e.target as Node)) {
setMenuOpen(false)
}
}
document.addEventListener('mousedown', onDocClick)
return () => document.removeEventListener('mousedown', onDocClick)
}, [menuOpen])
const handleClick = async (): Promise<void> => {
if (state === 'starred') {
setMenuOpen((v) => !v)
return
}
if (state !== 'not-starred') {
return
}
@ -101,25 +120,43 @@ function GitHubStarButton({ hasRepos }: { hasRepos: boolean }): React.JSX.Elemen
}
return (
<button
className={cn(
'inline-flex items-center gap-2 rounded-full border px-4 py-1.5 text-[13px] font-medium transition-all duration-300',
state === 'loading' && 'pointer-events-none opacity-0',
state === 'not-starred' &&
'border-amber-400/30 text-amber-300/90 hover:border-amber-400/50 hover:bg-amber-400/[0.08] cursor-pointer',
state === 'starred' && 'border-amber-400/25 bg-amber-400/[0.06] text-amber-400/60'
)}
onClick={handleClick}
disabled={state === 'starred' || state === 'loading'}
>
<Star
<div ref={wrapperRef} className="relative inline-block">
<button
className={cn(
'size-3.5 transition-all duration-300',
state === 'starred' ? 'fill-amber-400/60 text-amber-400/60' : 'text-amber-400/80'
'inline-flex items-center gap-2 rounded-full border px-4 py-1.5 text-[13px] font-medium transition-all duration-300',
state === 'loading' && 'pointer-events-none opacity-0',
state === 'not-starred' &&
'cursor-pointer border-amber-500/60 text-amber-700 hover:border-amber-500/80 hover:bg-amber-400/10 dark:border-amber-400/30 dark:text-amber-300/90 dark:hover:border-amber-400/50 dark:hover:bg-amber-400/[0.08]',
state === 'starred' &&
'cursor-pointer border-amber-500/50 bg-amber-400/10 text-amber-700 dark:border-amber-400/25 dark:bg-amber-400/[0.06] dark:text-amber-400/60'
)}
/>
{state === 'starred' ? 'Starred on GitHub' : 'Star on GitHub'}
</button>
onClick={handleClick}
disabled={state === 'loading'}
>
<Star
className={cn(
'size-3.5 transition-all duration-300',
state === 'starred'
? 'fill-amber-500/70 text-amber-500/70 dark:fill-amber-400/60 dark:text-amber-400/60'
: 'text-amber-600 dark:text-amber-400/80'
)}
/>
{state === 'starred' ? 'Starred on GitHub' : 'Star on GitHub'}
</button>
{state === 'starred' && menuOpen && (
<div className="absolute right-0 top-[calc(100%+4px)] z-10 min-w-[100px] rounded-md border border-border bg-popover py-1 shadow-md">
<button
className="w-full px-3 py-1.5 text-left text-[13px] text-foreground hover:bg-muted"
onClick={() => {
setMenuOpen(false)
setState('hidden')
}}
>
Hide
</button>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,152 @@
import { useEffect, useState } from 'react'
import { ExternalLink } from 'lucide-react'
import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog'
import { cn } from '@/lib/utils'
import type { TuiAgent } from '../../../../shared/types'
type AgentStepProps = {
selectedAgent: TuiAgent | null
onSelect: (agent: TuiAgent) => void
detectedSet: Set<TuiAgent>
isDetecting: boolean
}
export function AgentStep({
selectedAgent,
onSelect,
detectedSet,
isDetecting
}: AgentStepProps) {
const detected = AGENT_CATALOG.filter((agent) => detectedSet.has(agent.id))
const rest = AGENT_CATALOG.filter((agent) => !detectedSet.has(agent.id))
const hasDetected = detected.length > 0
const primary = hasDetected ? detected : AGENT_CATALOG.slice(0, 6)
const fallbackRest = hasDetected ? rest : AGENT_CATALOG.slice(6)
const selectedEntry =
selectedAgent && !detectedSet.has(selectedAgent)
? AGENT_CATALOG.find((a) => a.id === selectedAgent)
: undefined
// Why: keep the collapsed bucket open when the selected agent lives there, so
// the active card is visible without forcing the user to expand the disclosure.
const selectedEntryIsCollapsed =
selectedAgent != null && fallbackRest.some((a) => a.id === selectedAgent)
// Why: one-way latch — auto-open when selection lands in the fallback bucket,
// but never force-close. The user can freely toggle via the native <details>
// disclosure once it's open; controlling `open` directly off the prop would
// slam it shut as soon as `selectedEntryIsCollapsed` flips back to false.
const [openState, setOpenState] = useState(selectedEntryIsCollapsed)
useEffect(() => {
if (selectedEntryIsCollapsed) {
setOpenState(true)
}
}, [selectedEntryIsCollapsed])
return (
<div className="space-y-5">
{!hasDetected && !isDetecting && (
<div className="rounded-lg border border-amber-400/30 bg-amber-400/10 px-4 py-3 text-xs text-amber-700 dark:text-amber-200/90">
No agents detected on your PATH. Pick one to install later, or continue with a blank
terminal.
</div>
)}
{selectedEntry && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-amber-400/30 bg-amber-400/10 px-4 py-2.5 text-xs text-amber-700 dark:text-amber-200/90">
<span>
<span className="font-medium">{selectedEntry.label}</span> isn&apos;t on your PATH yet
Orca will set it as your default and you can install it any time.
</span>
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-amber-400/40 bg-amber-400/10 px-2 py-1 font-medium text-amber-800 hover:bg-amber-400/20 dark:text-amber-100"
onClick={() => void window.api.shell.openUrl(selectedEntry.homepageUrl)}
>
Install instructions
<ExternalLink className="size-3" />
</button>
</div>
)}
<section className="space-y-3">
<SectionHeader
label={hasDetected ? 'Detected on your system' : 'Popular agents'}
count={primary.length}
/>
<div className="grid grid-cols-2 gap-2.5 md:grid-cols-3">
{primary.map((agent) => (
<AgentButton
key={agent.id}
agent={agent}
selected={selectedAgent === agent.id}
onClick={() => onSelect(agent.id)}
/>
))}
</div>
</section>
{fallbackRest.length > 0 && (
<details
className="group space-y-3"
open={openState}
onToggle={(e) => setOpenState(e.currentTarget.open)}
>
<summary className="cursor-pointer list-none text-xs font-medium text-muted-foreground hover:text-foreground group-open:mb-3">
Show {fallbackRest.length} more {hasDetected ? 'agents' : ''}
</summary>
<div className="grid grid-cols-2 gap-2.5 md:grid-cols-3">
{fallbackRest.map((agent) => (
<AgentButton
key={agent.id}
agent={agent}
selected={selectedAgent === agent.id}
onClick={() => onSelect(agent.id)}
/>
))}
</div>
</details>
)}
</div>
)
}
function SectionHeader({ label, count }: { label: string; count: number }) {
return (
<div className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
<span>{label}</span>
<span className="text-muted-foreground/60">·</span>
<span className="tabular-nums text-muted-foreground">{count}</span>
</div>
)
}
function AgentButton({
agent,
selected,
onClick
}: {
agent: (typeof AGENT_CATALOG)[number]
selected: boolean
onClick: () => void
}) {
return (
<button
type="button"
aria-pressed={selected}
className={cn(
'group relative overflow-hidden rounded-xl border p-3.5 text-left transition-all',
selected
? 'border-foreground/50 bg-muted ring-2 ring-foreground/20'
: 'border-border bg-muted/30 hover:bg-muted/60'
)}
onClick={onClick}
>
<div className="flex min-w-0 items-start gap-2.5">
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-muted text-foreground">
<AgentIcon agent={agent.id} size={16} />
</span>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-foreground">{agent.label}</div>
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground">
{agent.cmd}
</div>
</div>
</div>
</button>
)
}

View File

@ -0,0 +1,76 @@
import { cn } from '@/lib/utils'
// Why: wizard uses positive framing ("notify when focused"); persisted
// setting stays `suppressWhenFocused` and is inverted at the boundary.
export type NotificationDraft = {
agentTaskComplete: boolean
terminalBell: boolean
notifyWhenFocused: boolean
}
type NotificationStepProps = {
value: NotificationDraft
onChange: (value: NotificationDraft) => void
}
export function NotificationStep({ value, onChange }: NotificationStepProps) {
const rows: { key: keyof NotificationDraft; title: string; description: string }[] = [
{
key: 'agentTaskComplete',
title: 'Agent task complete',
description: 'Ping me when an agent finishes its work.'
},
{
key: 'terminalBell',
title: 'Terminal bell',
description: 'Play a sound when a terminal rings — usually a question waiting on you.'
},
{
key: 'notifyWhenFocused',
title: 'Notify even when Orca is focused',
description: "Show notifications while you're already in the app."
}
]
return (
<>
<div className="overflow-hidden rounded-xl border border-border bg-muted/20">
{rows.map((row, idx) => (
<button
key={row.key}
type="button"
role="switch"
aria-checked={value[row.key]}
className={cn(
'flex w-full items-center justify-between gap-6 px-5 py-4 text-left transition-colors hover:bg-muted/50',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
idx > 0 && 'border-t border-border'
)}
onClick={() => onChange({ ...value, [row.key]: !value[row.key] })}
>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">{row.title}</div>
<div className="mt-0.5 text-[13px] text-muted-foreground">{row.description}</div>
</div>
<span
className={cn(
'relative h-6 w-11 shrink-0 rounded-full transition-colors',
value[row.key] ? 'bg-primary' : 'bg-muted-foreground/40'
)}
>
<span
className={cn(
'absolute left-0.5 top-0.5 size-5 rounded-full bg-background shadow-sm transition-transform',
value[row.key] && 'translate-x-5'
)}
/>
</span>
</button>
))}
</div>
<p className="mt-3 text-[13px] text-muted-foreground">
Configure other agent status personalization like custom sounds or pet sidekicks under{' '}
<span className="font-medium text-foreground">Settings Notifications</span>.
</p>
</>
)
}

View File

@ -0,0 +1,213 @@
import { useEffect } from 'react'
import { ChevronLeft } from 'lucide-react'
import { cn } from '@/lib/utils'
import { isEditableTarget } from '@/lib/editable-target'
import type { OnboardingState } from '../../../../shared/types'
import { AgentStep } from './AgentStep'
import { ThemeStep } from './ThemeStep'
import { NotificationStep } from './NotificationStep'
import { RepoStep } from './RepoStep'
import { STEPS, useOnboardingFlow } from './use-onboarding-flow'
import logo from '../../../../../resources/logo.svg'
const isMac = navigator.userAgent.includes('Mac')
// Why: AGENTS.md mandates `Ctrl+Enter` style on non-Mac; bare `Ctrl↵` reads as one glyph.
const enterLabel = isMac ? '⌘↵' : 'Ctrl+Enter'
const stepCopy = {
agent: {
title: 'Pick your default agent',
subtitle:
"Orca works with every CLI agent. Choose the one you'll reach for most — switch any time."
},
theme: {
title: 'Make it feel like home',
subtitle: 'Pick the look you want to stare at for hours.'
},
notifications: {
title: 'Know when an agent needs you',
subtitle: 'Get a desktop notification when your agent finishes or asks a question.'
},
repo: {
title: 'Point Orca at some code',
subtitle: 'Open a folder, clone a repo, or skip and add one later.'
}
} as const
type OnboardingFlowProps = {
onboarding: OnboardingState
onOnboardingChange: (state: OnboardingState) => void
}
export default function OnboardingFlow({
onboarding,
onOnboardingChange
}: OnboardingFlowProps): React.JSX.Element {
const flow = useOnboardingFlow(onboarding, onOnboardingChange)
const { currentStep, stepIndex, busyLabel } = flow
const copy = stepCopy[currentStep.id]
// Why: depend on stable callbacks + step id only so the listener doesn't
// re-bind on every render of the parent (flow object identity changes).
const { next: flowNext, openFolder: flowOpenFolder } = flow
useEffect(() => {
const onKeyDown = (event: KeyboardEvent): void => {
// Why: don't hijack Enter / Cmd+Enter while the user is typing into the
// clone-URL input or any other editable field on a step.
if (isEditableTarget(event.target)) {
return
}
const mod = isMac ? event.metaKey : event.ctrlKey
if (!mod || event.key !== 'Enter') {
return
}
event.preventDefault()
if (currentStep.id === 'repo') {
void flowOpenFolder()
} else {
void flowNext()
}
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [currentStep.id, flowNext, flowOpenFolder])
return (
<div className="fixed inset-0 z-[100] overflow-auto bg-background text-foreground">
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-70 dark:opacity-70"
style={{
background:
'radial-gradient(60% 50% at 20% 0%, color-mix(in srgb, var(--foreground) 6%, transparent) 0%, transparent 60%), radial-gradient(45% 40% at 90% 100%, color-mix(in srgb, var(--foreground) 4%, transparent) 0%, transparent 60%)'
}}
/>
<div
className="absolute inset-x-0 top-0 h-12"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
/>
<div className="relative mx-auto flex min-h-screen w-full max-w-[820px] flex-col px-8 pb-10 pt-16">
<div className="flex items-center gap-2.5 text-sm font-semibold tracking-tight">
<div
className="flex size-7 items-center justify-center rounded-md"
style={{ backgroundColor: '#12181e' }}
>
<img src={logo} alt="Orca logo" className="size-5" />
</div>
<span>Orca</span>
</div>
<div className="mt-12 flex items-center gap-2">
{STEPS.map((step, idx) => {
const isActive = idx === stepIndex
const isDone = idx < stepIndex
return (
<div
key={step.id}
className={cn(
'h-1 rounded-full transition-all duration-300',
isActive
? 'w-10 bg-foreground'
: isDone
? 'w-6 bg-muted-foreground/70'
: 'w-6 bg-muted'
)}
/>
)
})}
<span className="ml-3 text-xs font-medium text-muted-foreground">
{stepIndex + 1} of {STEPS.length}
</span>
</div>
<div className="mt-8">
{stepIndex === 0 && (
<div className="mb-2 text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
Welcome to Orca
</div>
)}
<h1 className="text-[34px] font-semibold leading-[1.15] tracking-tight text-foreground">
{copy.title}
</h1>
<p className="mt-3 max-w-[58ch] text-[15px] leading-relaxed text-muted-foreground">
{copy.subtitle}
</p>
</div>
<div className="mt-10 flex-1">
{currentStep.id === 'agent' && (
<AgentStep
selectedAgent={flow.selectedAgent}
onSelect={flow.setSelectedAgent}
detectedSet={flow.detectedSet}
isDetecting={flow.isDetectingAgents}
/>
)}
{currentStep.id === 'theme' && (
<ThemeStep
theme={flow.theme}
onThemeChange={flow.setTheme}
settings={flow.settings}
updateSettings={flow.updateSettings}
/>
)}
{currentStep.id === 'notifications' && (
<NotificationStep value={flow.notifications} onChange={flow.setNotifications} />
)}
{currentStep.id === 'repo' && (
<RepoStep
cloneUrl={flow.cloneUrl}
onCloneUrlChange={flow.setCloneUrl}
onOpenFolder={() => void flow.openFolder()}
onClone={() => void flow.clone()}
workspaceDir={flow.settings?.workspaceDir ?? ''}
busyLabel={flow.busyLabel}
error={flow.error}
/>
)}
</div>
<footer className="mt-10 flex items-center justify-between border-t border-border pt-5">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<kbd className="rounded-md border border-border bg-muted/60 px-1.5 py-0.5 font-mono text-[11px] text-foreground">
{enterLabel}
</kbd>
<span>{currentStep.id === 'repo' ? 'open folder' : 'continue'}</span>
</div>
<div className="flex items-center gap-2">
<button
className={
currentStep.id === 'repo'
? 'rounded-md border border-foreground/20 bg-muted px-3 py-2 text-sm font-medium text-foreground hover:bg-muted-foreground/10'
: 'rounded-md px-3 py-2 text-sm text-muted-foreground hover:text-foreground'
}
onClick={() => void flow.skip()}
>
{currentStep.id === 'repo' ? "I'll add one later" : 'Skip'}
</button>
{stepIndex > 0 && (
<button
className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/60 px-3 py-2 text-sm text-foreground hover:bg-muted disabled:opacity-60"
disabled={Boolean(busyLabel)}
onClick={flow.back}
>
<ChevronLeft className="size-4" />
Back
</button>
)}
{currentStep.id !== 'repo' && (
<button
className="rounded-md bg-primary px-5 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-60"
disabled={Boolean(busyLabel)}
onClick={() => void flow.next()}
>
Continue
</button>
)}
</div>
</footer>
</div>
</div>
)
}

View File

@ -0,0 +1,104 @@
import { FolderOpen, GitBranch, Server } from 'lucide-react'
type RepoStepProps = {
cloneUrl: string
onCloneUrlChange: (value: string) => void
onOpenFolder: () => void
onClone: () => void
workspaceDir: string
busyLabel: string | null
error: string | null
}
export function RepoStep({
cloneUrl,
onCloneUrlChange,
onOpenFolder,
onClone,
workspaceDir,
busyLabel,
error
}: RepoStepProps) {
const disabled = Boolean(busyLabel)
return (
<div className="space-y-3">
<button
type="button"
className="group flex w-full items-center gap-4 rounded-xl border border-border bg-muted/30 p-5 text-left transition hover:border-foreground/40 hover:bg-muted/60 disabled:opacity-60"
disabled={disabled}
onClick={onOpenFolder}
>
<div className="grid size-11 shrink-0 place-items-center rounded-lg bg-muted text-foreground">
<FolderOpen className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="text-base font-semibold text-foreground">Open a folder</div>
<div className="mt-0.5 text-[13px] text-muted-foreground">
Choose any local directory git repo or not.
</div>
</div>
<span className="shrink-0 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition group-hover:border-foreground/40">
Browse
</span>
</button>
<form
className="rounded-xl border border-border bg-muted/30 p-5"
onSubmit={(e) => {
e.preventDefault()
onClone()
}}
>
<div className="flex items-center gap-4">
<div className="grid size-11 shrink-0 place-items-center rounded-lg bg-muted text-foreground">
<GitBranch className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="text-base font-semibold text-foreground">Clone a repo</div>
<div className="mt-0.5 text-[13px] text-muted-foreground">
Paste an HTTPS or SSH URL.
</div>
</div>
</div>
<div className="mt-4 flex gap-2">
<input
className="min-w-0 flex-1 rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm text-foreground outline-none transition focus:border-foreground/50 focus:ring-2 focus:ring-foreground/15"
placeholder="git@github.com:org/repo.git"
value={cloneUrl}
disabled={disabled}
onChange={(event) => onCloneUrlChange(event.target.value)}
/>
<button
type="submit"
className="shrink-0 rounded-lg bg-primary px-5 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-40"
disabled={!cloneUrl.trim() || disabled}
>
Clone
</button>
</div>
</form>
<div className="flex flex-wrap items-center justify-between gap-3 px-1 pt-1 text-xs text-muted-foreground">
<div className="flex min-w-0 items-center gap-2">
<span>Workspace</span>
<span className="truncate font-mono text-foreground">{workspaceDir}</span>
</div>
<div className="flex items-center gap-1.5">
<Server className="size-3.5" />
<span>SSH? Set hosts up in Settings</span>
</div>
</div>
{busyLabel && (
<div className="rounded-lg border border-blue-400/30 bg-blue-400/10 px-4 py-2.5 text-sm text-blue-700 dark:text-blue-200">
{busyLabel}
</div>
)}
{error && (
<div className="rounded-lg border border-red-400/30 bg-red-400/10 px-4 py-2.5 text-sm text-red-700 dark:text-red-200">
{error}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,328 @@
import { useEffect, useState } from 'react'
import { Check, Monitor, Moon, Settings2, Sun } from 'lucide-react'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import type { GhosttyImportPreview, GlobalSettings } from '../../../../shared/types'
import ghosttyIcon from '../../../../../resources/ghostty.svg'
type ThemeStepProps = {
theme: GlobalSettings['theme']
onThemeChange: (theme: GlobalSettings['theme']) => void
settings: GlobalSettings | null
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void>
}
type DiscoveryState =
| { status: 'idle' }
| { status: 'detecting' }
| { status: 'found'; preview: GhosttyImportPreview; fields: string[] }
| { status: 'imported'; fields: string[] }
| { status: 'absent' }
export function ThemeStep({ theme, onThemeChange, settings, updateSettings }: ThemeStepProps) {
const [importing, setImporting] = useState(false)
const [discovery, setDiscovery] = useState<DiscoveryState>({ status: 'idle' })
// Why: read-only IPC. Auto-detect on step mount so the user sees a clear
// "we found your Ghostty config" prompt instead of a buried Import button.
// Settings are not applied until the user clicks Import (per design doc).
useEffect(() => {
let cancelled = false
setDiscovery({ status: 'detecting' })
void window.api.settings
.previewGhosttyImport()
.then((preview) => {
if (cancelled) {
return
}
// Why: hide the row when there's nothing to import. An empty diff can
// mean "settings already match" *or* "every key in the config was
// unsupported by the mapper" (e.g. theme = some-named-theme); we can't
// tell, so don't make a claim either way.
if (!preview.found || Object.keys(preview.diff).length === 0) {
setDiscovery({ status: 'absent' })
return
}
setDiscovery({ status: 'found', preview, fields: humanFields(preview.diff) })
})
.catch(() => {
if (cancelled) {
return
}
setDiscovery({ status: 'absent' })
})
return () => {
cancelled = true
}
}, [])
const importGhostty = async (preview: GhosttyImportPreview) => {
if (!settings || importing) {
return
}
setImporting(true)
try {
const resolved = preview.found ? preview : await window.api.settings.previewGhosttyImport()
if (!resolved.found || Object.keys(resolved.diff).length === 0) {
toast.info('No Ghostty settings found to import')
return
}
await updateSettings({
...resolved.diff,
...(resolved.diff.terminalColorOverrides
? {
terminalColorOverrides: {
...settings.terminalColorOverrides,
...resolved.diff.terminalColorOverrides
}
}
: {})
})
// Why: parent controller holds local `theme` state that overwrites
// settings.theme on Continue; sync it so the import isn't clobbered.
if (resolved.diff.theme) {
onThemeChange(resolved.diff.theme)
}
setDiscovery({ status: 'imported', fields: humanFields(resolved.diff) })
} catch (err) {
toast.error('Failed to import Ghostty settings', {
description: err instanceof Error ? err.message : String(err)
})
} finally {
setImporting(false)
}
}
const themes: {
id: GlobalSettings['theme']
label: string
hint: string
icon: typeof Monitor
}[] = [
{ id: 'system', label: 'System', hint: 'Match OS', icon: Monitor },
{ id: 'dark', label: 'Dark', hint: 'Easy on the eyes', icon: Moon },
{ id: 'light', label: 'Light', hint: 'Bright & crisp', icon: Sun }
]
return (
<div className="space-y-5">
<div className="grid grid-cols-3 gap-3">
{themes.map(({ id, label, hint, icon: Icon }) => {
const selected = theme === id
return (
<button
key={id}
className={cn(
'group overflow-hidden rounded-xl border p-3 text-left transition-all',
selected
? 'border-violet-500/60 bg-violet-500/10 ring-2 ring-violet-500/30'
: 'border-border bg-muted/30 hover:bg-muted/60'
)}
onClick={() => onThemeChange(id)}
>
<div className="relative mb-3 h-24 overflow-hidden rounded-lg border border-border">
<ChromePreview variant={id} />
{selected && (
<div className="absolute right-1.5 top-1.5 grid size-5 place-items-center rounded-full bg-violet-500 text-white shadow-sm">
<Check className="size-3" strokeWidth={3} />
</div>
)}
</div>
<div className="flex items-baseline justify-between gap-2">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<Icon className="size-3.5 text-muted-foreground" />
{label}
</div>
<div className="text-[11px] text-muted-foreground">{hint}</div>
</div>
</button>
)
})}
</div>
<GhosttyDiscoveryRow
discovery={discovery}
importing={importing}
disabled={!settings}
onImport={importGhostty}
/>
<div className="flex items-center gap-2 px-1 text-[12px] text-muted-foreground">
<Settings2 className="size-3.5" />
<span>
More terminal options font, cursor, palette in{' '}
<span className="font-medium text-foreground">Settings Terminal</span>
</span>
</div>
</div>
)
}
function GhosttyDiscoveryRow({
discovery,
importing,
disabled,
onImport
}: {
discovery: DiscoveryState
importing: boolean
disabled: boolean
onImport: (preview: GhosttyImportPreview) => void
}) {
if (discovery.status === 'absent') {
return null
}
if (discovery.status === 'detecting' || discovery.status === 'idle') {
return (
<div className="flex items-center gap-2.5 rounded-lg border border-dashed border-border bg-transparent px-3.5 py-2.5 text-[12px] text-muted-foreground">
<span className="size-1.5 animate-pulse rounded-full bg-muted-foreground/60" />
Looking for a Ghostty config
</div>
)
}
if (discovery.status === 'imported') {
return (
<div className="flex items-center gap-2.5 rounded-lg border border-emerald-500/30 bg-emerald-500/[0.07] px-3.5 py-2.5 text-[12px] text-foreground">
<Check className="size-3.5 text-emerald-600 dark:text-emerald-400" strokeWidth={3} />
<span className="flex-1">
<span className="font-medium">Imported from Ghostty.</span>
{discovery.fields.length > 0 && (
<span className="text-muted-foreground"> {discovery.fields.join(' · ')}</span>
)}
</span>
</div>
)
}
const { preview, fields } = discovery
return (
<div className="flex items-center gap-3 rounded-lg border border-violet-500/30 bg-violet-500/[0.06] px-3.5 py-2.5">
<img src={ghosttyIcon} alt="" className="size-4 shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-[12px] text-foreground">
<span className="font-medium">Ghostty config detected.</span>{' '}
<span className="text-muted-foreground">
Import {fields.length > 0 ? fields.map((f) => f.toLowerCase()).join(', ') : 'settings'}
?
</span>
</div>
{preview.configPath && (
<div
className="mt-0.5 truncate font-mono text-[10.5px] text-muted-foreground"
title={preview.configPath}
>
{preview.configPath}
</div>
)}
</div>
<button
className="shrink-0 rounded-md bg-foreground px-3 py-1.5 text-[11.5px] font-semibold text-background hover:bg-foreground/90 disabled:opacity-50"
disabled={importing || disabled}
onClick={() => onImport(preview)}
>
{importing ? 'Importing…' : 'Import'}
</button>
</div>
)
}
function ChromePreview({ variant }: { variant: GlobalSettings['theme'] }) {
if (variant === 'system') {
return (
<div className="relative size-full">
<div className="absolute inset-0" style={{ clipPath: 'polygon(0 0, 50% 0, 50% 100%, 0 100%)' }}>
<ChromeMock dark />
</div>
<div
className="absolute inset-0"
style={{ clipPath: 'polygon(50% 0, 100% 0, 100% 100%, 50% 100%)' }}
>
<ChromeMock dark={false} />
</div>
<div
aria-hidden
className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border/70"
/>
</div>
)
}
return <ChromeMock dark={variant === 'dark'} />
}
function ChromeMock({ dark }: { dark: boolean }) {
// Tiny Orca chrome: sidebar with two rows + a content area with a tab and
// a composer line. Pure Tailwind so it stays lightweight inside the tile.
const bg = dark ? 'bg-[#0f1115]' : 'bg-[#f7f8fa]'
const sidebar = dark ? 'bg-[#16181d]' : 'bg-[#eceef2]'
const sidebarBorder = dark ? 'border-white/5' : 'border-black/5'
const row = dark ? 'bg-white/10' : 'bg-black/10'
const rowDim = dark ? 'bg-white/5' : 'bg-black/5'
const tab = dark ? 'bg-[#1d2026] border-white/5' : 'bg-white border-black/5'
const accent = 'bg-violet-500/80'
return (
<div className={cn('flex size-full', bg)}>
<div className={cn('flex w-[34%] flex-col gap-1 border-r p-1.5', sidebar, sidebarBorder)}>
<div className={cn('h-1 w-7 rounded-sm', rowDim)} />
<div className="mt-0.5 flex items-center gap-1">
<span className={cn('size-1 rounded-full', accent)} />
<span className={cn('h-1 flex-1 rounded-sm', row)} />
</div>
<div className="flex items-center gap-1">
<span className={cn('size-1 rounded-full', rowDim)} />
<span className={cn('h-1 flex-1 rounded-sm', rowDim)} />
</div>
<div className="flex items-center gap-1">
<span className={cn('size-1 rounded-full', rowDim)} />
<span className={cn('h-1 w-3/4 rounded-sm', rowDim)} />
</div>
</div>
<div className="flex flex-1 flex-col p-1.5">
<div className="flex gap-1">
<div className={cn('h-2 w-8 rounded-sm border', tab)} />
<div className={cn('h-2 w-5 rounded-sm', rowDim)} />
</div>
<div className="mt-1.5 flex-1 space-y-1">
<div className={cn('h-1 w-full rounded-sm', rowDim)} />
<div className={cn('h-1 w-5/6 rounded-sm', rowDim)} />
<div className={cn('h-1 w-2/3 rounded-sm', rowDim)} />
</div>
<div className={cn('mt-1 flex h-2.5 items-center gap-1 rounded-sm border px-1', tab)}>
<span className={cn('size-1 rounded-full', accent)} />
<span className={cn('h-0.5 flex-1 rounded-sm', rowDim)} />
</div>
</div>
</div>
)
}
function humanFields(diff: Partial<GlobalSettings>): string[] {
// Why: chip labels are a friendly summary, not a strict 1:1 of mapper keys.
// Group related diff keys (font weight + family + size → "Font") so the row
// stays tidy. Anything in the diff that doesn't match a label still gets
// imported — it just isn't surfaced as a chip.
const groups: { label: string; keys: (keyof GlobalSettings)[] }[] = [
{ label: 'Font', keys: ['terminalFontFamily', 'terminalFontSize', 'terminalFontWeight'] },
{
label: 'Cursor',
keys: ['terminalCursorStyle', 'terminalCursorBlink', 'terminalCursorOpacity']
},
{ label: 'Theme palette', keys: ['terminalThemeDark', 'terminalThemeLight'] },
{ label: 'Colors', keys: ['terminalColorOverrides'] },
{ label: 'Padding', keys: ['terminalPaddingX', 'terminalPaddingY'] },
{
label: 'Window',
keys: ['terminalBackgroundOpacity', 'windowBackgroundBlur', 'terminalInactivePaneOpacity']
},
{
label: 'Dividers',
keys: ['terminalDividerColorDark', 'terminalDividerColorLight']
},
{ label: 'Mouse', keys: ['terminalMouseHideWhileTyping', 'terminalFocusFollowsMouse'] },
{ label: 'macOS Option key', keys: ['terminalMacOptionAsAlt'] }
]
return groups
.filter(({ keys }) => keys.some((k) => k in diff))
.map(({ label }) => label)
}

View File

@ -0,0 +1,7 @@
import type { OnboardingState } from '../../../../shared/types'
// Why: split out so App.tsx can gate the lazy <OnboardingFlow> without an
// eager static import path that pulls the whole flow into the main chunk.
export function shouldShowOnboarding(onboarding: OnboardingState | null): boolean {
return onboarding !== null && onboarding.closedAt === null
}

View File

@ -0,0 +1,180 @@
import { useCallback } from 'react'
import { track } from '@/lib/telemetry'
import { ONBOARDING_FINAL_STEP } from '../../../../shared/constants'
import type { GlobalSettings, OnboardingState, TuiAgent } from '../../../../shared/types'
import type { NotificationDraft } from './NotificationStep'
import type { StepId, StepNumber } from './use-onboarding-flow-types'
export async function persistStep(
stepNumber: number,
updates: Partial<OnboardingState> = {}
): Promise<OnboardingState> {
return window.api.onboarding.update({
lastCompletedStep: Math.max(stepNumber, -1),
...updates
})
}
function selectedAgentOrBlank(agent: TuiAgent | null): TuiAgent | 'blank' {
return agent ?? 'blank'
}
type CloseWithDeps = {
onOnboardingChange: (state: OnboardingState) => void
onboardingChecklist: OnboardingState['checklist']
startTimeRef: { current: number }
setError: (msg: string | null) => void
}
export function useCloseWith({
onOnboardingChange,
onboardingChecklist,
startTimeRef,
setError
}: CloseWithDeps) {
return useCallback(
async (
outcome: 'completed' | 'dismissed',
checklist: Partial<OnboardingState['checklist']>,
lastStepReached: StepNumber,
completedPath?: 'open_folder' | 'clone_url'
): Promise<boolean> => {
let nextState: OnboardingState
try {
// Why: main-process updateOnboarding already merges with current state,
// so spreading the local (potentially stale) onboarding.checklist would
// overwrite concurrent updates.
nextState = await window.api.onboarding.update({
closedAt: Date.now(),
outcome,
lastCompletedStep: outcome === 'completed' ? ONBOARDING_FINAL_STEP : -1,
checklist: {
...checklist,
dismissed: outcome === 'dismissed'
}
})
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
return false
}
onOnboardingChange(nextState)
if (outcome === 'completed' && completedPath) {
const total = Math.max(0, Date.now() - startTimeRef.current)
track('onboarding_completed', {
path: completedPath,
is_git_repo: checklist.addedRepo === true,
total_duration_ms: total
})
// Why: checklist items completed by the wizard itself must fire
// `activation_checklist_item_completed` so the post-wizard panel and
// analytics agree. Other items (ranFirstAgent, triedCmdJ, …) emit
// from their own product surfaces.
if (checklist.addedRepo && !onboardingChecklist.addedRepo) {
track('activation_checklist_item_completed', {
item: 'addedRepo',
time_since_completed_ms: 0
})
}
if (checklist.addedFolder && !onboardingChecklist.addedFolder) {
track('activation_checklist_item_completed', {
item: 'addedFolder',
time_since_completed_ms: 0
})
}
} else if (outcome === 'dismissed') {
track('onboarding_dismissed', { last_step: lastStepReached })
}
return true
},
[onOnboardingChange, onboardingChecklist, startTimeRef, setError]
)
}
type PersistCurrentStepDeps = {
currentStepId: StepId
selectedAgent: TuiAgent | null
theme: GlobalSettings['theme']
notifications: NotificationDraft
settings: GlobalSettings | null
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void> | void
onboardingChecklist: OnboardingState['checklist']
onOnboardingChange: (state: OnboardingState) => void
setError: (msg: string | null) => void
}
export function usePersistCurrentStep({
currentStepId,
selectedAgent,
theme,
notifications,
settings,
updateSettings,
onboardingChecklist,
onOnboardingChange,
setError
}: PersistCurrentStepDeps) {
return useCallback(async (): Promise<boolean> => {
if (!settings) {
return false
}
try {
if (currentStepId === 'agent') {
const defaultTuiAgent = selectedAgentOrBlank(selectedAgent)
await updateSettings({ defaultTuiAgent })
const choseAgent = defaultTuiAgent !== 'blank'
const wasAlreadyChosen = onboardingChecklist.choseAgent
onOnboardingChange(
await persistStep(1, {
checklist: { ...onboardingChecklist, choseAgent }
})
)
if (choseAgent && !wasAlreadyChosen) {
track('activation_checklist_item_completed', {
item: 'choseAgent',
time_since_completed_ms: 0
})
}
return true
}
if (currentStepId === 'theme') {
await updateSettings({ theme })
onOnboardingChange(await persistStep(2))
return true
}
if (currentStepId === 'notifications') {
const enabled = notifications.agentTaskComplete || notifications.terminalBell
if (enabled) {
// Why: triggers macOS first-prompt notification on first call. Only fire
// on Continue; Skip uses the persistence-only path below.
await window.api.notifications.requestPermission()
}
await updateSettings({
notifications: {
...settings.notifications,
enabled,
agentTaskComplete: notifications.agentTaskComplete,
terminalBell: notifications.terminalBell,
// Why: invert positive UX framing back to persisted negative field.
suppressWhenFocused: !notifications.notifyWhenFocused
}
})
onOnboardingChange(await persistStep(3))
return true
}
return false
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
return false
}
}, [
currentStepId,
notifications,
onboardingChecklist,
onOnboardingChange,
selectedAgent,
settings,
theme,
updateSettings,
setError
])
}

View File

@ -0,0 +1,13 @@
export type StepNumber = 1 | 2 | 3 | 4
export type StepId = 'agent' | 'theme' | 'notifications' | 'repo'
export const STEPS: readonly {
id: StepId
stepNumber: StepNumber
valueKind: 'agent' | 'theme' | 'notifications' | 'repo'
}[] = [
{ id: 'agent', stepNumber: 1, valueKind: 'agent' },
{ id: 'theme', stepNumber: 2, valueKind: 'theme' },
{ id: 'notifications', stepNumber: 3, valueKind: 'notifications' },
{ id: 'repo', stepNumber: 4, valueKind: 'repo' }
]

View File

@ -0,0 +1,347 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { useAppStore } from '@/store'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { applyDocumentTheme } from '@/lib/document-theme'
import { track } from '@/lib/telemetry'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { GlobalSettings, OnboardingState, TuiAgent } from '../../../../shared/types'
import type { NotificationDraft } from './NotificationStep'
import { STEPS, type StepNumber } from './use-onboarding-flow-types'
import {
persistStep,
useCloseWith,
usePersistCurrentStep
} from './use-onboarding-flow-persistence'
export { STEPS } from './use-onboarding-flow-types'
export type { StepId, StepNumber } from './use-onboarding-flow-types'
export type OnboardingFlowController = ReturnType<typeof useOnboardingFlow>
export function useOnboardingFlow(
onboarding: OnboardingState,
onOnboardingChange: (state: OnboardingState) => void
) {
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
const refreshDetectedAgents = useAppStore((s) => s.refreshDetectedAgents)
const detectedAgentIds = useAppStore((s) => s.detectedAgentIds)
const isDetectingAgents = useAppStore(
(s) => s.isDetectingAgents || s.isRefreshingAgents
)
const fetchRepos = useAppStore((s) => s.fetchRepos)
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
const openModal = useAppStore((s) => s.openModal)
const initialStep = Math.min(Math.max(onboarding.lastCompletedStep, 0), STEPS.length - 1)
const [stepIndex, setStepIndex] = useState(initialStep)
const [selectedAgent, setSelectedAgent] = useState<TuiAgent | null>(
settings?.defaultTuiAgent && settings.defaultTuiAgent !== 'blank'
? settings.defaultTuiAgent
: null
)
// Why: hydrate theme from saved settings instead of hardcoding 'dark' so users
// who already configured a theme see their choice preselected.
const [theme, setTheme] = useState<GlobalSettings['theme']>(settings?.theme ?? 'dark')
// Why: wizard force-defaults every toggle on (ignoring stored settings) so
// first-run users land in the most attentive state and choose what to dial
// back. Positive framing ("Notify when focused") inverts back to the
// persisted `suppressWhenFocused` field at save time.
const [notifications, setNotifications] = useState<NotificationDraft>({
agentTaskComplete: true,
terminalBell: true,
notifyWhenFocused: true
})
const [cloneUrl, setCloneUrl] = useState('')
const [busyLabel, setBusyLabel] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
// Why: settings load async; the lazy useState initializers above run before
// settings hydrates. Re-sync once when settings transitions to non-null,
// unless the user has already interacted with that field.
const themeInteractedRef = useRef(false)
const agentInteractedRef = useRef(false)
const settingsHydratedRef = useRef(false)
useEffect(() => {
if (!settings || settingsHydratedRef.current) {
return
}
settingsHydratedRef.current = true
if (!themeInteractedRef.current) {
setTheme(settings.theme)
}
if (!agentInteractedRef.current) {
const fromSettings =
settings.defaultTuiAgent && settings.defaultTuiAgent !== 'blank'
? settings.defaultTuiAgent
: null
if (fromSettings !== null) {
setSelectedAgent(fromSettings)
}
}
}, [settings])
// Why: track user interaction so async settings hydration above doesn't
// overwrite a value the user explicitly chose.
const setThemeInteractive = useCallback((value: GlobalSettings['theme']) => {
themeInteractedRef.current = true
setTheme(value)
}, [])
const setSelectedAgentInteractive = useCallback((value: TuiAgent | null) => {
agentInteractedRef.current = true
setSelectedAgent(value)
}, [])
const detectedSet = useMemo(() => new Set(detectedAgentIds ?? []), [detectedAgentIds])
const currentStep = STEPS[stepIndex]
// Why: pin start time once so onboarding_completed reports a real funnel duration.
const startTimeRef = useRef<number>(Date.now())
// Why: track the latest persisted theme in a ref so the unmount-only revert
// below uses the freshest value without retriggering on each settings change.
const persistedThemeRef = useRef<GlobalSettings['theme']>(settings?.theme ?? 'dark')
useEffect(() => {
persistedThemeRef.current = settings?.theme ?? 'dark'
}, [settings?.theme])
// Apply preview when local theme changes.
useEffect(() => {
applyDocumentTheme(theme)
}, [theme])
// Why: the theme step previews on the document before persistence. Revert to
// the persisted theme only on wizard unmount so saving (which updates
// settings.theme) doesn't trigger a one-frame revert/reapply flicker.
useEffect(() => {
return () => {
applyDocumentTheme(persistedThemeRef.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Why: ref guard prevents StrictMode's double-invoke from emitting
// `onboarding_started` twice on mount.
const startedTrackedRef = useRef(false)
useEffect(() => {
if (startedTrackedRef.current) {
return
}
startedTrackedRef.current = true
// Why: `resumed_from_step` is the step the user finished (1..3), not the
// step we resume into.
const lastCompleted = onboarding.lastCompletedStep
track(
'onboarding_started',
lastCompleted >= 1 && lastCompleted <= 3
? { resumed_from_step: lastCompleted as StepNumber }
: {}
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
track('onboarding_step_viewed', { step: currentStep.stepNumber })
}, [currentStep.stepNumber])
// Why: only auto-pick on first mount when detection completes; otherwise
// selecting an agent would re-trigger this effect and clobber/race user clicks.
const didAutoSelectRef = useRef(false)
const selectedAgentRef = useRef(selectedAgent)
useEffect(() => {
selectedAgentRef.current = selectedAgent
}, [selectedAgent])
useEffect(() => {
if (didAutoSelectRef.current) {
return
}
didAutoSelectRef.current = true
// Why: re-read PATH on wizard mount instead of reusing the session cache.
// The cache can be poisoned if a prior caller ran before shell PATH
// hydration finished, leaving the wizard with a false "no agents" state.
void refreshDetectedAgents().then((ids) => {
if (selectedAgentRef.current !== null) {
return
}
const preferred = AGENT_CATALOG.find((agent) => ids.includes(agent.id))?.id ?? null
setSelectedAgent(preferred)
})
}, [refreshDetectedAgents])
const closeWith = useCloseWith({
onOnboardingChange,
onboardingChecklist: onboarding.checklist,
startTimeRef,
setError
})
const completeRepo = useCallback(
async (repoId: string, isGit: boolean, path: 'open_folder' | 'clone_url') => {
await fetchRepos()
await fetchWorktrees(repoId)
const worktree = useAppStore.getState().worktreesByRepo[repoId]?.[0]
if (worktree) {
activateAndRevealWorktree(worktree.id)
}
// Why: next() short-circuits step 4, so emit step_completed here once the
// repo is successfully added to keep the funnel consistent. Gate on
// closeWith's success so a persistence failure doesn't double-count.
const closed = await closeWith(
'completed',
isGit ? { addedRepo: true } : { addedFolder: true },
4,
path
)
if (!closed) {
return
}
track('onboarding_step_completed', { step: 4, value_kind: 'repo' })
if (isGit) {
openModal('new-workspace-composer', {
initialRepoId: repoId,
prefilledName: 'onboarding',
telemetrySource: 'onboarding'
})
}
},
[closeWith, fetchRepos, fetchWorktrees, openModal]
)
const persistCurrentStep = usePersistCurrentStep({
currentStepId: currentStep.id,
selectedAgent,
theme,
notifications,
settings,
updateSettings,
onboardingChecklist: onboarding.checklist,
onOnboardingChange,
setError
})
const next = useCallback(async () => {
if (busyLabel || currentStep.id === 'repo') {
return
}
const ok = await persistCurrentStep()
if (ok) {
track('onboarding_step_completed', {
step: currentStep.stepNumber,
value_kind: currentStep.valueKind
})
setStepIndex((idx) => Math.min(idx + 1, STEPS.length - 1))
}
}, [busyLabel, currentStep.id, currentStep.stepNumber, currentStep.valueKind, persistCurrentStep])
const openFolder = useCallback(async () => {
// Why: re-entry guard — rapid Cmd+Enter must not launch duplicate pickers.
if (busyLabel !== null) {
return
}
setError(null)
track('onboarding_step4_path_clicked', { path: 'open_folder' })
const path = await window.api.repos.pickFolder()
if (!path) {
track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'cancelled' })
return
}
setBusyLabel('Opening project…')
try {
let result = await window.api.repos.add({ path })
if ('error' in result && result.error.includes('Not a valid git repository')) {
result = await window.api.repos.add({ path, kind: 'folder' })
}
if ('error' in result) {
throw new Error(result.error)
}
await completeRepo(result.repo.id, isGitRepoKind(result.repo), 'open_folder')
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' })
} finally {
setBusyLabel(null)
}
}, [busyLabel, completeRepo])
const clone = useCallback(async () => {
// Why: re-entry guard — prevents Enter spamming from triggering duplicate clones.
if (busyLabel !== null) {
return
}
const trimmed = cloneUrl.trim()
if (!trimmed || !settings) {
return
}
setError(null)
track('onboarding_step4_path_clicked', { path: 'clone_url' })
setBusyLabel('Cloning repo…')
try {
const repo = await window.api.repos.clone({ url: trimmed, destination: settings.workspaceDir })
await completeRepo(repo.id, true, 'clone_url')
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
track('onboarding_step4_path_failed', { path: 'clone_url', reason: 'clone_failed' })
toast.error('Clone failed', {
description: err instanceof Error ? err.message : String(err)
})
} finally {
setBusyLabel(null)
}
}, [busyLabel, cloneUrl, completeRepo, settings])
const skip = useCallback(async () => {
if (busyLabel) {
return
}
track('onboarding_step_skipped', { step: currentStep.stepNumber })
// Why: theme step previews on the document without persisting. On skip,
// revert to the saved theme before advancing so the preview doesn't leak.
if (currentStep.id === 'theme' && settings) {
setTheme(settings.theme)
applyDocumentTheme(settings.theme)
}
if (currentStep.id === 'repo') {
await closeWith('dismissed', {}, currentStep.stepNumber)
return
}
// Why: persistence-only path — does NOT trigger requestPermission, so
// skipping step 3 never fires the OS permission prompt.
try {
onOnboardingChange(await persistStep(currentStep.stepNumber))
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
return
}
setStepIndex((idx) => Math.min(idx + 1, STEPS.length - 1))
}, [busyLabel, closeWith, currentStep.id, currentStep.stepNumber, onOnboardingChange, settings])
const back = useCallback(() => {
setStepIndex((idx) => Math.max(idx - 1, 0))
}, [])
return {
settings,
updateSettings,
stepIndex,
currentStep,
selectedAgent,
setSelectedAgent: setSelectedAgentInteractive,
theme,
setTheme: setThemeInteractive,
notifications,
setNotifications,
cloneUrl,
setCloneUrl,
busyLabel,
error,
detectedSet,
isDetectingAgents,
next,
skip,
back,
openFolder,
clone
}
}

View File

@ -1327,9 +1327,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
// "create" path is the new-workspace surface; request_kind is
// `'new'` because this is always a fresh session (issue/PR-driven
// follow-ups go through launch-work-item-direct.ts).
// Why: when the composer is opened from onboarding, the first
// `agent_started` must attribute to `onboarding` so D1 activation
// can be measured against the funnel.
const composerTelemetry: AgentStartedTelemetry = {
agent_kind: tuiAgentToAgentKind(tuiAgent),
launch_source: 'new_workspace_composer',
launch_source: telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer',
request_kind: 'new'
}
activateAndRevealWorktree(worktree.id, {
@ -1527,7 +1530,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
? null
: {
agent_kind: tuiAgentToAgentKind(agent),
launch_source: 'new_workspace_composer',
launch_source:
telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer',
request_kind: 'new'
}
activateAndRevealWorktree(worktree.id, {

View File

@ -58,6 +58,9 @@ export function applyDocumentTheme(
}
root.classList.toggle('dark', shouldUseDarkTheme)
// Mirror with `light` so consumers can observe the resolved theme
// symmetrically (Tailwind keys only on `dark`, so this is style-neutral).
root.classList.toggle('light', !shouldUseDarkTheme)
if (!disableTransitions) {
return

View File

@ -0,0 +1,24 @@
// Why: shared across global keyboard listeners (App-level shortcuts and the
// onboarding flow) so an in-progress text edit never gets hijacked by a
// capture-phase keydown handler.
export function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false
}
// xterm.js focuses a hidden <textarea class="xterm-helper-textarea"> for
// keyboard input. That element IS an editable target, but we must NOT
// suppress global shortcuts when the terminal itself is focused — otherwise
// Cmd/Ctrl+P and other app-level keybindings become unreachable.
if (target.classList.contains('xterm-helper-textarea')) {
return false
}
if (target.isContentEditable) {
return true
}
return (
target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]') !==
null
)
}

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()
}
}

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]> }

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 ─────────────────────────────────────────────

View File

@ -37,7 +37,29 @@ type OrcaWorkerFixtures = {
testRepoPath: string
}
// Why: parse + warn at module scope so a bad ORCA_E2E_SLOWMO_MS value logs once
// per worker instead of once per test (otherwise hundreds of lines per CI run).
const ORCA_E2E_SLOWMO_MS_RAW = process.env.ORCA_E2E_SLOWMO_MS
const ORCA_E2E_SLOWMO_MS = ((): number => {
if (ORCA_E2E_SLOWMO_MS_RAW === undefined) {
return 0
}
const parsed = Number(ORCA_E2E_SLOWMO_MS_RAW)
if (!Number.isFinite(parsed)) {
console.warn(
`[orca-e2e] ORCA_E2E_SLOWMO_MS="${ORCA_E2E_SLOWMO_MS_RAW}" is not a number; ignoring (using 0).`
)
return 0
}
return Math.max(parsed, 0)
})()
function shouldLaunchHeadful(testInfo: TestInfo): boolean {
// Why: ORCA_E2E_FORCE_HEADFUL lets a developer watch any spec in a real
// window without retagging it `@headful` or switching projects.
if (process.env.ORCA_E2E_FORCE_HEADFUL === '1') {
return true
}
return testInfo.project.metadata.orcaHeadful === true
}
@ -129,8 +151,26 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
// which Node rejects with "bad option" and the process exits immediately.
const { ELECTRON_RUN_AS_NODE: _unused, ...cleanEnv } = process.env
void _unused
// Why: ORCA_E2E_SLOWMO_MS adds a pause between every Playwright action so a
// developer running with ORCA_E2E_FORCE_HEADFUL=1 can actually watch what
// the test does. Defaults to 0 (no slowdown) for normal runs.
const slowMo = ORCA_E2E_SLOWMO_MS
// Why: ORCA_E2E_RECORD_VIDEO=1 captures a webm of the renderer so a
// developer can replay the run later — Electron's Playwright trace viewer
// does not produce DOM snapshots, so video is the only reliable replay.
// Why: testInfo.outputDir is created lazily by Playwright; on Windows the
// dir may not exist when the fixture initializes, and Electron silently
// drops the recording. mkdir up-front so the recorder always has a home.
const recordVideoDir = process.env.ORCA_E2E_RECORD_VIDEO === '1'
? testInfo.outputDir
: null
if (recordVideoDir) {
mkdirSync(recordVideoDir, { recursive: true })
}
const app = await electron.launch({
args: [mainPath],
...(slowMo > 0 ? { slowMo } : {}),
...(recordVideoDir ? { recordVideo: { dir: recordVideoDir } } : {}),
// Why: keep NODE_ENV=development so window.__store is exposed and
// dev-only helpers activate. ORCA_E2E_USER_DATA_DIR overrides the usual
// shared dev profile so every spec gets a clean persistence root.

View File

@ -0,0 +1,377 @@
/**
* E2E tests for the first-launch Onboarding flow.
*
* The onboarding overlay is gated by `OnboardingState.closedAt === null` (see
* `shouldShowOnboarding` in `should-show-onboarding.ts`). Each test gets a fresh
* Electron instance + isolated userData dir, so persistence starts clean and
* the overlay renders on first paint without any setup.
*/
import { test, expect } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
import type { Page } from '@stablyai/playwright-test'
import type { GlobalSettings, TuiAgent } from '../../src/shared/types'
type OnboardingState = {
closedAt: number | null
outcome: 'completed' | 'dismissed' | null
lastCompletedStep: number
checklist: Record<string, boolean>
}
async function getOnboardingState(page: Page): Promise<OnboardingState> {
return page.evaluate(() => window.api.onboarding.get() as Promise<OnboardingState>)
}
async function getSettings(page: Page): Promise<GlobalSettings> {
return page.evaluate(() => window.api.settings.get())
}
async function getDocumentThemeClass(page: Page): Promise<'dark' | 'light'> {
return page.evaluate(() =>
document.documentElement.classList.contains('dark') ? 'dark' : 'light'
)
}
test.describe('Onboarding flow', () => {
test.beforeEach(async ({ orcaPage }) => {
// Per-test userData is freshly minted by the orcaPage fixture, so persisted
// onboarding state defaults to `closedAt: null, lastCompletedStep: -1` and
// the overlay paints on its own once App's bootstrap effect resolves.
await waitForSessionReady(orcaPage)
})
test('renders on first launch with the agent step active', async ({ orcaPage }) => {
await expect(
orcaPage.getByRole('heading', { name: /Pick your default agent/i })
).toBeVisible({ timeout: 15_000 })
await expect(orcaPage.getByText('1 of 4')).toBeVisible()
await expect(orcaPage.getByRole('button', { name: 'Continue' })).toBeVisible()
await expect(orcaPage.getByRole('button', { name: 'Skip' })).toBeVisible()
// Why: Back is not rendered on the first step (was previously rendered-but-
// disabled with `disabled:invisible`, now conditionally mounted).
await expect(orcaPage.getByRole('button', { name: 'Back', exact: true })).toHaveCount(0)
// Footer hint shows the platform-correct continue shortcut (⌘↵ on Mac,
// Ctrl+Enter elsewhere). Match either form so the test runs cross-platform.
// Why: scope to the footer's <kbd> element so background UI (e.g. menus or
// command palette hints) can't false-positive this assertion.
await expect(orcaPage.locator('footer kbd').filter({ hasText: /⌘↵|Ctrl\+Enter/ })).toBeVisible()
})
test('Continue advances steps, persists progress, and applies user-visible settings', async ({
orcaPage
}) => {
await expect(
orcaPage.getByRole('heading', { name: /Pick your default agent/i })
).toBeVisible({ timeout: 15_000 })
// --- Step 1: agent ---
// Force a deterministic, non-default selection so the assertion below
// proves the wizard actually wrote the user's choice (not just the
// pre-selected detected agent). Codex sits in the top-6 catalog when no
// agents are detected, otherwise behind the "Show N more agents" details
// expander — open it if codex isn't visible.
const targetAgent: TuiAgent = 'codex'
const codexButton = orcaPage.getByRole('button', { name: /^Codex\s/ })
// Why: isVisible() is a one-shot probe — on slow renderer paint it would
// race the wizard mount and falsely take the "show more agents" branch.
// waitFor with a small timeout actually retries until the button paints.
const codexVisible = await codexButton
.first()
.waitFor({ state: 'visible', timeout: 1_000 })
.then(() => true)
.catch(() => false)
if (!codexVisible) {
await orcaPage.getByText(/Show \d+ more agents/).click()
}
await codexButton.click()
await orcaPage.getByRole('button', { name: 'Continue' }).click()
await expect(
orcaPage.getByRole('heading', { name: /Make it feel like home/i })
).toBeVisible()
await expect(orcaPage.getByText('2 of 4')).toBeVisible()
await expect
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
timeout: 5_000,
message: 'lastCompletedStep did not advance to 1 after first Continue'
})
.toBe(1)
// The agent choice must be persisted to settings (the user will see this
// pre-selected when they later open a new tab / agent picker).
await expect
.poll(async () => (await getSettings(orcaPage)).defaultTuiAgent, { timeout: 5_000 })
.toBe(targetAgent)
// --- Step 2: theme ---
// Default settings.theme is 'system', so the document class can resolve to
// either 'dark' or 'light' depending on the host. Click the opposite tile
// so we always observe a live flip — the assertion that proves the wizard
// applies the choice immediately, not just on Continue.
// Why: 'system' resolves async on mount, so wait for the class to settle
// before snapshotting — otherwise startingTheme can be stale.
await orcaPage.waitForFunction(
() =>
document.documentElement.classList.contains('dark') ||
document.documentElement.classList.contains('light')
)
const startingTheme = await getDocumentThemeClass(orcaPage)
const oppositeTheme: 'dark' | 'light' = startingTheme === 'dark' ? 'light' : 'dark'
const oppositeTileName = oppositeTheme === 'light' ? /Bright & crisp/ : /Easy on the eyes/
await orcaPage.getByRole('button', { name: oppositeTileName }).click()
await expect
.poll(async () => getDocumentThemeClass(orcaPage), { timeout: 5_000 })
.toBe(oppositeTheme)
await orcaPage.getByRole('button', { name: 'Continue' }).click()
await expect(
orcaPage.getByRole('heading', { name: /Know when an agent needs you/i })
).toBeVisible()
await expect(orcaPage.getByText('3 of 4')).toBeVisible()
await expect
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
timeout: 5_000,
message: 'lastCompletedStep did not advance to 2 after second Continue'
})
.toBe(2)
await expect.poll(async () => (await getSettings(orcaPage)).theme, { timeout: 5_000 }).toBe(
oppositeTheme
)
// --- Step 3: notifications ---
// Why: the wizard force-defaults every toggle ON (use-onboarding-flow.ts),
// which intentionally diverges from the app defaults (terminalBell=false,
// suppressWhenFocused=true). Click Continue without touching the toggles —
// the post-Continue assertion proves the wizard wrote its opt-in defaults
// through the IPC boundary, including the inverted suppressWhenFocused.
await orcaPage.getByRole('button', { name: 'Continue' }).click()
await expect(
orcaPage.getByRole('heading', { name: /Point Orca at some code/i })
).toBeVisible()
await expect(orcaPage.getByText('4 of 4')).toBeVisible()
await expect(orcaPage.getByRole('button', { name: 'Continue' })).toHaveCount(0)
await expect(orcaPage.getByRole('button', { name: /I'll add one later/ })).toBeVisible()
await expect
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
timeout: 5_000
})
.toBe(3)
// Verify all three notification fields landed in settings, including the
// inverted suppressWhenFocused boundary (UI: notifyWhenFocused=true →
// persisted: suppressWhenFocused=false).
await expect
.poll(
async () => {
const s = await getSettings(orcaPage)
return {
agentTaskComplete: s.notifications.agentTaskComplete,
terminalBell: s.notifications.terminalBell,
suppressWhenFocused: s.notifications.suppressWhenFocused,
enabled: s.notifications.enabled
}
},
{ timeout: 5_000 }
)
.toEqual({
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false,
enabled: true
})
})
test('Cmd/Ctrl+Enter advances steps like Continue', async ({ orcaPage }) => {
await expect(
orcaPage.getByRole('heading', { name: /Pick your default agent/i })
).toBeVisible({ timeout: 15_000 })
// Why: the OS the renderer reports drives whether Cmd or Ctrl is the
// accelerator (OnboardingFlow.tsx checks navigator.userAgent).
const isMac = await orcaPage.evaluate(() => navigator.userAgent.includes('Mac'))
const accelerator = isMac ? 'Meta+Enter' : 'Control+Enter'
// Why: in headless Linux CI the window-level capture-phase listener can
// miss synthetic keyboard events when no element holds focus. Click an
// inert area inside the overlay first to anchor focus, then press.
await orcaPage.locator('footer').click({ position: { x: 1, y: 1 } })
await orcaPage.keyboard.press(accelerator)
await expect(
orcaPage.getByRole('heading', { name: /Make it feel like home/i })
).toBeVisible()
await expect
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
timeout: 5_000
})
.toBe(1)
})
test('selected agent button reports aria-pressed=true', async ({ orcaPage }) => {
await expect(
orcaPage.getByRole('heading', { name: /Pick your default agent/i })
).toBeVisible({ timeout: 15_000 })
const codexButton = orcaPage.getByRole('button', { name: /^Codex\s/ })
const codexVisible = await codexButton
.first()
.waitFor({ state: 'visible', timeout: 1_000 })
.then(() => true)
.catch(() => false)
if (!codexVisible) {
await orcaPage.getByText(/Show \d+ more agents/).click()
}
await codexButton.click()
// Why: AgentButton now sets aria-pressed so screen readers and assistive
// tech can announce the selection. Verify the attribute reflects state.
await expect(codexButton).toHaveAttribute('aria-pressed', 'true')
})
test('notification toggles flip independently and persist on Continue', async ({ orcaPage }) => {
await expect(
orcaPage.getByRole('heading', { name: /Pick your default agent/i })
).toBeVisible({ timeout: 15_000 })
await orcaPage.getByRole('button', { name: 'Skip' }).click()
await expect(
orcaPage.getByRole('heading', { name: /Make it feel like home/i })
).toBeVisible()
await orcaPage.getByRole('button', { name: 'Skip' }).click()
await expect(
orcaPage.getByRole('heading', { name: /Know when an agent needs you/i })
).toBeVisible()
// Why: NotificationStep buttons expose role="switch" + aria-checked. Flip
// terminalBell off and verify the toggle reflects + persists. The other
// two toggles stay at their wizard-default ON state.
const bellSwitch = orcaPage.getByRole('switch', { name: /Terminal bell/i })
await expect(bellSwitch).toHaveAttribute('aria-checked', 'true')
await bellSwitch.click()
await expect(bellSwitch).toHaveAttribute('aria-checked', 'false')
await orcaPage.getByRole('button', { name: 'Continue' }).click()
await expect(
orcaPage.getByRole('heading', { name: /Point Orca at some code/i })
).toBeVisible()
await expect
.poll(
async () => {
const s = await getSettings(orcaPage)
return {
agentTaskComplete: s.notifications.agentTaskComplete,
terminalBell: s.notifications.terminalBell
}
},
{ timeout: 5_000 }
)
.toEqual({ agentTaskComplete: true, terminalBell: false })
})
test('typing in the clone-url input does not hijack Enter as a global shortcut', async ({
orcaPage
}) => {
await expect(
orcaPage.getByRole('heading', { name: /Pick your default agent/i })
).toBeVisible({ timeout: 15_000 })
// Skip to the repo step.
await orcaPage.getByRole('button', { name: 'Skip' }).click()
await orcaPage.getByRole('button', { name: 'Skip' }).click()
await orcaPage.getByRole('button', { name: 'Skip' }).click()
await expect(
orcaPage.getByRole('heading', { name: /Point Orca at some code/i })
).toBeVisible()
// Why: focus the clone-url input and press Cmd/Ctrl+Enter. The capture-
// phase keydown handler should bail via isEditableTarget, so the folder
// picker IPC must NOT fire (the heading should remain visible — no
// navigation, no opened OS dialog). A bare Enter press also must not
// submit the empty form (the Clone button is disabled when blank).
const isMac = await orcaPage.evaluate(() => navigator.userAgent.includes('Mac'))
const accelerator = isMac ? 'Meta+Enter' : 'Control+Enter'
const input = orcaPage.getByPlaceholder('git@github.com:org/repo.git')
await input.click()
await input.press(accelerator)
// Brief wait so any (incorrect) handler firing would have already happened.
await orcaPage.waitForTimeout(250)
await expect(
orcaPage.getByRole('heading', { name: /Point Orca at some code/i })
).toBeVisible()
// Onboarding must still be open (closedAt remains null).
expect((await getOnboardingState(orcaPage)).closedAt).toBeNull()
})
test('Back returns to the previous step without losing progress', async ({ orcaPage }) => {
await expect(
orcaPage.getByRole('heading', { name: /Pick your default agent/i })
).toBeVisible({ timeout: 15_000 })
await orcaPage.getByRole('button', { name: 'Continue' }).click()
await expect(
orcaPage.getByRole('heading', { name: /Make it feel like home/i })
).toBeVisible()
await expect
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
timeout: 5_000
})
.toBe(1)
// Why: exact match — the app sidebar also exposes a "Go back" button that
// would otherwise match this regex.
await orcaPage.getByRole('button', { name: 'Back', exact: true }).click()
await expect(
orcaPage.getByRole('heading', { name: /Pick your default agent/i })
).toBeVisible()
await expect(orcaPage.getByText('1 of 4')).toBeVisible()
// Why: "without losing progress" means persisted lastCompletedStep stays
// at 1 — Back rewinds the visible step but must not roll persistence back.
// Poll because persistence flushes async via IPC after the Back click.
await expect
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
timeout: 5_000
})
.toBe(1)
})
test('"I\'ll add one later" on the repo step dismisses onboarding', async ({ orcaPage }) => {
await expect(
orcaPage.getByRole('heading', { name: /Pick your default agent/i })
).toBeVisible({ timeout: 15_000 })
// Skip through the first three steps. On steps 13 the affordance is
// labelled "Skip"; on the repo step it is "I'll add one later".
await orcaPage.getByRole('button', { name: 'Skip' }).click()
await expect(
orcaPage.getByRole('heading', { name: /Make it feel like home/i })
).toBeVisible()
await orcaPage.getByRole('button', { name: 'Skip' }).click()
await expect(
orcaPage.getByRole('heading', { name: /Know when an agent needs you/i })
).toBeVisible()
await orcaPage.getByRole('button', { name: 'Skip' }).click()
await expect(
orcaPage.getByRole('heading', { name: /Point Orca at some code/i })
).toBeVisible()
await orcaPage.getByRole('button', { name: /I'll add one later/ }).click()
// The overlay is unmounted once `closedAt` is set, so the heading must
// disappear from the DOM, not merely become invisible.
await expect(
orcaPage.getByRole('heading', { name: /Point Orca at some code/i })
).toHaveCount(0, { timeout: 10_000 })
// Why: DOM unmount fires when closedAt flips in the renderer, but the
// main-process write can lag by an IPC tick. Poll until the persisted
// record reflects the dismissal before asserting on its shape.
await expect
.poll(async () => (await getOnboardingState(orcaPage)).closedAt !== null, {
timeout: 5_000
})
.toBe(true)
const final = await getOnboardingState(orcaPage)
expect(final.outcome).toBe('dismissed')
expect(final.checklist.dismissed).toBe(true)
// Why: dismiss path resets lastCompletedStep to -1 (use-onboarding-flow.ts
// closeWith) so a future re-open would start at step 1. Lock that in.
expect(final.lastCompletedStep).toBe(-1)
})
})