diff --git a/src/main/telemetry/validator.test.ts b/src/main/telemetry/validator.test.ts index 534b5c656..934b2a090 100644 --- a/src/main/telemetry/validator.test.ts +++ b/src/main/telemetry/validator.test.ts @@ -308,6 +308,26 @@ describe('validate', () => { expect(result.ok).toBe(false) }) + it('accepts onboarding_task_sources_snapshot with bounded statuses', () => { + const result = validate('onboarding_task_sources_snapshot', { + github_status: 'connected', + linear_status: 'not_connected', + exit_action: 'continue', + duration_ms: 1200, + advanced_via: 'button' + }) + expect(result.ok).toBe(true) + }) + + it('rejects onboarding_task_sources_snapshot with unknown status strings', () => { + const result = validate('onboarding_task_sources_snapshot', { + github_status: 'signed-in', + linear_status: 'not_connected', + exit_action: 'continue' + } as never) + expect(result.ok).toBe(false) + }) + it('accepts onboarding_started with cohort upgrade_backfill', () => { const result = validate('onboarding_started', { cohort: 'upgrade_backfill' }) expect(result.ok).toBe(true) diff --git a/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx b/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx index 116cf7c3f..157dc5998 100644 --- a/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx +++ b/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx @@ -1,20 +1,11 @@ -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 { useCallback, useMemo, useRef, type KeyboardEvent } from 'react' import { track } from '@/lib/telemetry' -import { useAppStore } from '@/store' +import { OnboardingInlineCommandTerminal } from './OnboardingInlineCommandTerminal' 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 @@ -24,25 +15,6 @@ 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 prefersReducedMotion = useMemo( - () => - typeof window !== 'undefined' && - typeof window.matchMedia === 'function' && - window.matchMedia('(prefers-reduced-motion: reduce)').matches, - [] - ) - const [cwd, setCwd] = useState(null) - const [tabId, setTabId] = useState(null) - // Why: starts at `prefersReducedMotion` so users opted out of motion never - // see the slide-in frame; otherwise we flip to true after first paint so the - // CSS transition has a starting state to interpolate from. - const [entered, setEntered] = useState(prefersReducedMotion) - const terminalSectionRef = useRef(null) - const autoInsertedRef = useRef(null) const terminalOpenedTrackedRef = useRef(false) const terminalInteractedTrackedRef = useRef(false) @@ -51,7 +23,7 @@ export function FeatureSetupInlineTerminal({ [selection] ) - useEffect(() => { + const trackTerminalOpened = useCallback(() => { if (terminalOpenedTrackedRef.current) { return } @@ -80,164 +52,14 @@ export function FeatureSetupInlineTerminal({ [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(() => { - if (prefersReducedMotion) { - const scrollFrame = window.requestAnimationFrame(() => { - terminalSectionRef.current?.scrollIntoView({ behavior: 'auto', block: 'center' }) - }) - return () => window.cancelAnimationFrame(scrollFrame) - } - // Why: double rAF guarantees the browser commits the initial collapsed - // styles before we flip to `entered`, so the height/opacity transition - // actually plays instead of snapping straight to the final state. - const enterFrame = window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => setEntered(true)) - }) - return () => window.cancelAnimationFrame(enterFrame) - }, [prefersReducedMotion]) - - // Why: tracking scroll *during* the height transition is unavoidably - // jumpy — ResizeObserver / rAF ticks land in pixel-sized chunks, and each - // chunk reads as a step. Instead, let the section grow in place, then once - // the height has nearly settled fire a single native smooth scroll. The - // browser eases that scroll itself, which is the smoothest path available. - useEffect(() => { - if (!entered || prefersReducedMotion) { - return - } - const section = terminalSectionRef.current - if (!section) { - return - } - const scrollTimer = window.setTimeout(() => { - section.scrollIntoView({ behavior: 'smooth', block: 'center' }) - }, 500) - return () => window.clearTimeout(scrollTimer) - }, [entered, prefersReducedMotion]) - - 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]) - - // Why: grid 0fr → 1fr animates to the child's natural height without a - // hardcoded max-height, so we don't leave dead space if the terminal - // section's intrinsic size shifts. The inner section is positioned via the - // grid row, so xterm.js measures its real container on mount. 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/IntegrationsStep.tsx b/src/renderer/src/components/onboarding/IntegrationsStep.tsx new file mode 100644 index 000000000..6554c4c19 --- /dev/null +++ b/src/renderer/src/components/onboarding/IntegrationsStep.tsx @@ -0,0 +1,333 @@ +import { useEffect, useState } from 'react' +import { ExternalLink, Github, Loader2, Terminal } from 'lucide-react' +import { LinearIcon } from '@/components/icons/LinearIcon' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { useAppStore } from '@/store' +import { cn } from '@/lib/utils' +import { OnboardingInlineCommandTerminal } from './OnboardingInlineCommandTerminal' + +type GitHubSetupState = 'checking' | 'connected' | 'not-installed' | 'not-authenticated' + +function getGitHubSetupState( + status: ReturnType['preflightStatus'] +): GitHubSetupState { + if (!status) { + return 'checking' + } + if (!status.gh.installed) { + return 'not-installed' + } + return status.gh.authenticated ? 'connected' : 'not-authenticated' +} + +type StatusTone = 'connected' | 'attention' | 'neutral' + +const statusToneClassNames: Record = { + connected: { + pill: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300', + dot: 'bg-emerald-500' + }, + attention: { + pill: 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300', + dot: 'bg-amber-500' + }, + neutral: { + pill: 'border-border bg-background text-muted-foreground', + dot: 'bg-muted-foreground' + } +} + +function StatusPill({ + tone, + children +}: { + tone: StatusTone + children: React.ReactNode +}): React.JSX.Element { + return ( + + + {children} + + ) +} + +function GitHubRow(): React.JSX.Element { + const preflightStatus = useAppStore((s) => s.preflightStatus) + const preflightStatusLoading = useAppStore((s) => s.preflightStatusLoading) + const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) + + const state: GitHubSetupState = preflightStatusLoading + ? 'checking' + : getGitHubSetupState(preflightStatus) + const [githubTerminalOpen, setGithubTerminalOpen] = useState(false) + + return ( +
+
+
+ +
+
+
+

GitHub

+ {state === 'connected' ? ( + Connected + ) : state === 'not-installed' ? ( + CLI not installed + ) : state === 'not-authenticated' ? ( + Sign in needed + ) : ( + Checking… + )} +
+

+ Pull requests, issues, and check status. +

+
+
+ {state === 'not-installed' ? ( + + ) : null} + {state === 'not-authenticated' ? ( + + ) : null} + +
+
+ {state === 'not-authenticated' && githubTerminalOpen ? ( +
+ +
+ ) : null} +
+ ) +} + +function LinearRow(): React.JSX.Element { + const linearStatus = useAppStore((s) => s.linearStatus) + const checkLinearConnection = useAppStore((s) => s.checkLinearConnection) + const connectLinear = useAppStore((s) => s.connectLinear) + + const [dialogOpen, setDialogOpen] = useState(false) + const [apiKeyDraft, setApiKeyDraft] = useState('') + const [connectState, setConnectState] = useState<'idle' | 'connecting' | 'error'>('idle') + const [connectError, setConnectError] = useState(null) + + const workspaceCount = linearStatus.workspaces?.length ?? (linearStatus.connected ? 1 : 0) + + const handleConnect = async (): Promise => { + const apiKey = apiKeyDraft.trim() + if (!apiKey || connectState === 'connecting') { + return + } + setConnectState('connecting') + setConnectError(null) + try { + const result = await connectLinear(apiKey) + if (result.ok) { + setApiKeyDraft('') + setConnectState('idle') + setDialogOpen(false) + return + } + setConnectState('error') + setConnectError(result.error) + } catch (error) { + setConnectState('error') + setConnectError(error instanceof Error ? error.message : 'Connection failed') + } + } + + return ( + <> +
+
+
+ +
+
+
+

Linear

+ {linearStatus.connected ? Connected : null} +
+

+ {linearStatus.connected + ? `${workspaceCount} workspace${workspaceCount === 1 ? '' : 's'} linked. Add another any time.` + : 'Paste a Linear API key to link issues to workspaces. Stored locally; nothing leaves this machine.'} +

+
+
+ {linearStatus.connected ? ( + + ) : ( + + )} + +
+
+
+ + { + if (connectState !== 'connecting') { + setDialogOpen(open) + } + }} + > + { + if (event.key === 'Enter' && apiKeyDraft.trim() && connectState !== 'connecting') { + event.preventDefault() + void handleConnect() + } + }} + > + + Connect Linear workspace + + Paste a Personal API key to add a Linear workspace to Orca. + + +
+ { + setApiKeyDraft(event.target.value) + if (connectState === 'error') { + setConnectState('idle') + setConnectError(null) + } + }} + disabled={connectState === 'connecting'} + /> + {connectState === 'error' && connectError ? ( +

{connectError}

+ ) : null} +

+ Create one in{' '} + + . +

+
+ + + + +
+
+ + ) +} + +const CAPABILITIES = [ + 'Start a workspace from any issue, PR, or Linear ticket, prefilled with its title and context', + 'Browse your assigned tasks in the Tasks view without leaving Orca', + 'See issue state, PR review status, and CI checks on every worktree', + 'Read, comment on, and merge pull requests without leaving Orca' +] as const + +export function IntegrationsStep(): React.JSX.Element { + const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) + const checkLinearConnection = useAppStore((s) => s.checkLinearConnection) + + useEffect(() => { + void refreshPreflightStatus() + void checkLinearConnection() + }, [checkLinearConnection, refreshPreflightStatus]) + + return ( +
+
    + {CAPABILITIES.map((line) => ( +
  • + + {line} +
  • + ))} +
+ +
+ + +
+
+ ) +} diff --git a/src/renderer/src/components/onboarding/NotificationStep.test.tsx b/src/renderer/src/components/onboarding/NotificationStep.test.tsx index e6fcc5dcf..df2763bb6 100644 --- a/src/renderer/src/components/onboarding/NotificationStep.test.tsx +++ b/src/renderer/src/components/onboarding/NotificationStep.test.tsx @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import { NotificationStep } from './NotificationStep' describe('NotificationStep', () => { - it('renders the feature setup checklist in the notification step', () => { + it('renders feature setup in the notification step', () => { const html = renderToStaticMarkup( { expect(html).toContain('Computer Use') expect(html).toContain('Agent Orchestration') expect(html).toContain('role="checkbox"') + expect(html).not.toContain('Connect task sources') }) }) diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.tsx index 5acacd5a3..87b797aed 100644 --- a/src/renderer/src/components/onboarding/OnboardingFlow.tsx +++ b/src/renderer/src/components/onboarding/OnboardingFlow.tsx @@ -7,6 +7,7 @@ import type { OnboardingState } from '../../../../shared/types' import { AgentStep } from './AgentStep' import { ThemeStep } from './ThemeStep' import { NotificationStep } from './NotificationStep' +import { IntegrationsStep } from './IntegrationsStep' import { RepoStep } from './RepoStep' import { STEPS, useOnboardingFlow } from './use-onboarding-flow' import logo from '../../../../../resources/logo.svg' @@ -28,6 +29,10 @@ const stepCopy = { subtitle: 'Get notifications when agents need you, and choose the capabilities Orca should enable on this computer.' }, + integrations: { + title: 'Connect your task sources', + subtitle: 'Connect GitHub or Linear to:' + }, repo: { title: 'Point Orca at some code', subtitle: 'Open a folder or clone a repo to finish setup.' @@ -38,6 +43,7 @@ const stepTooltipLabels = { agent: 'Default Agent', theme: 'Appearance', notifications: 'Agent tools', + integrations: 'Integrations', repo: 'Create project' } as const @@ -190,6 +196,7 @@ export default function OnboardingFlow({ featureSetupCommandSelection={flow.featureSetupTerminalSelection} /> )} + {currentStep.id === 'integrations' && } {currentStep.id === 'repo' && ( )} + {shouldShowSetupAction && ( + + )} {currentStep.id !== 'repo' && (