Add onboarding feature setup checklist (#1853)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-05-14 15:21:30 -07:00
committed by GitHub
co-authored by Orca
parent be72e64a47
commit 2e5ac1c8eb
30 changed files with 1498 additions and 85 deletions
@@ -0,0 +1,85 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { _resetValidatorWarnCacheForTests, validate } from './validator'
describe('onboarding feature setup telemetry validation', () => {
beforeEach(() => {
_resetValidatorWarnCacheForTests()
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('accepts checklist, setup run, and terminal interaction events', () => {
const selection = {
browser_use: true,
computer_use: false,
orchestration: true,
selected_count: 2
}
const cases = [
['onboarding_feature_setup_toggled', { feature: 'browser_use', selected: false }],
[
'onboarding_feature_setup_run',
{
...selection,
cli_touched: true,
skill_commands_copied: true,
skill_install_command_prepared: true,
computer_use_permissions_opened: false,
warning_count: 0
}
],
['onboarding_feature_setup_terminal_opened', selection],
['onboarding_feature_setup_terminal_interacted', { ...selection, method: 'keyboard' }]
] as const
for (const [event, props] of cases) {
expect(validate(event, props).ok).toBe(true)
}
})
it('rejects raw strings and unknown fields', () => {
expect(
validate('onboarding_feature_setup_terminal_opened', {
browser_use: true,
computer_use: false,
orchestration: true,
selected_count: 2,
command: 'npx skills add https://github.com/stablyai/orca --global'
} as never).ok
).toBe(false)
expect(
validate('onboarding_feature_setup_toggled', {
feature: 'browser_use',
selected: false,
path: '/Users/alice/project'
} as never).ok
).toBe(false)
})
it('rejects selected_count values that do not match selected features', () => {
expect(
validate('onboarding_feature_setup_run', {
browser_use: false,
computer_use: false,
orchestration: false,
selected_count: 3,
cli_touched: false,
skill_commands_copied: false,
skill_install_command_prepared: false,
computer_use_permissions_opened: false,
warning_count: 0
} as never).ok
).toBe(false)
expect(
validate('onboarding_feature_setup_terminal_opened', {
browser_use: true,
computer_use: false,
orchestration: true,
selected_count: 1
} as never).ok
).toBe(false)
})
})
@@ -11,7 +11,11 @@ import {
} from '@/components/ui/dialog'
import { PASTE_TERMINAL_TEXT_EVENT } from '@/constants/terminal'
import { ORCHESTRATION_SKILL_INSTALL_COMMAND } from '@/lib/orchestration-install-command'
import { notifyOrchestrationSetupStateChanged } from '@/lib/orchestration-setup-state'
import {
ORCHESTRATION_ENABLED_STORAGE_KEY,
ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY,
notifyOrchestrationSetupStateChanged
} from '@/lib/orchestration-setup-state'
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
type FloatingTerminalOrchestrationDialogProps = {
@@ -75,8 +79,8 @@ export function FloatingTerminalOrchestrationDialog({
const handlePasteSkillCommand = async (): Promise<void> => {
setSkillBusy(true)
try {
localStorage.setItem('orca.orchestration.enabled', '1')
localStorage.removeItem('orca.orchestration.setupDismissed')
localStorage.setItem(ORCHESTRATION_ENABLED_STORAGE_KEY, '1')
localStorage.removeItem(ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY)
notifyOrchestrationSetupStateChanged()
await window.api.ui.writeClipboardText(ORCHESTRATION_SKILL_INSTALL_COMMAND)
if (activeTabId) {
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import {
ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY,
ORCHESTRATION_SETUP_STATE_EVENT,
hasOrchestrationSetupMarker,
isOrchestrationSetupDismissed,
@@ -327,7 +328,7 @@ export function FloatingTerminalPanel({
}
const dismissOrchestrationSetup = useCallback(() => {
localStorage.setItem('orca.orchestration.setupDismissed', '1')
localStorage.setItem(ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY, '1')
setShowOrchestrationSetup(false)
notifyOrchestrationSetupStateChanged()
}, [])
@@ -28,7 +28,7 @@ export function AgentStep({ selectedAgent, onSelect, detectedSet, isDetecting }:
// 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,
// 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.
@@ -49,7 +49,7 @@ export function AgentStep({ selectedAgent, onSelect, detectedSet, isDetecting }:
{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
<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
@@ -0,0 +1,114 @@
import type { ReactNode } from 'react'
import { Check, Globe2, MonitorCog, Workflow } from 'lucide-react'
import { cn } from '@/lib/utils'
import type {
OnboardingFeatureSetupId,
OnboardingFeatureSetupSelection
} from './onboarding-feature-setup'
type FeatureSetupChecklistProps = {
value: OnboardingFeatureSetupSelection
onChange: (value: OnboardingFeatureSetupSelection) => void
}
type FeatureSetupRow = {
id: OnboardingFeatureSetupId
title: string
description: string
setupSummary: string
icon: ReactNode
}
const FEATURE_SETUP_ROWS: readonly FeatureSetupRow[] = [
{
id: 'browserUse',
title: 'Agent Browser Use',
description: 'Agents can navigate sites, inspect pages, and work through browser tasks.',
setupSummary: 'Enables browser use, prepares orca-cli, and leaves cookies for Settings.',
icon: <Globe2 className="size-4" />
},
{
id: 'computerUse',
title: 'Computer Use',
description: 'Agents can inspect app windows and operate local apps when you ask.',
setupSummary: 'Registers `orca`, opens permissions, and prepares the skill.',
icon: <MonitorCog className="size-4" />
},
{
id: 'orchestration',
title: 'Agent Orchestration',
description: 'Agents can message each other, take tasks, and coordinate handoffs.',
setupSummary: 'Registers `orca`, enables orchestration, and prepares the skill.',
icon: <Workflow className="size-4" />
}
]
export function FeatureSetupChecklist({
value,
onChange
}: FeatureSetupChecklistProps): React.JSX.Element {
return (
<section className="mt-6 space-y-3">
<div className="space-y-1">
<h2 className="text-sm font-semibold text-foreground">Set up agent features</h2>
<p className="text-[13px] leading-relaxed text-muted-foreground">
Pick the capabilities you want ready after onboarding. Selected features run setup on the
next click and show a terminal here with the skill command ready for review.
</p>
</div>
<div className="grid gap-3 md:grid-cols-3">
{FEATURE_SETUP_ROWS.map((row) => {
const selected = value[row.id]
return (
<button
key={row.id}
type="button"
role="checkbox"
aria-checked={selected}
className={cn(
'flex min-h-40 flex-col rounded-lg border px-4 py-3 text-left transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
selected
? 'border-foreground/40 bg-card text-foreground'
: 'border-border bg-muted/20 text-muted-foreground hover:bg-muted/40'
)}
onClick={() => onChange({ ...value, [row.id]: !selected })}
>
<span className="flex items-start justify-between gap-3">
<span
className={cn(
'flex size-8 items-center justify-center rounded-lg border',
selected
? 'border-border bg-muted text-foreground'
: 'border-border bg-muted/40'
)}
>
{row.icon}
</span>
<span
aria-hidden
className={cn(
'flex size-5 items-center justify-center rounded-full border transition-colors',
selected
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-background'
)}
>
{selected ? <Check className="size-3.5" /> : null}
</span>
</span>
<span className="mt-3 text-sm font-medium text-foreground">{row.title}</span>
<span className="mt-1 text-xs leading-relaxed text-muted-foreground">
{row.description}
</span>
<span className="mt-auto pt-3 text-[11px] leading-relaxed text-muted-foreground">
{row.setupSummary}
</span>
</button>
)
})}
</div>
</section>
)
}
@@ -0,0 +1,196 @@
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { Loader2 } from 'lucide-react'
import TerminalPane from '@/components/terminal-pane/TerminalPane'
import { PASTE_TERMINAL_TEXT_EVENT, type PasteTerminalTextDetail } from '@/constants/terminal'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { track } from '@/lib/telemetry'
import { useAppStore } from '@/store'
import {
onboardingFeatureSetupTelemetrySelection,
type OnboardingFeatureSetupSelection
} from './onboarding-feature-setup'
const ONBOARDING_SETUP_TERMINAL_WORKTREE_ID = 'onboarding-setup-terminal'
const AUTO_INSERT_DELAY_MS = 700
const READY_RETRY_MS = 100
const READY_MAX_ATTEMPTS = 50
type FeatureSetupInlineTerminalProps = {
command: string
selection: OnboardingFeatureSetupSelection
}
export function FeatureSetupInlineTerminal({
command,
selection
}: FeatureSetupInlineTerminalProps): React.JSX.Element {
const createTab = useAppStore((s) => s.createTab)
const closeTab = useAppStore((s) => s.closeTab)
const setActiveTabForWorktree = useAppStore((s) => s.setActiveTabForWorktree)
const setTabCustomTitle = useAppStore((s) => s.setTabCustomTitle)
const [cwd, setCwd] = useState<string | null>(null)
const [tabId, setTabId] = useState<string | null>(null)
const terminalSectionRef = useRef<HTMLElement>(null)
const autoInsertedRef = useRef<string | null>(null)
const terminalOpenedTrackedRef = useRef(false)
const terminalInteractedTrackedRef = useRef(false)
const selectionTelemetry = useMemo(
() => onboardingFeatureSetupTelemetrySelection(selection),
[selection]
)
useEffect(() => {
if (terminalOpenedTrackedRef.current) {
return
}
terminalOpenedTrackedRef.current = true
track('onboarding_feature_setup_terminal_opened', selectionTelemetry)
}, [selectionTelemetry])
const trackTerminalInteraction = useCallback(
(method: 'keyboard' | 'pointer', event?: KeyboardEvent<HTMLElement>) => {
if (terminalInteractedTrackedRef.current) {
return
}
const isMac = navigator.userAgent.includes('Mac')
const isContinueShortcut = event?.key === 'Enter' && (isMac ? event.metaKey : event.ctrlKey)
if (isContinueShortcut) {
return
}
// Why: auto-insert focuses the terminal programmatically; only count
// direct terminal activity, not the global continue shortcut.
terminalInteractedTrackedRef.current = true
track('onboarding_feature_setup_terminal_interacted', {
...selectionTelemetry,
method
})
},
[selectionTelemetry]
)
useEffect(() => {
void window.api.app.getFloatingTerminalCwd({ path: '~' }).then(setCwd)
}, [])
useEffect(() => {
const tab = createTab(ONBOARDING_SETUP_TERMINAL_WORKTREE_ID, undefined, undefined, {
activate: false
})
setActiveTabForWorktree(ONBOARDING_SETUP_TERMINAL_WORKTREE_ID, tab.id)
setTabCustomTitle(tab.id, 'Skill setup')
setTabId(tab.id)
}, [createTab, setActiveTabForWorktree, setTabCustomTitle])
useEffect(() => {
const frame = window.requestAnimationFrame(() => {
const prefersReducedMotion =
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
terminalSectionRef.current?.scrollIntoView({
behavior: prefersReducedMotion ? 'auto' : 'smooth',
block: 'center'
})
})
return () => window.cancelAnimationFrame(frame)
}, [])
const insertCommand = useCallback(() => {
if (!tabId) {
return
}
terminalSectionRef.current?.scrollIntoView({
behavior: 'auto',
block: 'nearest'
})
window.dispatchEvent(
new CustomEvent<PasteTerminalTextDetail>(PASTE_TERMINAL_TEXT_EVENT, {
detail: {
tabId,
text: command.trim()
}
})
)
focusTerminalTabSurface(tabId)
}, [command, tabId])
useEffect(() => {
if (!tabId || autoInsertedRef.current === command) {
return
}
let canceled = false
let insertionTimer: number | null = null
const waitForTerminal = (attempt: number): void => {
if (canceled) {
return
}
if (findTerminalTabElement(tabId)?.querySelector('[data-pty-id]')) {
insertionTimer = window.setTimeout(() => {
if (!canceled) {
autoInsertedRef.current = command
insertCommand()
}
}, AUTO_INSERT_DELAY_MS)
return
}
if (attempt < READY_MAX_ATTEMPTS) {
window.setTimeout(() => waitForTerminal(attempt + 1), READY_RETRY_MS)
}
}
waitForTerminal(0)
return () => {
canceled = true
if (insertionTimer !== null) {
window.clearTimeout(insertionTimer)
}
}
}, [command, insertCommand, tabId])
return (
<section
ref={terminalSectionRef}
aria-label="Skill setup command"
className="mt-5 overflow-hidden rounded-xl border border-border bg-card"
>
<div className="border-b border-border px-4 py-3">
<p className="text-xs leading-relaxed text-muted-foreground">
Press Enter to run the command and confirm npm if asked. You can also set this up later in
Settings.
</p>
</div>
<div
className="relative h-[280px] min-h-0 bg-background"
onKeyDownCapture={(event) => trackTerminalInteraction('keyboard', event)}
onPointerDownCapture={() => trackTerminalInteraction('pointer')}
>
{cwd && tabId ? (
<TerminalPane
tabId={tabId}
worktreeId={ONBOARDING_SETUP_TERMINAL_WORKTREE_ID}
cwd={cwd}
isActive
isVisible
onPtyExit={() => closeTab(tabId)}
onCloseTab={() => closeTab(tabId)}
/>
) : (
<div className="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Starting terminal...
</div>
)}
</div>
</section>
)
}
function findTerminalTabElement(tabId: string): HTMLElement | null {
for (const element of document.querySelectorAll<HTMLElement>('[data-terminal-tab-id]')) {
if (element.dataset.terminalTabId === tabId) {
return element
}
}
return null
}
@@ -0,0 +1,32 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { NotificationStep } from './NotificationStep'
describe('NotificationStep', () => {
it('renders the feature setup checklist in the notification step', () => {
const html = renderToStaticMarkup(
<NotificationStep
value={{
agentTaskComplete: true,
terminalBell: true,
notifyWhenFocused: true
}}
onChange={vi.fn()}
featureSetup={{
browserUse: true,
computerUse: true,
orchestration: true
}}
onFeatureSetupChange={vi.fn()}
featureSetupCommand={null}
featureSetupCommandSelection={null}
/>
)
expect(html).toContain('Set up agent features')
expect(html).toContain('Agent Browser Use')
expect(html).toContain('Computer Use')
expect(html).toContain('Agent Orchestration')
expect(html).toContain('role="checkbox"')
})
})
@@ -1,4 +1,7 @@
import { cn } from '@/lib/utils'
import { FeatureSetupInlineTerminal } from './FeatureSetupInlineTerminal'
import { FeatureSetupChecklist } from './FeatureSetupChecklist'
import type { OnboardingFeatureSetupSelection } from './onboarding-feature-setup'
// Why: wizard uses positive framing ("notify when focused"); persisted
// setting stays `suppressWhenFocused` and is inverted at the boundary.
@@ -11,9 +14,20 @@ export type NotificationDraft = {
type NotificationStepProps = {
value: NotificationDraft
onChange: (value: NotificationDraft) => void
featureSetup: OnboardingFeatureSetupSelection
onFeatureSetupChange: (value: OnboardingFeatureSetupSelection) => void
featureSetupCommand: string | null
featureSetupCommandSelection: OnboardingFeatureSetupSelection | null
}
export function NotificationStep({ value, onChange }: NotificationStepProps) {
export function NotificationStep({
value,
onChange,
featureSetup,
onFeatureSetupChange,
featureSetupCommand,
featureSetupCommandSelection
}: NotificationStepProps) {
const rows: { key: keyof NotificationDraft; title: string; description: string }[] = [
{
key: 'agentTaskComplete',
@@ -23,7 +37,7 @@ export function NotificationStep({ value, onChange }: NotificationStepProps) {
{
key: 'terminalBell',
title: 'Terminal bell',
description: 'Play a sound when a terminal rings usually a question waiting on you.'
description: 'Play a sound when a terminal rings, usually a question waiting on you.'
},
{
key: 'notifyWhenFocused',
@@ -68,9 +82,16 @@ export function NotificationStep({ value, onChange }: NotificationStepProps) {
))}
</div>
<p className="mt-3 text-[13px] text-muted-foreground">
Configure other agent status personalization like custom sounds or pet sidekicks under{' '}
Configure other agent status personalization, like custom sounds, under{' '}
<span className="font-medium text-foreground">Settings Notifications</span>.
</p>
<FeatureSetupChecklist value={featureSetup} onChange={onFeatureSetupChange} />
{featureSetupCommand ? (
<FeatureSetupInlineTerminal
command={featureSetupCommand}
selection={featureSetupCommandSelection ?? featureSetup}
/>
) : null}
</>
)
}
@@ -1,5 +1,5 @@
import { useEffect } from 'react'
import { ChevronLeft } from 'lucide-react'
import { ChevronLeft, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { isEditableTarget } from '@/lib/editable-target'
import type { OnboardingState } from '../../../../shared/types'
@@ -18,15 +18,16 @@ 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."
"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.'
title: 'Set up Orca for agents',
subtitle:
'Get notifications when agents need you, and choose the capabilities Orca should enable on this computer.'
},
repo: {
title: 'Point Orca at some code',
@@ -46,6 +47,11 @@ export default function OnboardingFlow({
const flow = useOnboardingFlow(onboarding, onOnboardingChange)
const { currentStep, stepIndex, busyLabel } = flow
const copy = stepCopy[currentStep.id]
const shouldShowSetupAction =
currentStep.id === 'notifications' &&
flow.hasSelectedFeatureSetup &&
!flow.featureSetupTerminalCommand
const primaryActionLabel = busyLabel ?? (shouldShowSetupAction ? 'Set up' : 'Continue')
// 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
@@ -153,7 +159,14 @@ export default function OnboardingFlow({
/>
)}
{currentStep.id === 'notifications' && (
<NotificationStep value={flow.notifications} onChange={flow.setNotifications} />
<NotificationStep
value={flow.notifications}
onChange={flow.setNotifications}
featureSetup={flow.featureSetupSelection}
onFeatureSetupChange={flow.setFeatureSetupSelection}
featureSetupCommand={flow.featureSetupTerminalCommand}
featureSetupCommandSelection={flow.featureSetupTerminalSelection}
/>
)}
{currentStep.id === 'repo' && (
<RepoStep
@@ -173,15 +186,25 @@ export default function OnboardingFlow({
<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>
<span>
{currentStep.id === 'repo'
? 'open folder'
: currentStep.id === 'notifications' &&
flow.hasSelectedFeatureSetup &&
!flow.featureSetupTerminalCommand
? 'set up'
: 'continue'}
</span>
</div>
<div className="flex items-center gap-2">
<button
className={
className={cn(
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'
}
: 'rounded-md px-3 py-2 text-sm text-muted-foreground hover:text-foreground',
'disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:text-muted-foreground'
)}
disabled={Boolean(busyLabel)}
onClick={() => void flow.skip()}
>
{currentStep.id === 'repo' ? "I'll add one later" : 'Skip'}
@@ -198,11 +221,13 @@ export default function OnboardingFlow({
)}
{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"
className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-5 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
aria-busy={Boolean(busyLabel)}
disabled={Boolean(busyLabel)}
onClick={() => void flow.next()}
>
Continue
{busyLabel ? <Loader2 className="size-4 animate-spin" /> : null}
{primaryActionLabel}
</button>
)}
</div>
@@ -34,7 +34,7 @@ export function RepoStep({
<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.
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">
@@ -211,7 +211,7 @@ export function ThemeStep({ theme, onThemeChange, settings, updateSettings }: Th
<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{' '}
More terminal options, including font, cursor, and palette, in{' '}
<span className="font-medium text-foreground">Settings Terminal</span>
</span>
</div>
@@ -367,7 +367,7 @@ 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.
// imported; it just isn't surfaced as a chip.
const groups: { label: string; keys: (keyof GlobalSettings)[] }[] = [
{ label: 'Font', keys: ['terminalFontFamily', 'terminalFontSize', 'terminalFontWeight'] },
{
@@ -0,0 +1,260 @@
import { describe, expect, it, vi } from 'vitest'
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
import type {
ComputerUsePermissionSetupResult,
ComputerUsePermissionStatusResult
} from '../../../../shared/computer-use-permissions-types'
import {
buildAgentFeatureSkillInstallCommand,
COMPUTER_USE_SKILL_NAME,
ORCA_CLI_SKILL_NAME,
ORCHESTRATION_SKILL_NAME
} from '@/lib/agent-feature-install-commands'
import { BROWSER_USE_ENABLED_STORAGE_KEY } from '@/lib/browser-use-setup-state'
import {
ORCHESTRATION_ENABLED_STORAGE_KEY,
ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY
} from '@/lib/orchestration-setup-state'
import {
DEFAULT_ONBOARDING_FEATURE_SETUP_SELECTION,
buildOnboardingFeatureSetupClipboardText,
onboardingFeatureSetupRunTelemetry,
onboardingFeatureSetupTelemetryFeature,
onboardingFeatureSetupTelemetrySelection,
runOnboardingFeatureSetup,
type OnboardingFeatureSetupDeps,
type OnboardingFeatureSetupSelection
} from './onboarding-feature-setup'
const ALL_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([
ORCA_CLI_SKILL_NAME,
COMPUTER_USE_SKILL_NAME,
ORCHESTRATION_SKILL_NAME
])
const ORCHESTRATION_ONLY_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([
ORCHESTRATION_SKILL_NAME
])
const INSTALLED_CLI_STATUS: CliInstallStatus = {
platform: 'darwin',
commandName: 'orca',
commandPath: '/usr/local/bin/orca',
pathDirectory: '/usr/local/bin',
pathConfigured: true,
launcherPath: '/Applications/Orca.app/Contents/MacOS/Orca',
installMethod: 'symlink',
supported: true,
state: 'installed',
currentTarget: '/Applications/Orca.app/Contents/MacOS/Orca',
unsupportedReason: null,
detail: null
}
const GRANTED_COMPUTER_USE_STATUS: ComputerUsePermissionStatusResult = {
platform: 'darwin',
permissions: [
{ id: 'accessibility', status: 'granted' },
{ id: 'screenshots', status: 'granted' }
]
}
const OPENED_COMPUTER_USE_SETUP: ComputerUsePermissionSetupResult = {
platform: 'darwin',
helperAppPath: '/Applications/Orca.app',
openedSettings: true,
launchedHelper: true
}
function createDeps(
overrides: Partial<OnboardingFeatureSetupDeps> = {}
): OnboardingFeatureSetupDeps & {
storage: Map<string, string>
clipboardWrites: string[]
} {
const storage = new Map<string, string>()
const clipboardWrites: string[] = []
return {
storage,
clipboardWrites,
getCliStatus: vi.fn(async () => INSTALLED_CLI_STATUS),
installCli: vi.fn(async () => INSTALLED_CLI_STATUS),
writeClipboardText: vi.fn(async (text: string) => {
clipboardWrites.push(text)
}),
getComputerUsePermissionStatus: vi.fn(async () => GRANTED_COMPUTER_USE_STATUS),
openComputerUsePermissionSetup: vi.fn(async () => OPENED_COMPUTER_USE_SETUP),
setStorageItem: vi.fn((key: string, value: string) => {
storage.set(key, value)
}),
removeStorageItem: vi.fn((key: string) => {
storage.delete(key)
}),
notifyOrchestrationStateChanged: vi.fn(),
...overrides
}
}
describe('onboarding feature setup runner', () => {
it('defaults every setup item on so first-launch setup is ready to run', () => {
expect(DEFAULT_ONBOARDING_FEATURE_SETUP_SELECTION).toEqual({
browserUse: true,
computerUse: true,
orchestration: true
})
})
it('builds one skill command for the selected Browser Use, Computer Use, and Orchestration features', () => {
const text = buildOnboardingFeatureSetupClipboardText({
browserUse: true,
computerUse: true,
orchestration: true
})
expect(text).toBe(ALL_SKILL_INSTALL_COMMAND)
expect(text).toBe(
'npx skills add https://github.com/stablyai/orca --skill orca-cli computer-use orchestration --global'
)
})
it('builds privacy-safe telemetry payloads for selected feature setup items', () => {
const selection: OnboardingFeatureSetupSelection = {
browserUse: true,
computerUse: false,
orchestration: true
}
expect(onboardingFeatureSetupTelemetryFeature('browserUse')).toBe('browser_use')
expect(onboardingFeatureSetupTelemetrySelection(selection)).toEqual({
browser_use: true,
computer_use: false,
orchestration: true,
selected_count: 2
})
expect(
onboardingFeatureSetupRunTelemetry(selection, {
selectedIds: ['browserUse', 'orchestration'],
cliTouched: true,
skillCommandsCopied: false,
skillInstallCommand: ORCHESTRATION_ONLY_SKILL_INSTALL_COMMAND,
computerUsePermissionsOpened: false,
warnings: [{ featureId: 'skills', message: 'Clipboard unavailable' }]
})
).toEqual({
browser_use: true,
computer_use: false,
orchestration: true,
selected_count: 2,
cli_touched: true,
skill_commands_copied: false,
skill_install_command_prepared: true,
computer_use_permissions_opened: false,
warning_count: 1
})
})
it('runs selected Browser Use, Computer Use, and Orchestration setup through injected deps only', async () => {
const deps = createDeps({
getComputerUsePermissionStatus: vi.fn(
async (): Promise<ComputerUsePermissionStatusResult> => ({
platform: 'darwin',
permissions: [
{ id: 'accessibility', status: 'not-granted' },
{ id: 'screenshots', status: 'granted' }
]
})
)
})
const result = await runOnboardingFeatureSetup(
{ browserUse: true, computerUse: true, orchestration: true },
deps
)
expect(result).toEqual({
selectedIds: ['browserUse', 'computerUse', 'orchestration'],
cliTouched: false,
skillCommandsCopied: true,
skillInstallCommand: ALL_SKILL_INSTALL_COMMAND,
computerUsePermissionsOpened: true,
warnings: []
})
expect(deps.getCliStatus).toHaveBeenCalledTimes(1)
expect(deps.installCli).not.toHaveBeenCalled()
expect(deps.getComputerUsePermissionStatus).toHaveBeenCalledTimes(1)
expect(deps.openComputerUsePermissionSetup).toHaveBeenCalledTimes(1)
expect(deps.storage.get(BROWSER_USE_ENABLED_STORAGE_KEY)).toBe('1')
expect(deps.storage.get(ORCHESTRATION_ENABLED_STORAGE_KEY)).toBe('1')
expect(deps.removeStorageItem).toHaveBeenCalledWith(ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY)
expect(deps.notifyOrchestrationStateChanged).toHaveBeenCalledTimes(1)
expect(deps.clipboardWrites).toEqual([ALL_SKILL_INSTALL_COMMAND])
})
it('keeps invasive Browser Use and Computer Use setup untouched when only Orchestration is selected', async () => {
const deps = createDeps()
const selection: OnboardingFeatureSetupSelection = {
browserUse: false,
computerUse: false,
orchestration: true
}
const result = await runOnboardingFeatureSetup(selection, deps)
expect(result.selectedIds).toEqual(['orchestration'])
expect(result.skillCommandsCopied).toBe(true)
expect(result.skillInstallCommand).toBe(ORCHESTRATION_ONLY_SKILL_INSTALL_COMMAND)
expect(result.computerUsePermissionsOpened).toBe(false)
expect(deps.getCliStatus).toHaveBeenCalledTimes(1)
expect(deps.installCli).not.toHaveBeenCalled()
expect(deps.getComputerUsePermissionStatus).not.toHaveBeenCalled()
expect(deps.openComputerUsePermissionSetup).not.toHaveBeenCalled()
expect(deps.storage.get(BROWSER_USE_ENABLED_STORAGE_KEY)).toBe('0')
expect(deps.storage.get(ORCHESTRATION_ENABLED_STORAGE_KEY)).toBe('1')
expect(deps.clipboardWrites).toEqual([ORCHESTRATION_ONLY_SKILL_INSTALL_COMMAND])
})
it('clears feature markers when no setup items are selected', async () => {
const deps = createDeps()
const result = await runOnboardingFeatureSetup(
{ browserUse: false, computerUse: false, orchestration: false },
deps
)
expect(result).toEqual({
selectedIds: [],
cliTouched: false,
skillCommandsCopied: false,
skillInstallCommand: null,
computerUsePermissionsOpened: false,
warnings: []
})
expect(deps.storage.get(BROWSER_USE_ENABLED_STORAGE_KEY)).toBe('0')
expect(deps.storage.get(ORCHESTRATION_ENABLED_STORAGE_KEY)).toBe('0')
expect(deps.getCliStatus).not.toHaveBeenCalled()
expect(deps.getComputerUsePermissionStatus).not.toHaveBeenCalled()
expect(deps.clipboardWrites).toEqual([])
})
it('warns when selected skill commands cannot be copied', async () => {
const deps = createDeps({
writeClipboardText: vi.fn(async () => {
throw new Error('Clipboard unavailable')
})
})
const result = await runOnboardingFeatureSetup(
{ browserUse: false, computerUse: false, orchestration: true },
deps
)
expect(result.skillCommandsCopied).toBe(false)
expect(result.skillInstallCommand).toBe(ORCHESTRATION_ONLY_SKILL_INSTALL_COMMAND)
expect(result.warnings).toEqual([
{
featureId: 'skills',
message: 'Clipboard unavailable'
}
])
expect(deps.clipboardWrites).toEqual([])
})
})
@@ -0,0 +1,270 @@
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
import type {
ComputerUsePermissionSetupResult,
ComputerUsePermissionStatusResult
} from '../../../../shared/computer-use-permissions-types'
import {
COMPUTER_USE_SKILL_NAME,
ORCA_CLI_SKILL_NAME,
ORCHESTRATION_SKILL_NAME,
buildAgentFeatureSkillInstallCommand
} from '@/lib/agent-feature-install-commands'
import { BROWSER_USE_ENABLED_STORAGE_KEY } from '@/lib/browser-use-setup-state'
import { e2eConfig } from '@/lib/e2e-config'
import {
ORCHESTRATION_ENABLED_STORAGE_KEY,
ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY,
notifyOrchestrationSetupStateChanged
} from '@/lib/orchestration-setup-state'
import type { EventProps } from '../../../../shared/telemetry-events'
export type OnboardingFeatureSetupId = 'browserUse' | 'computerUse' | 'orchestration'
export type OnboardingFeatureSetupSelection = Record<OnboardingFeatureSetupId, boolean>
export const DEFAULT_ONBOARDING_FEATURE_SETUP_SELECTION: OnboardingFeatureSetupSelection = {
browserUse: true,
computerUse: true,
orchestration: true
}
export const ONBOARDING_FEATURE_SETUP_IDS: readonly OnboardingFeatureSetupId[] = [
'browserUse',
'computerUse',
'orchestration'
]
const FEATURE_SKILL_NAMES: Record<OnboardingFeatureSetupId, string> = {
browserUse: ORCA_CLI_SKILL_NAME,
computerUse: COMPUTER_USE_SKILL_NAME,
orchestration: ORCHESTRATION_SKILL_NAME
}
const FEATURE_TELEMETRY_IDS: Record<
OnboardingFeatureSetupId,
EventProps<'onboarding_feature_setup_toggled'>['feature']
> = {
browserUse: 'browser_use',
computerUse: 'computer_use',
orchestration: 'orchestration'
}
export type OnboardingFeatureSetupWarning = {
featureId: OnboardingFeatureSetupId | 'cli' | 'skills'
message: string
}
export type OnboardingFeatureSetupResult = {
selectedIds: OnboardingFeatureSetupId[]
cliTouched: boolean
skillCommandsCopied: boolean
skillInstallCommand: string | null
computerUsePermissionsOpened: boolean
warnings: OnboardingFeatureSetupWarning[]
}
export type OnboardingFeatureSetupDeps = {
getCliStatus: () => Promise<CliInstallStatus>
installCli: () => Promise<CliInstallStatus>
writeClipboardText: (text: string) => Promise<void>
getComputerUsePermissionStatus: () => Promise<ComputerUsePermissionStatusResult>
openComputerUsePermissionSetup: () => Promise<ComputerUsePermissionSetupResult>
setStorageItem: (key: string, value: string) => void
removeStorageItem: (key: string) => void
notifyOrchestrationStateChanged: () => void
}
export function hasSelectedOnboardingFeatureSetup(
selection: OnboardingFeatureSetupSelection
): boolean {
return ONBOARDING_FEATURE_SETUP_IDS.some((id) => selection[id])
}
export function selectedOnboardingFeatureSetupIds(
selection: OnboardingFeatureSetupSelection
): OnboardingFeatureSetupId[] {
return ONBOARDING_FEATURE_SETUP_IDS.filter((id) => selection[id])
}
export function buildOnboardingFeatureSetupClipboardText(
selection: OnboardingFeatureSetupSelection
): string | null {
return buildOnboardingFeatureSetupSkillCommand(selection)
}
export function buildOnboardingFeatureSetupSkillCommand(
selection: OnboardingFeatureSetupSelection
): string | null {
const skillNames = selectedOnboardingFeatureSetupIds(selection).map(
(id) => FEATURE_SKILL_NAMES[id]
)
if (skillNames.length === 0) {
return null
}
return buildAgentFeatureSkillInstallCommand(skillNames)
}
export function onboardingFeatureSetupTelemetryFeature(
id: OnboardingFeatureSetupId
): EventProps<'onboarding_feature_setup_toggled'>['feature'] {
return FEATURE_TELEMETRY_IDS[id]
}
export function onboardingFeatureSetupTelemetrySelection(
selection: OnboardingFeatureSetupSelection
): EventProps<'onboarding_feature_setup_terminal_opened'> {
return {
browser_use: selection.browserUse,
computer_use: selection.computerUse,
orchestration: selection.orchestration,
selected_count: selectedOnboardingFeatureSetupIds(selection).length
}
}
export function onboardingFeatureSetupRunTelemetry(
selection: OnboardingFeatureSetupSelection,
result: OnboardingFeatureSetupResult
): EventProps<'onboarding_feature_setup_run'> {
return {
...onboardingFeatureSetupTelemetrySelection(selection),
cli_touched: result.cliTouched,
skill_commands_copied: result.skillCommandsCopied,
skill_install_command_prepared: result.skillInstallCommand !== null,
computer_use_permissions_opened: result.computerUsePermissionsOpened,
warning_count: result.warnings.length
}
}
export function createOnboardingFeatureSetupDeps(): OnboardingFeatureSetupDeps {
const e2eDeps = getE2EOnboardingFeatureSetupDeps()
if (e2eDeps) {
return e2eDeps
}
return {
getCliStatus: () => window.api.cli.getInstallStatus(),
installCli: () => window.api.cli.install(),
writeClipboardText: (text) => window.api.ui.writeClipboardText(text),
getComputerUsePermissionStatus: () => window.api.computerUsePermissions.getStatus(),
openComputerUsePermissionSetup: () => window.api.computerUsePermissions.openSetup(),
setStorageItem: (key, value) => localStorage.setItem(key, value),
removeStorageItem: (key) => localStorage.removeItem(key),
notifyOrchestrationStateChanged: notifyOrchestrationSetupStateChanged
}
}
function getE2EOnboardingFeatureSetupDeps(): OnboardingFeatureSetupDeps | null {
if (!e2eConfig.enabled || typeof window === 'undefined') {
return null
}
return (
(window as unknown as { __onboardingFeatureSetupDeps?: OnboardingFeatureSetupDeps })
.__onboardingFeatureSetupDeps ?? null
)
}
export async function runOnboardingFeatureSetup(
selection: OnboardingFeatureSetupSelection,
deps: OnboardingFeatureSetupDeps = createOnboardingFeatureSetupDeps()
): Promise<OnboardingFeatureSetupResult> {
const selectedIds = selectedOnboardingFeatureSetupIds(selection)
const warnings: OnboardingFeatureSetupWarning[] = []
let cliTouched = false
let skillCommandsCopied = false
const skillInstallCommand = buildOnboardingFeatureSetupSkillCommand(selection)
let computerUsePermissionsOpened = false
deps.setStorageItem(BROWSER_USE_ENABLED_STORAGE_KEY, selection.browserUse ? '1' : '0')
deps.setStorageItem(ORCHESTRATION_ENABLED_STORAGE_KEY, selection.orchestration ? '1' : '0')
if (selection.orchestration) {
deps.removeStorageItem(ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY)
}
deps.notifyOrchestrationStateChanged()
if (selectedIds.length === 0) {
return {
selectedIds,
cliTouched,
skillCommandsCopied,
skillInstallCommand,
computerUsePermissionsOpened,
warnings
}
}
try {
const status = await deps.getCliStatus()
if (!status.supported) {
warnings.push({
featureId: 'cli',
message: status.detail ?? 'Orca CLI registration is not available on this platform.'
})
} else if (status.state !== 'installed') {
const next = await deps.installCli()
cliTouched = true
if (next.state !== 'installed') {
warnings.push({
featureId: 'cli',
message: next.detail ?? 'Orca CLI registration needs attention.'
})
} else if (!next.pathConfigured && next.detail) {
warnings.push({ featureId: 'cli', message: next.detail })
}
} else if (!status.pathConfigured && status.detail) {
warnings.push({ featureId: 'cli', message: status.detail })
}
} catch (error) {
warnings.push({ featureId: 'cli', message: formatFeatureSetupError(error) })
}
if (selection.computerUse) {
try {
const status = await deps.getComputerUsePermissionStatus()
const needsMacPermissions =
status.platform === 'darwin' &&
status.permissions.some((permission) => permission.status !== 'granted')
if (needsMacPermissions) {
await deps.openComputerUsePermissionSetup()
computerUsePermissionsOpened = true
}
} catch (error) {
warnings.push({
featureId: 'computerUse',
message: formatFeatureSetupError(error)
})
}
}
skillCommandsCopied = await copySkillCommands(selection, deps, warnings)
return {
selectedIds,
cliTouched,
skillCommandsCopied,
skillInstallCommand,
computerUsePermissionsOpened,
warnings
}
}
function formatFeatureSetupError(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
async function copySkillCommands(
selection: OnboardingFeatureSetupSelection,
deps: OnboardingFeatureSetupDeps,
warnings: OnboardingFeatureSetupWarning[]
): Promise<boolean> {
const clipboardText = buildOnboardingFeatureSetupClipboardText(selection)
if (!clipboardText) {
return false
}
try {
await deps.writeClipboardText(clipboardText)
return true
} catch (error) {
warnings.push({ featureId: 'skills', message: formatFeatureSetupError(error) })
return false
}
}
@@ -1,8 +1,16 @@
import { useCallback } from 'react'
import { toast } from 'sonner'
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 {
hasSelectedOnboardingFeatureSetup,
onboardingFeatureSetupRunTelemetry,
runOnboardingFeatureSetup,
type OnboardingFeatureSetupResult,
type OnboardingFeatureSetupSelection
} from './onboarding-feature-setup'
import type { StepId, StepNumber } from './use-onboarding-flow-types'
export async function persistStep(
@@ -109,6 +117,7 @@ type PersistCurrentStepDeps = {
selectedAgent: TuiAgent | null
theme: GlobalSettings['theme']
notifications: NotificationDraft
featureSetupSelection: OnboardingFeatureSetupSelection
settings: GlobalSettings | null
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void> | void
onboardingChecklist: OnboardingState['checklist']
@@ -116,20 +125,26 @@ type PersistCurrentStepDeps = {
setError: (msg: string | null) => void
}
export type PersistCurrentStepResult = {
ok: boolean
featureSetupResult?: OnboardingFeatureSetupResult
}
export function usePersistCurrentStep({
currentStepId,
selectedAgent,
theme,
notifications,
featureSetupSelection,
settings,
updateSettings,
onboardingChecklist,
onOnboardingChange,
setError
}: PersistCurrentStepDeps) {
return useCallback(async (): Promise<boolean> => {
return useCallback(async (): Promise<PersistCurrentStepResult> => {
if (!settings) {
return false
return { ok: false }
}
try {
if (currentStepId === 'agent') {
@@ -148,12 +163,12 @@ export function usePersistCurrentStep({
time_since_completed_ms: 0
})
}
return true
return { ok: true }
}
if (currentStepId === 'theme') {
await updateSettings({ theme })
onOnboardingChange(await persistStep(2))
return true
return { ok: true }
}
if (currentStepId === 'notifications') {
const enabled = notifications.agentTaskComplete || notifications.terminalBell
@@ -172,16 +187,38 @@ export function usePersistCurrentStep({
suppressWhenFocused: !notifications.notifyWhenFocused
}
})
const setupResult = await runOnboardingFeatureSetup(featureSetupSelection)
const featureSetupResult: OnboardingFeatureSetupResult = setupResult
track('onboarding_feature_setup_run', {
...onboardingFeatureSetupRunTelemetry(featureSetupSelection, setupResult)
})
if (hasSelectedOnboardingFeatureSetup(featureSetupSelection)) {
const firstWarning = setupResult.warnings[0]
if (firstWarning) {
toast.warning('Some feature setup needs attention', {
description: firstWarning.message
})
}
if (setupResult.skillCommandsCopied) {
toast.success('Feature setup ready', {
description: 'Skill command copied and inserted below for review.'
})
}
if (setupResult.computerUsePermissionsOpened) {
toast.message('Opened Computer Use permissions')
}
}
onOnboardingChange(await persistStep(3))
return true
return { ok: true, featureSetupResult }
}
return false
return { ok: false }
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
return false
return { ok: false }
}
}, [
currentStepId,
featureSetupSelection,
notifications,
onboardingChecklist,
onOnboardingChange,
@@ -10,6 +10,13 @@ import { buildAgentPickedPayload } from './agent-picked-payload'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { GlobalSettings, OnboardingState, TuiAgent } from '../../../../shared/types'
import type { NotificationDraft } from './NotificationStep'
import {
DEFAULT_ONBOARDING_FEATURE_SETUP_SELECTION,
ONBOARDING_FEATURE_SETUP_IDS,
hasSelectedOnboardingFeatureSetup,
onboardingFeatureSetupTelemetryFeature,
type OnboardingFeatureSetupSelection
} from './onboarding-feature-setup'
import { STEPS, type StepNumber } from './use-onboarding-flow-types'
import { persistStep, useCloseWith, usePersistCurrentStep } from './use-onboarding-flow-persistence'
@@ -52,6 +59,15 @@ export function useOnboardingFlow(
terminalBell: true,
notifyWhenFocused: true
})
const [featureSetupSelection, setFeatureSetupSelection] =
useState<OnboardingFeatureSetupSelection>(DEFAULT_ONBOARDING_FEATURE_SETUP_SELECTION)
const [featureSetupTerminalCommand, setFeatureSetupTerminalCommand] = useState<string | null>(
null
)
// Why: terminal telemetry must describe the selection that produced the
// command, even if the checklist changes while async setup is finishing.
const [featureSetupTerminalSelection, setFeatureSetupTerminalSelection] =
useState<OnboardingFeatureSetupSelection | null>(null)
const [cloneUrl, setCloneUrl] = useState('')
const [busyLabel, setBusyLabel] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -283,12 +299,30 @@ export function useOnboardingFlow(
selectedAgent,
theme,
notifications,
featureSetupSelection,
settings,
updateSettings,
onboardingChecklist: onboarding.checklist,
onOnboardingChange,
setError
})
const hasSelectedFeatureSetup = hasSelectedOnboardingFeatureSetup(featureSetupSelection)
const setFeatureSetupSelectionInteractive = useCallback(
(value: OnboardingFeatureSetupSelection) => {
for (const id of ONBOARDING_FEATURE_SETUP_IDS) {
if (value[id] !== featureSetupSelection[id]) {
track('onboarding_feature_setup_toggled', {
feature: onboardingFeatureSetupTelemetryFeature(id),
selected: value[id]
})
}
}
setFeatureSetupSelection(value)
setFeatureSetupTerminalCommand(null)
setFeatureSetupTerminalSelection(null)
},
[featureSetupSelection]
)
// Why: synchronous re-entry latch. `busyLabel` is React state and only
// commits after the awaited persistCurrentStep round-trip resolves, so a
@@ -296,24 +330,54 @@ export function useOnboardingFlow(
// the first call's setStepIndex has run, advancing twice and skipping a
// step. A ref flips synchronously so re-entries bail immediately.
const nextInFlightRef = useRef(false)
const notificationsStepCompletedTrackedRef = useRef(false)
const next = useCallback(
async (advancedVia: 'button' | 'keyboard' = 'button') => {
if (nextInFlightRef.current || busyLabel || currentStep.id === 'repo') {
return
}
if (currentStep.id === 'notifications' && featureSetupTerminalCommand) {
setStepIndex((idx) => Math.min(idx + 1, STEPS.length - 1))
return
}
nextInFlightRef.current = true
if (currentStep.id === 'notifications' && hasSelectedFeatureSetup) {
setBusyLabel('Setting up features…')
}
try {
const ok = await persistCurrentStep()
if (ok) {
const trackCurrentStepCompleted = (): void => {
if (currentStep.id === 'notifications') {
if (notificationsStepCompletedTrackedRef.current) {
return
}
// Why: feature setup can keep the user on this already-persisted
// step to review a terminal command; later checklist edits must
// not double-count the same step completion.
notificationsStepCompletedTrackedRef.current = true
}
track('onboarding_step_completed', {
step: currentStep.stepNumber,
value_kind: currentStep.valueKind,
duration_ms: consumeStepDurationMs(),
advanced_via: advancedVia
})
}
const result = await persistCurrentStep()
const nextCommand = result.featureSetupResult?.skillInstallCommand ?? null
if (currentStep.id === 'notifications' && nextCommand) {
trackCurrentStepCompleted()
setFeatureSetupTerminalSelection(featureSetupSelection)
setFeatureSetupTerminalCommand(nextCommand)
return
}
if (result.ok) {
trackCurrentStepCompleted()
setStepIndex((idx) => Math.min(idx + 1, STEPS.length - 1))
}
} finally {
if (currentStep.id === 'notifications') {
setBusyLabel(null)
}
nextInFlightRef.current = false
}
},
@@ -323,6 +387,9 @@ export function useOnboardingFlow(
currentStep.id,
currentStep.stepNumber,
currentStep.valueKind,
featureSetupSelection,
featureSetupTerminalCommand,
hasSelectedFeatureSetup,
persistCurrentStep
]
)
@@ -447,6 +514,11 @@ export function useOnboardingFlow(
setTheme: setThemeInteractive,
notifications,
setNotifications,
featureSetupSelection,
setFeatureSetupSelection: setFeatureSetupSelectionInteractive,
featureSetupTerminalCommand,
featureSetupTerminalSelection,
hasSelectedFeatureSetup,
cloneUrl,
setCloneUrl,
busyLabel,
@@ -2,6 +2,11 @@ import { useEffect, useState } from 'react'
import { Import, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
import { ORCA_CLI_SKILL_INSTALL_COMMAND } from '@/lib/agent-feature-install-commands'
import {
BROWSER_USE_ENABLED_STORAGE_KEY,
BROWSER_USE_SKILL_INSTALLED_STORAGE_KEY
} from '@/lib/browser-use-setup-state'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import {
@@ -24,9 +29,6 @@ import { BrowserUseExamples } from './BrowserUseExamples'
import { StepBadge } from './BrowserUseStepBadge'
import { BrowserUseSkillStep } from './BrowserUseSkillStep'
const ORCA_SKILL_INSTALL_COMMAND =
'npx skills add https://github.com/stablyai/orca --skill orca-cli'
type BrowserUseSetupProps = {
onConfigureMoreBrowsers?: () => void
}
@@ -50,12 +52,12 @@ export function BrowserUseSetup({
// functional effect elsewhere in the app — it's a UI affordance local to
// this pane, consistent with the skill-installed marker below.
const [browserUseEnabled, setBrowserUseEnabled] = useState<boolean>(() => {
return localStorage.getItem('orca.browserUse.enabled') === '1'
return localStorage.getItem(BROWSER_USE_ENABLED_STORAGE_KEY) === '1'
})
const toggleBrowserUse = (value: boolean): void => {
setBrowserUseEnabled(value)
localStorage.setItem('orca.browserUse.enabled', value ? '1' : '0')
localStorage.setItem(BROWSER_USE_ENABLED_STORAGE_KEY, value ? '1' : '0')
}
const refreshCli = async (): Promise<void> => {
@@ -94,12 +96,12 @@ export function BrowserUseSetup({
// user mark it done explicitly after copying — this avoids falsely implying
// progress and keeps the guided flow honest.
const [skillInstalled, setSkillInstalled] = useState<boolean>(() => {
return localStorage.getItem('orca.browserUse.skillInstalled') === '1'
return localStorage.getItem(BROWSER_USE_SKILL_INSTALLED_STORAGE_KEY) === '1'
})
const markSkillInstalled = (value: boolean): void => {
setSkillInstalled(value)
localStorage.setItem('orca.browserUse.skillInstalled', value ? '1' : '0')
localStorage.setItem(BROWSER_USE_SKILL_INSTALLED_STORAGE_KEY, value ? '1' : '0')
}
const handleEnableCli = async (): Promise<void> => {
@@ -117,8 +119,8 @@ export function BrowserUseSetup({
const handleCopySkillCommand = async (): Promise<void> => {
try {
await window.api.ui.writeClipboardText(ORCA_SKILL_INSTALL_COMMAND)
toast.success('Copied install command. Run it in your agent project.')
await window.api.ui.writeClipboardText(ORCA_CLI_SKILL_INSTALL_COMMAND)
toast.success('Copied install command. Run it on this computer.')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to copy command.')
}
@@ -280,7 +282,7 @@ export function BrowserUseSetup({
}`}
>
<BrowserUseSkillStep
command={ORCA_SKILL_INSTALL_COMMAND}
command={ORCA_CLI_SKILL_INSTALL_COMMAND}
skillInstalled={skillInstalled}
disabled={!cliEnabled}
onCopy={() => void handleCopySkillCommand()}
@@ -25,8 +25,8 @@ export function BrowserUseSkillStep({
<div className="space-y-1">
<p className="text-sm font-medium">Install Browser Use Skill</p>
<p className="text-xs text-muted-foreground">
Run this in your agent project once per project so Claude Code, Codex, and other
agents learn to drive Orca&apos;s browser.
Run this once on your computer so Claude Code, Codex, and other agents learn to drive
Orca&apos;s browser.
</p>
</div>
<div className="flex max-w-full items-center gap-2 rounded-lg border border-border/60 bg-background/60 px-3 py-2">
@@ -55,7 +55,7 @@ export function BrowserUseSkillStep({
<span>
{skillInstalled
? 'Marked as installed on this machine.'
: "Check off once you've run it in your project."}
: "Check off once you've run it on this computer."}
</span>
<button
type="button"
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Copy, FolderOpen, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
import { ORCA_CLI_SKILL_INSTALL_COMMAND } from '@/lib/agent-feature-install-commands'
import { Button } from '../ui/button'
import {
Dialog,
@@ -18,9 +19,6 @@ type CliSectionProps = {
currentPlatform: string
}
const ORCA_CLI_SKILL_INSTALL_COMMAND =
'npx skills add https://github.com/stablyai/orca --skill orca-cli'
function getRevealLabel(platform: string): string {
if (platform === 'darwin') {
return 'Show in Finder'
@@ -6,13 +6,11 @@ import type {
ComputerUsePermissionState,
ComputerUsePermissionStatus
} from '../../../../shared/computer-use-permissions-types'
import { COMPUTER_USE_SKILL_INSTALL_COMMAND } from '@/lib/agent-feature-install-commands'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import type { SettingsSearchEntry } from './settings-search'
const COMPUTER_USE_SKILL_INSTALL_COMMAND =
'npx skills add https://github.com/stablyai/orca --skill computer-use'
export const COMPUTER_USE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Computer Use',
@@ -228,8 +226,7 @@ export function ComputerUsePane(): React.JSX.Element {
<div className="space-y-1">
<p className="text-sm font-medium">Install Computer Use Skill</p>
<p className="text-xs text-muted-foreground">
Run this once in an agent project so agents know how to use Orca&apos;s computer
controls.
Run this once on your computer so agents know how to use Orca&apos;s computer controls.
</p>
</div>
<div className="flex max-w-full items-center gap-2 rounded-lg border border-border/60 bg-background/60 px-3 py-2">
@@ -6,6 +6,8 @@ import { Label } from '../ui/label'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { ORCHESTRATION_SKILL_INSTALL_COMMAND } from '@/lib/orchestration-install-command'
import {
ORCHESTRATION_ENABLED_STORAGE_KEY,
ORCHESTRATION_SKILL_INSTALLED_STORAGE_KEY,
ORCHESTRATION_SETUP_STATE_EVENT,
isOrchestrationSetupEnabled,
isOrchestrationSkillMarkedInstalled,
@@ -41,20 +43,20 @@ export function OrchestrationPane(): React.JSX.Element {
const toggleOrchestration = (value: boolean): void => {
setOrchestrationEnabled(value)
localStorage.setItem('orca.orchestration.enabled', value ? '1' : '0')
localStorage.setItem(ORCHESTRATION_ENABLED_STORAGE_KEY, value ? '1' : '0')
notifyOrchestrationSetupStateChanged()
}
const markOrchestrationSkillInstalled = (value: boolean): void => {
setOrchestrationSkillInstalled(value)
localStorage.setItem('orca.orchestration.skillInstalled', value ? '1' : '0')
localStorage.setItem(ORCHESTRATION_SKILL_INSTALLED_STORAGE_KEY, value ? '1' : '0')
notifyOrchestrationSetupStateChanged()
}
const handleCopyOrchestrationCommand = async (): Promise<void> => {
try {
await window.api.ui.writeClipboardText(ORCHESTRATION_SKILL_INSTALL_COMMAND)
toast.success('Copied install command. Run it in your agent project.')
toast.success('Copied install command. Run it on this computer.')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to copy command.')
}
@@ -100,8 +102,7 @@ export function OrchestrationPane(): React.JSX.Element {
<div className="space-y-1">
<p className="text-sm font-medium">Install Orchestration Skill</p>
<p className="text-xs text-muted-foreground">
Run this in your agent project so agents learn to use inter-agent orchestration
commands.
Run this once on your computer so agents learn to use inter-agent orchestration.
</p>
</div>
<div className="flex max-w-full items-center gap-2 rounded-lg border border-border/60 bg-background/60 px-3 py-2">
@@ -130,7 +131,7 @@ export function OrchestrationPane(): React.JSX.Element {
<span>
{orchestrationSkillInstalled
? 'Marked as installed on this machine.'
: "Check off once you've run it in your project."}
: "Check off once you've run it on this computer."}
</span>
<button
type="button"
+2
View File
@@ -1,6 +1,7 @@
/// <reference types="vite/client" />
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { OnboardingFeatureSetupDeps } from '@/components/onboarding/onboarding-feature-setup'
import type { languages } from 'monaco-editor'
declare module 'monaco-editor/esm/vs/basic-languages/python/python.js' {
@@ -17,6 +18,7 @@ declare global {
// oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging requires interface
interface Window {
__paneManagers?: Map<string, PaneManager>
__onboardingFeatureSetupDeps?: OnboardingFeatureSetupDeps
}
}
@@ -0,0 +1,8 @@
export {
buildAgentFeatureSkillInstallCommand,
COMPUTER_USE_SKILL_INSTALL_COMMAND,
COMPUTER_USE_SKILL_NAME,
ORCA_CLI_SKILL_INSTALL_COMMAND,
ORCA_CLI_SKILL_NAME,
ORCHESTRATION_SKILL_NAME
} from '../../../shared/agent-feature-install-commands'
@@ -0,0 +1,2 @@
export const BROWSER_USE_ENABLED_STORAGE_KEY = 'orca.browserUse.enabled'
export const BROWSER_USE_SKILL_INSTALLED_STORAGE_KEY = 'orca.browserUse.skillInstalled'
@@ -1,2 +1 @@
export const ORCHESTRATION_SKILL_INSTALL_COMMAND =
'npx skills add https://github.com/stablyai/orca --skill orchestration'
export { ORCHESTRATION_SKILL_INSTALL_COMMAND } from '../../../shared/agent-feature-install-commands'
@@ -1,11 +1,14 @@
export const ORCHESTRATION_SETUP_STATE_EVENT = 'orca:orchestration-setup-state'
export const ORCHESTRATION_ENABLED_STORAGE_KEY = 'orca.orchestration.enabled'
export const ORCHESTRATION_SKILL_INSTALLED_STORAGE_KEY = 'orca.orchestration.skillInstalled'
export const ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY = 'orca.orchestration.setupDismissed'
export function isOrchestrationSetupEnabled(): boolean {
return localStorage.getItem('orca.orchestration.enabled') === '1'
return localStorage.getItem(ORCHESTRATION_ENABLED_STORAGE_KEY) === '1'
}
export function isOrchestrationSkillMarkedInstalled(): boolean {
return localStorage.getItem('orca.orchestration.skillInstalled') === '1'
return localStorage.getItem(ORCHESTRATION_SKILL_INSTALLED_STORAGE_KEY) === '1'
}
export function hasOrchestrationSetupMarker(): boolean {
@@ -13,7 +16,7 @@ export function hasOrchestrationSetupMarker(): boolean {
}
export function isOrchestrationSetupDismissed(): boolean {
return localStorage.getItem('orca.orchestration.setupDismissed') === '1'
return localStorage.getItem(ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY) === '1'
}
export function notifyOrchestrationSetupStateChanged(): void {
@@ -0,0 +1,24 @@
export const ORCA_SKILLS_REPOSITORY_URL = 'https://github.com/stablyai/orca'
export const ORCA_CLI_SKILL_NAME = 'orca-cli'
export const COMPUTER_USE_SKILL_NAME = 'computer-use'
export const ORCHESTRATION_SKILL_NAME = 'orchestration'
export function buildAgentFeatureSkillInstallCommand(skillNames: readonly string[]): string {
if (skillNames.length === 0) {
throw new Error('At least one skill name is required.')
}
return `npx skills add ${ORCA_SKILLS_REPOSITORY_URL} --skill ${skillNames.join(' ')} --global`
}
export const ORCA_CLI_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([
ORCA_CLI_SKILL_NAME
])
export const COMPUTER_USE_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([
COMPUTER_USE_SKILL_NAME
])
export const ORCHESTRATION_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([
ORCHESTRATION_SKILL_NAME
])
+78
View File
@@ -367,6 +367,31 @@ const onboardingChecklistItemSchema = z.enum([
'openedFile',
'ranAgentOnFile'
])
const onboardingFeatureSetupFeatureSchema = z.enum(['browser_use', 'computer_use', 'orchestration'])
const onboardingFeatureSetupSelectionSchema = {
browser_use: z.boolean(),
computer_use: z.boolean(),
orchestration: z.boolean(),
selected_count: z.number().int().min(0).max(3)
} as const
type OnboardingFeatureSetupSelectionTelemetry = {
browser_use: boolean
computer_use: boolean
orchestration: boolean
selected_count: number
}
const onboardingFeatureSetupSelectedCountRefinement = {
path: ['selected_count'],
message: 'selected_count must match selected feature flags'
}
function hasMatchingOnboardingFeatureSetupSelectedCount(
props: OnboardingFeatureSetupSelectionTelemetry
): boolean {
const selectedCount =
(props.browser_use ? 1 : 0) + (props.computer_use ? 1 : 0) + (props.orchestration ? 1 : 0)
return props.selected_count === selectedCount
}
// Why: compile-time guard that the enum above stays in lockstep with the
// activation keys of OnboardingChecklistState (everything except the UI-only
@@ -587,6 +612,51 @@ const onboardingGhosttyImportFailedSchema = z
cohort: cohortSchema
})
.strict()
const onboardingFeatureSetupToggledSchema = z
.object({
feature: onboardingFeatureSetupFeatureSchema,
selected: z.boolean(),
cohort: cohortSchema
})
.strict()
const onboardingFeatureSetupRunSchema = z
.object({
...onboardingFeatureSetupSelectionSchema,
cli_touched: z.boolean(),
skill_commands_copied: z.boolean(),
skill_install_command_prepared: z.boolean(),
computer_use_permissions_opened: z.boolean(),
warning_count: z.number().int().nonnegative(),
cohort: cohortSchema
})
// Why: selected_count is derived analytics data; validate the relationship
// at the untrusted IPC boundary instead of trusting renderer callers.
.refine(
hasMatchingOnboardingFeatureSetupSelectedCount,
onboardingFeatureSetupSelectedCountRefinement
)
.strict()
const onboardingFeatureSetupTerminalOpenedSchema = z
.object({
...onboardingFeatureSetupSelectionSchema,
cohort: cohortSchema
})
.refine(
hasMatchingOnboardingFeatureSetupSelectedCount,
onboardingFeatureSetupSelectedCountRefinement
)
.strict()
const onboardingFeatureSetupTerminalInteractedSchema = z
.object({
...onboardingFeatureSetupSelectionSchema,
method: z.enum(['keyboard', 'pointer']),
cohort: cohortSchema
})
.refine(
hasMatchingOnboardingFeatureSetupSelectedCount,
onboardingFeatureSetupSelectedCountRefinement
)
.strict()
// ── Event registry: the one record the validator consumes ───────────────
//
@@ -636,6 +706,10 @@ export const eventSchemas = {
onboarding_ghostty_discovered: onboardingGhosttyDiscoveredSchema,
onboarding_ghostty_import_clicked: onboardingGhosttyImportClickedSchema,
onboarding_ghostty_import_failed: onboardingGhosttyImportFailedSchema,
onboarding_feature_setup_toggled: onboardingFeatureSetupToggledSchema,
onboarding_feature_setup_run: onboardingFeatureSetupRunSchema,
onboarding_feature_setup_terminal_opened: onboardingFeatureSetupTerminalOpenedSchema,
onboarding_feature_setup_terminal_interacted: onboardingFeatureSetupTerminalInteractedSchema,
activation_checklist_item_completed: activationChecklistItemCompletedSchema,
smart_sort_class_distribution: smartSortClassDistributionSchema,
@@ -738,6 +812,10 @@ type _OnboardingCohortRoster =
| 'onboarding_ghostty_discovered'
| 'onboarding_ghostty_import_clicked'
| 'onboarding_ghostty_import_failed'
| 'onboarding_feature_setup_toggled'
| 'onboarding_feature_setup_run'
| 'onboarding_feature_setup_terminal_opened'
| 'onboarding_feature_setup_terminal_interacted'
type _DerivedOnboardingCohortEvents = {
[N in EventName]: 'cohort' extends _KnownPayloadKeys<EventMap[N]> ? N : never
}[EventName]
+4 -4
View File
@@ -11,7 +11,8 @@
*/
import { execSync } from 'child_process'
import { existsSync, mkdirSync, writeFileSync } from 'fs'
import { randomUUID } from 'crypto'
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'fs'
import path from 'path'
import os from 'os'
@@ -53,8 +54,7 @@ export default function globalSetup(): void {
// ── 2. Create a seeded test git repo ───────────────────────────────
// Why: each test run gets its own git repo so the suite is fully
// idempotent. No test depends on whatever repos the user has open.
const testRepoDir = path.join(os.tmpdir(), `orca-e2e-repo-${Date.now()}`)
mkdirSync(testRepoDir, { recursive: true })
const testRepoDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-repo-'))
execSync('git init', { cwd: testRepoDir, stdio: 'pipe' })
execSync('git config user.email "e2e@test.local"', { cwd: testRepoDir, stdio: 'pipe' })
@@ -80,7 +80,7 @@ export default function globalSetup(): void {
// Why: several tests verify worktree-switching behavior (terminal content
// retention, browser tab retention). They need at least 2 worktrees.
// Creating one here makes those tests run instead of being skipped.
const worktreeDir = path.join(testRepoDir, '..', `orca-e2e-worktree-${Date.now()}`)
const worktreeDir = path.join(testRepoDir, '..', `orca-e2e-worktree-${randomUUID()}`)
execSync(`git worktree add "${worktreeDir}" -b e2e-secondary`, {
cwd: testRepoDir,
stdio: 'pipe'
+5 -3
View File
@@ -22,6 +22,7 @@ import {
} from '@stablyai/playwright-test'
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { execSync } from 'child_process'
import { randomUUID } from 'crypto'
import os from 'os'
import path from 'path'
import { TEST_REPO_PATH_FILE } from '../global-setup'
@@ -88,8 +89,7 @@ function isValidGitRepo(repoPath: string): boolean {
}
function createSeededTestRepo(): string {
const testRepoDir = path.join(os.tmpdir(), `orca-e2e-repo-${Date.now()}`)
mkdirSync(testRepoDir, { recursive: true })
const testRepoDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-repo-'))
execSync('git init', { cwd: testRepoDir, stdio: 'pipe' })
execSync('git config user.email "e2e@test.local"', { cwd: testRepoDir, stdio: 'pipe' })
@@ -111,7 +111,9 @@ function createSeededTestRepo(): string {
execSync('git add -A', { cwd: testRepoDir, stdio: 'pipe' })
execSync('git commit -m "Initial commit for E2E tests"', { cwd: testRepoDir, stdio: 'pipe' })
const worktreeDir = path.join(testRepoDir, '..', `orca-e2e-worktree-${Date.now()}`)
// Why: worker-scoped fixture fallbacks can run in parallel; UUIDs avoid
// colliding on the same temp repo/worktree when workers start together.
const worktreeDir = path.join(testRepoDir, '..', `orca-e2e-worktree-${randomUUID()}`)
execSync(`git worktree add "${worktreeDir}" -b e2e-secondary`, {
cwd: testRepoDir,
stdio: 'pipe'
+194 -14
View File
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Why: onboarding E2E coverage shares one first-launch wizard fixture and step helpers; splitting this file would make the linear flow harder to audit. */
/**
* E2E tests for the first-launch Onboarding flow.
*
@@ -19,6 +20,9 @@ type OnboardingState = {
checklist: Record<string, boolean>
}
const ORCHESTRATION_ENABLED_STORAGE_KEY = 'orca.orchestration.enabled'
const BROWSER_USE_ENABLED_STORAGE_KEY = 'orca.browserUse.enabled'
async function getOnboardingState(page: Page): Promise<OnboardingState> {
return page.evaluate(() => window.api.onboarding.get() as Promise<OnboardingState>)
}
@@ -33,6 +37,67 @@ async function getDocumentThemeClass(page: Page): Promise<'dark' | 'light'> {
)
}
async function installSafeOnboardingFeatureSetupDeps(page: Page): Promise<void> {
await page.evaluate(() => {
window.__onboardingFeatureSetupDeps = {
getCliStatus: async () => ({
platform: 'darwin',
commandName: 'orca',
commandPath: '/usr/local/bin/orca',
pathDirectory: '/usr/local/bin',
pathConfigured: true,
launcherPath: '/Applications/Orca.app/Contents/MacOS/Orca',
installMethod: 'symlink',
supported: true,
state: 'installed',
currentTarget: '/Applications/Orca.app/Contents/MacOS/Orca',
unsupportedReason: null,
detail: null
}),
installCli: async () => {
throw new Error('CLI registration should not run in this onboarding E2E')
},
writeClipboardText: async (text) => {
localStorage.setItem('orca.e2e.onboardingFeatureSetupClipboard', text)
},
getComputerUsePermissionStatus: async () => ({
platform: 'darwin',
permissions: [
{ id: 'accessibility', status: 'granted' },
{ id: 'screenshots', status: 'granted' }
]
}),
openComputerUsePermissionSetup: async () => {
throw new Error('Computer Use setup should not open in this onboarding E2E')
},
setStorageItem: (key, value) => localStorage.setItem(key, value),
removeStorageItem: (key) => localStorage.removeItem(key),
notifyOrchestrationStateChanged: () => {
window.dispatchEvent(new CustomEvent('orca:orchestration-setup-state'))
}
}
})
}
async function expectSkillSetupTerminalReady(page: Page): Promise<void> {
await expect(page.getByRole('region', { name: /Skill setup command/i })).toBeInViewport({
timeout: 10_000
})
await expect(
page.getByText(/Press Enter to run the command and confirm npm if asked/i)
).toBeVisible()
await expect
.poll(
async () =>
page.evaluate(() => document.activeElement?.classList.contains('xterm-helper-textarea')),
{
timeout: 10_000,
message: 'Skill setup command terminal did not receive keyboard focus'
}
)
.toBe(true)
}
test.describe('Onboarding flow', () => {
// Why: the shared fixture pre-seeds onboarding as closed so non-onboarding
// tests don't get blocked by the fullscreen overlay. Opt out here so this
@@ -127,9 +192,7 @@ test.describe('Onboarding flow', () => {
.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.getByRole('heading', { name: /Set up Orca for agents/i })).toBeVisible()
await expect(orcaPage.getByText('3 of 4')).toBeVisible()
await expect
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
@@ -144,10 +207,24 @@ test.describe('Onboarding flow', () => {
// --- 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()
// suppressWhenFocused=true). Use the default setup action without touching
// the toggles; the assertions prove the wizard wrote its opt-in defaults
// through IPC, including the inverted suppressWhenFocused.
// Why: the feature checklist also defaults ON; inject safe deps so this
// E2E validates persistence without registering the real CLI or opening
// OS permission prompts.
await installSafeOnboardingFeatureSetupDeps(orcaPage)
const browserUse = orcaPage.getByRole('checkbox', { name: /Agent Browser Use/i })
const computerUse = orcaPage.getByRole('checkbox', { name: /Computer Use/i })
const orchestration = orcaPage.getByRole('checkbox', { name: /Agent Orchestration/i })
await expect(browserUse).toHaveAttribute('aria-checked', 'true')
await expect(computerUse).toHaveAttribute('aria-checked', 'true')
await expect(orchestration).toHaveAttribute('aria-checked', 'true')
await orcaPage.getByRole('button', { name: 'Set up' }).click()
await expectSkillSetupTerminalReady(orcaPage)
await expect(orcaPage.getByRole('button', { name: 'Continue', exact: true })).toBeVisible()
await orcaPage.getByRole('button', { name: 'Continue', exact: true }).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)
@@ -180,6 +257,23 @@ test.describe('Onboarding flow', () => {
suppressWhenFocused: false,
enabled: true
})
await expect
.poll(
async () =>
orcaPage.evaluate(
({ orchestrationKey, browserUseKey }) => ({
orchestration: localStorage.getItem(orchestrationKey),
browserUse: localStorage.getItem(browserUseKey)
}),
{
orchestrationKey: ORCHESTRATION_ENABLED_STORAGE_KEY,
browserUseKey: BROWSER_USE_ENABLED_STORAGE_KEY
}
),
{ timeout: 5_000 }
)
.toEqual({ orchestration: '1', browserUse: '1' })
})
test('Cmd/Ctrl+Enter advances steps like Continue', async ({ orcaPage }) => {
@@ -232,9 +326,7 @@ test.describe('Onboarding flow', () => {
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 expect(orcaPage.getByRole('heading', { name: /Set up Orca for agents/i })).toBeVisible()
// Why: NotificationStep buttons expose role="switch" + aria-checked. Flip
// terminalBell off and verify the toggle reflects + persists. The other
@@ -244,7 +336,10 @@ test.describe('Onboarding flow', () => {
await bellSwitch.click()
await expect(bellSwitch).toHaveAttribute('aria-checked', 'false')
await orcaPage.getByRole('button', { name: 'Continue' }).click()
await installSafeOnboardingFeatureSetupDeps(orcaPage)
await orcaPage.getByRole('button', { name: 'Set up' }).click()
await expect(orcaPage.getByRole('region', { name: /Skill setup command/i })).toBeVisible()
await orcaPage.getByRole('button', { name: 'Continue', exact: true }).click()
await expect(orcaPage.getByRole('heading', { name: /Point Orca at some code/i })).toBeVisible()
await expect
.poll(
@@ -260,6 +355,93 @@ test.describe('Onboarding flow', () => {
.toEqual({ agentTaskComplete: true, terminalBell: false })
})
test('can opt into orchestration setup without enabling browser or computer use', 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: /Set up Orca for agents/i })).toBeVisible()
// Why: this flow validates the orchestration-only setup path without
// touching Browser Use, Computer Use permission prompts, or real CLI mutation.
await orcaPage.evaluate(() => {
window.__onboardingFeatureSetupDeps = {
getCliStatus: async () => ({
platform: 'darwin',
commandName: 'orca',
commandPath: '/usr/local/bin/orca',
pathDirectory: '/usr/local/bin',
pathConfigured: true,
launcherPath: '/Applications/Orca.app/Contents/MacOS/Orca',
installMethod: 'symlink',
supported: true,
state: 'installed',
currentTarget: '/Applications/Orca.app/Contents/MacOS/Orca',
unsupportedReason: null,
detail: null
}),
installCli: async () => {
throw new Error('CLI registration should not run in this onboarding E2E')
},
writeClipboardText: async () => undefined,
getComputerUsePermissionStatus: async () => {
throw new Error('Computer Use permissions should stay untouched')
},
openComputerUsePermissionSetup: async () => {
throw new Error('Computer Use setup should stay untouched')
},
setStorageItem: (key, value) => localStorage.setItem(key, value),
removeStorageItem: (key) => localStorage.removeItem(key),
notifyOrchestrationStateChanged: () => {
window.dispatchEvent(new CustomEvent('orca:orchestration-setup-state'))
}
}
})
const browserUse = orcaPage.getByRole('checkbox', { name: /Agent Browser Use/i })
const computerUse = orcaPage.getByRole('checkbox', { name: /Computer Use/i })
const orchestration = orcaPage.getByRole('checkbox', { name: /Agent Orchestration/i })
await expect(browserUse).toHaveAttribute('aria-checked', 'true')
await expect(computerUse).toHaveAttribute('aria-checked', 'true')
await expect(orchestration).toHaveAttribute('aria-checked', 'true')
await browserUse.click()
await computerUse.click()
await expect(browserUse).toHaveAttribute('aria-checked', 'false')
await expect(computerUse).toHaveAttribute('aria-checked', 'false')
await expect(orchestration).toHaveAttribute('aria-checked', 'true')
await orcaPage.getByRole('button', { name: 'Set up' }).click()
await expect(orcaPage.getByRole('region', { name: /Skill setup command/i })).toBeVisible()
await orcaPage.getByRole('button', { name: 'Continue', exact: true }).click()
await expect(orcaPage.getByRole('heading', { name: /Point Orca at some code/i })).toBeVisible()
await expect
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
timeout: 5_000
})
.toBe(3)
await expect
.poll(
async () =>
orcaPage.evaluate(
({ orchestrationKey, browserUseKey }) => ({
orchestration: localStorage.getItem(orchestrationKey),
browserUse: localStorage.getItem(browserUseKey)
}),
{
orchestrationKey: ORCHESTRATION_ENABLED_STORAGE_KEY,
browserUseKey: BROWSER_USE_ENABLED_STORAGE_KEY
}
),
{ timeout: 5_000 }
)
.toEqual({ orchestration: '1', browserUse: '0' })
})
test('typing in the clone-url input does not hijack Enter as a global shortcut', async ({
orcaPage
}) => {
@@ -328,9 +510,7 @@ test.describe('Onboarding flow', () => {
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 expect(orcaPage.getByRole('heading', { name: /Set up Orca for agents/i })).toBeVisible()
await orcaPage.getByRole('button', { name: 'Skip' }).click()
await expect(orcaPage.getByRole('heading', { name: /Point Orca at some code/i })).toBeVisible()