From 2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 14 May 2026 15:21:30 -0700 Subject: [PATCH] Add onboarding feature setup checklist (#1853) Co-authored-by: Orca --- ...onboarding-feature-setup-validator.test.ts | 85 ++++++ .../FloatingTerminalOrchestrationDialog.tsx | 10 +- .../FloatingTerminalPanel.tsx | 3 +- .../src/components/onboarding/AgentStep.tsx | 4 +- .../onboarding/FeatureSetupChecklist.tsx | 114 ++++++++ .../onboarding/FeatureSetupInlineTerminal.tsx | 196 +++++++++++++ .../onboarding/NotificationStep.test.tsx | 32 +++ .../onboarding/NotificationStep.tsx | 27 +- .../components/onboarding/OnboardingFlow.tsx | 47 ++- .../src/components/onboarding/RepoStep.tsx | 2 +- .../src/components/onboarding/ThemeStep.tsx | 4 +- .../onboarding-feature-setup.test.ts | 260 +++++++++++++++++ .../onboarding/onboarding-feature-setup.ts | 270 ++++++++++++++++++ .../use-onboarding-flow-persistence.ts | 51 +++- .../onboarding/use-onboarding-flow.ts | 76 ++++- .../components/settings/BrowserUsePane.tsx | 22 +- .../settings/BrowserUseSkillStep.tsx | 6 +- .../src/components/settings/CliSection.tsx | 4 +- .../components/settings/ComputerUsePane.tsx | 7 +- .../components/settings/OrchestrationPane.tsx | 13 +- src/renderer/src/env.d.ts | 2 + .../src/lib/agent-feature-install-commands.ts | 8 + .../src/lib/browser-use-setup-state.ts | 2 + .../src/lib/orchestration-install-command.ts | 3 +- .../src/lib/orchestration-setup-state.ts | 9 +- src/shared/agent-feature-install-commands.ts | 24 ++ src/shared/telemetry-events.ts | 78 +++++ tests/e2e/global-setup.ts | 8 +- tests/e2e/helpers/orca-app.ts | 8 +- tests/e2e/onboarding.spec.ts | 208 +++++++++++++- 30 files changed, 1498 insertions(+), 85 deletions(-) create mode 100644 src/main/telemetry/onboarding-feature-setup-validator.test.ts create mode 100644 src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx create mode 100644 src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx create mode 100644 src/renderer/src/components/onboarding/NotificationStep.test.tsx create mode 100644 src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts create mode 100644 src/renderer/src/components/onboarding/onboarding-feature-setup.ts create mode 100644 src/renderer/src/lib/agent-feature-install-commands.ts create mode 100644 src/renderer/src/lib/browser-use-setup-state.ts create mode 100644 src/shared/agent-feature-install-commands.ts diff --git a/src/main/telemetry/onboarding-feature-setup-validator.test.ts b/src/main/telemetry/onboarding-feature-setup-validator.test.ts new file mode 100644 index 00000000000..82f3591384b --- /dev/null +++ b/src/main/telemetry/onboarding-feature-setup-validator.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx index 615155eadf7..ed8c87f07af 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx @@ -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 => { 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) { diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index ff78f85a6b8..b16d06ffaf6 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -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() }, []) diff --git a/src/renderer/src/components/onboarding/AgentStep.tsx b/src/renderer/src/components/onboarding/AgentStep.tsx index 3cf79fc29cb..5db0506ad81 100644 --- a/src/renderer/src/components/onboarding/AgentStep.tsx +++ b/src/renderer/src/components/onboarding/AgentStep.tsx @@ -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
// 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 && (
- {selectedEntry.label} isn't on your PATH yet — + {selectedEntry.label} isn't on your PATH yet. Orca will set it as your default and you can install it any time. + ) + })} +
+ + ) +} diff --git a/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx b/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx new file mode 100644 index 00000000000..ff93a1e90ee --- /dev/null +++ b/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx @@ -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(null) + const [tabId, setTabId] = useState(null) + const terminalSectionRef = useRef(null) + const autoInsertedRef = useRef(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) => { + 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(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 ( +
+
+

+ Press Enter to run the command and confirm npm if asked. You can also set this up later in + Settings. +

+
+
trackTerminalInteraction('keyboard', event)} + onPointerDownCapture={() => trackTerminalInteraction('pointer')} + > + {cwd && tabId ? ( + closeTab(tabId)} + onCloseTab={() => closeTab(tabId)} + /> + ) : ( +
+ + Starting terminal... +
+ )} +
+
+ ) +} + +function findTerminalTabElement(tabId: string): HTMLElement | null { + for (const element of document.querySelectorAll('[data-terminal-tab-id]')) { + if (element.dataset.terminalTabId === tabId) { + return element + } + } + return null +} diff --git a/src/renderer/src/components/onboarding/NotificationStep.test.tsx b/src/renderer/src/components/onboarding/NotificationStep.test.tsx new file mode 100644 index 00000000000..e6fcc5dcfe5 --- /dev/null +++ b/src/renderer/src/components/onboarding/NotificationStep.test.tsx @@ -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( + + ) + + 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"') + }) +}) diff --git a/src/renderer/src/components/onboarding/NotificationStep.tsx b/src/renderer/src/components/onboarding/NotificationStep.tsx index 202f6e1483a..1d21e56e298 100644 --- a/src/renderer/src/components/onboarding/NotificationStep.tsx +++ b/src/renderer/src/components/onboarding/NotificationStep.tsx @@ -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) { ))}

- Configure other agent status personalization — like custom sounds or pet sidekicks — under{' '} + Configure other agent status personalization, like custom sounds, under{' '} Settings → Notifications.

+ + {featureSetupCommand ? ( + + ) : null} ) } diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.tsx index 42494568969..755afab67ca 100644 --- a/src/renderer/src/components/onboarding/OnboardingFlow.tsx +++ b/src/renderer/src/components/onboarding/OnboardingFlow.tsx @@ -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' && ( - + )} {currentStep.id === 'repo' && ( {enterLabel} - {currentStep.id === 'repo' ? 'open folder' : 'continue'} + + {currentStep.id === 'repo' + ? 'open folder' + : currentStep.id === 'notifications' && + flow.hasSelectedFeatureSetup && + !flow.featureSetupTerminalCommand + ? 'set up' + : 'continue'} +
)}
diff --git a/src/renderer/src/components/onboarding/RepoStep.tsx b/src/renderer/src/components/onboarding/RepoStep.tsx index ae129e3b7a5..ddaada2bf07 100644 --- a/src/renderer/src/components/onboarding/RepoStep.tsx +++ b/src/renderer/src/components/onboarding/RepoStep.tsx @@ -34,7 +34,7 @@ export function RepoStep({
Open a folder
- Choose any local directory — git repo or not. + Choose any local directory, git repo or not.
diff --git a/src/renderer/src/components/onboarding/ThemeStep.tsx b/src/renderer/src/components/onboarding/ThemeStep.tsx index 096232f1f22..063aafcdced 100644 --- a/src/renderer/src/components/onboarding/ThemeStep.tsx +++ b/src/renderer/src/components/onboarding/ThemeStep.tsx @@ -211,7 +211,7 @@ export function ThemeStep({ theme, onThemeChange, settings, updateSettings }: Th
- More terminal options — font, cursor, palette — in{' '} + More terminal options, including font, cursor, and palette, in{' '} Settings → Terminal
@@ -367,7 +367,7 @@ function humanFields(diff: Partial): 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'] }, { diff --git a/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts b/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts new file mode 100644 index 00000000000..7fc319534ca --- /dev/null +++ b/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts @@ -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 & { + storage: Map + clipboardWrites: string[] +} { + const storage = new Map() + 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 => ({ + 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([]) + }) +}) diff --git a/src/renderer/src/components/onboarding/onboarding-feature-setup.ts b/src/renderer/src/components/onboarding/onboarding-feature-setup.ts new file mode 100644 index 00000000000..68ee13a7cd0 --- /dev/null +++ b/src/renderer/src/components/onboarding/onboarding-feature-setup.ts @@ -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 + +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 = { + 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 + installCli: () => Promise + writeClipboardText: (text: string) => Promise + getComputerUsePermissionStatus: () => Promise + openComputerUsePermissionSetup: () => Promise + 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 { + 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 { + 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 + } +} diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts index 1c668f281ec..e828824f810 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts @@ -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) => Promise | 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 => { + return useCallback(async (): Promise => { 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, diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.ts index 2d4647d9ac6..df477c1eb3a 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow.ts @@ -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(DEFAULT_ONBOARDING_FEATURE_SETUP_SELECTION) + const [featureSetupTerminalCommand, setFeatureSetupTerminalCommand] = useState( + 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(null) const [cloneUrl, setCloneUrl] = useState('') const [busyLabel, setBusyLabel] = useState(null) const [error, setError] = useState(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, diff --git a/src/renderer/src/components/settings/BrowserUsePane.tsx b/src/renderer/src/components/settings/BrowserUsePane.tsx index 63a71359b96..86412cb88d9 100644 --- a/src/renderer/src/components/settings/BrowserUsePane.tsx +++ b/src/renderer/src/components/settings/BrowserUsePane.tsx @@ -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(() => { - 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 => { @@ -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(() => { - 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 => { @@ -117,8 +119,8 @@ export function BrowserUseSetup({ const handleCopySkillCommand = async (): Promise => { 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({ }`} > void handleCopySkillCommand()} diff --git a/src/renderer/src/components/settings/BrowserUseSkillStep.tsx b/src/renderer/src/components/settings/BrowserUseSkillStep.tsx index b744dd93248..09bcedd429a 100644 --- a/src/renderer/src/components/settings/BrowserUseSkillStep.tsx +++ b/src/renderer/src/components/settings/BrowserUseSkillStep.tsx @@ -25,8 +25,8 @@ export function BrowserUseSkillStep({

Install Browser Use Skill

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

@@ -55,7 +55,7 @@ export function BrowserUseSkillStep({ {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."}
diff --git a/src/renderer/src/components/settings/OrchestrationPane.tsx b/src/renderer/src/components/settings/OrchestrationPane.tsx index a692105316f..c5a2756e4c7 100644 --- a/src/renderer/src/components/settings/OrchestrationPane.tsx +++ b/src/renderer/src/components/settings/OrchestrationPane.tsx @@ -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 => { 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 {

Install Orchestration Skill

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

@@ -130,7 +131,7 @@ export function OrchestrationPane(): React.JSX.Element { {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."}