mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
Add onboarding feature setup checklist (#1853)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
+7
-3
@@ -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't on your PATH yet —
|
||||
<span className="font-medium">{selectedEntry.label}</span> isn'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's browser.
|
||||
Run this once on your computer so Claude Code, Codex, and other agents learn to drive
|
||||
Orca'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's computer
|
||||
controls.
|
||||
Run this once on your computer so agents know how to use Orca'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"
|
||||
|
||||
Vendored
+2
@@ -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
|
||||
])
|
||||
@@ -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]
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user