Add GitHub and Linear onboarding setup (#2275)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-18 16:02:39 -07:00 committed by GitHub
parent 71a3bba90d
commit ececd27fd8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 746 additions and 209 deletions

View File

@ -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)

View File

@ -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<string | null>(null)
const [tabId, setTabId] = useState<string | null>(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<HTMLElement>(null)
const autoInsertedRef = useRef<string | null>(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<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])
// 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 (
<div
aria-hidden={!entered}
className="grid transition-[grid-template-rows,opacity,margin-top] duration-[700ms] ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none"
style={{
gridTemplateRows: entered ? '1fr' : '0fr',
opacity: entered ? 1 : 0,
marginTop: entered ? 20 : 0
}}
>
<section
ref={terminalSectionRef}
aria-label="Skill setup command"
className="min-h-0 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>
</div>
<OnboardingInlineCommandTerminal
command={command}
title="Skill setup"
ariaLabel="Skill setup command"
description="Press Enter to run the command and confirm npm if asked. You can also set this up later in Settings."
onOpened={trackTerminalOpened}
onInteracted={trackTerminalInteraction}
/>
)
}
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
}

View File

@ -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<typeof useAppStore.getState>['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<StatusTone, { pill: string; dot: string }> = {
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 (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium',
statusToneClassNames[tone].pill
)}
>
<span className={cn('size-1.5 rounded-full', statusToneClassNames[tone].dot)} />
{children}
</span>
)
}
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 (
<div className="rounded-xl border border-border bg-muted/20">
<div className="flex items-start gap-4 p-5">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border bg-background text-foreground">
<Github className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="text-[15px] font-semibold leading-tight text-foreground">GitHub</h3>
{state === 'connected' ? (
<StatusPill tone="connected">Connected</StatusPill>
) : state === 'not-installed' ? (
<StatusPill tone="attention">CLI not installed</StatusPill>
) : state === 'not-authenticated' ? (
<StatusPill tone="attention">Sign in needed</StatusPill>
) : (
<StatusPill tone="neutral">Checking</StatusPill>
)}
</div>
<p className="mt-1 text-[13px] leading-relaxed text-muted-foreground">
Pull requests, issues, and check status.
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{state === 'not-installed' ? (
<Button
variant="outline"
size="sm"
onClick={() => window.api.shell.openUrl('https://cli.github.com')}
>
<ExternalLink className="size-3.5" />
Install gh
</Button>
) : null}
{state === 'not-authenticated' ? (
<Button
variant="outline"
size="sm"
disabled={githubTerminalOpen}
onClick={() => setGithubTerminalOpen(true)}
>
<Terminal className="size-3.5" />
{githubTerminalOpen ? 'Signing in' : 'Sign in'}
</Button>
) : null}
<Button
variant="ghost"
size="sm"
onClick={() => void refreshPreflightStatus({ force: true })}
>
Re-check
</Button>
</div>
</div>
{state === 'not-authenticated' && githubTerminalOpen ? (
<div className="px-5 pb-5">
<OnboardingInlineCommandTerminal
command="gh auth login"
title="GitHub setup"
ariaLabel="GitHub sign in command"
description="Press Enter to run GitHub CLI auth. Re-check GitHub after the browser or device flow finishes."
/>
</div>
) : null}
</div>
)
}
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<string | null>(null)
const workspaceCount = linearStatus.workspaces?.length ?? (linearStatus.connected ? 1 : 0)
const handleConnect = async (): Promise<void> => {
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 (
<>
<div className="rounded-xl border border-border bg-muted/20">
<div className="flex items-start gap-4 p-5">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border bg-background text-foreground">
<LinearIcon className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="text-[15px] font-semibold leading-tight text-foreground">Linear</h3>
{linearStatus.connected ? <StatusPill tone="connected">Connected</StatusPill> : null}
</div>
<p className="mt-1 text-[13px] leading-relaxed text-muted-foreground">
{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.'}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{linearStatus.connected ? (
<Button variant="outline" size="sm" onClick={() => setDialogOpen(true)}>
Add workspace
</Button>
) : (
<Button size="sm" onClick={() => setDialogOpen(true)}>
Connect
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => void checkLinearConnection(true)}>
Re-check
</Button>
</div>
</div>
</div>
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
if (connectState !== 'connecting') {
setDialogOpen(open)
}
}}
>
<DialogContent
overlayClassName="z-[110]"
className="z-[120] sm:max-w-md"
onKeyDown={(event) => {
if (event.key === 'Enter' && apiKeyDraft.trim() && connectState !== 'connecting') {
event.preventDefault()
void handleConnect()
}
}}
>
<DialogHeader className="gap-3">
<DialogTitle className="leading-tight">Connect Linear workspace</DialogTitle>
<DialogDescription>
Paste a Personal API key to add a Linear workspace to Orca.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Input
autoFocus
type="password"
placeholder="lin_api_..."
value={apiKeyDraft}
onChange={(event) => {
setApiKeyDraft(event.target.value)
if (connectState === 'error') {
setConnectState('idle')
setConnectError(null)
}
}}
disabled={connectState === 'connecting'}
/>
{connectState === 'error' && connectError ? (
<p className="text-xs text-destructive">{connectError}</p>
) : null}
<p className="text-xs leading-relaxed text-muted-foreground">
Create one in{' '}
<button
className="text-primary underline-offset-2 hover:underline"
onClick={() =>
window.api.shell.openUrl('https://linear.app/settings/account/security')
}
>
Linear Settings &rarr; Security
</button>
.
</p>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={connectState === 'connecting'}
>
Cancel
</Button>
<Button
onClick={() => void handleConnect()}
disabled={!apiKeyDraft.trim() || connectState === 'connecting'}
>
{connectState === 'connecting' ? (
<>
<Loader2 className="size-4 animate-spin" />
Verifying...
</>
) : (
'Connect'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
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 (
<div className="space-y-6">
<ul className="-mt-6 space-y-1.5 text-[14px] leading-relaxed text-muted-foreground">
{CAPABILITIES.map((line) => (
<li key={line} className="flex gap-2.5">
<span className="mt-2 size-1 shrink-0 rounded-full bg-muted-foreground" aria-hidden />
<span>{line}</span>
</li>
))}
</ul>
<div className="space-y-3">
<GitHubRow />
<LinearRow />
</div>
</div>
)
}

View File

@ -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(
<NotificationStep
value={{
@ -28,5 +28,6 @@ describe('NotificationStep', () => {
expect(html).toContain('Computer Use')
expect(html).toContain('Agent Orchestration')
expect(html).toContain('role="checkbox"')
expect(html).not.toContain('Connect task sources')
})
})

View File

@ -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' && <IntegrationsStep />}
{currentStep.id === 'repo' && (
<RepoStep
cloneUrl={flow.cloneUrl}
@ -233,6 +240,15 @@ export default function OnboardingFlow({
Back
</button>
)}
{shouldShowSetupAction && (
<button
className="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.skipAgentSetup()}
>
Skip
</button>
)}
{currentStep.id !== 'repo' && (
<button
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"

View File

@ -0,0 +1,211 @@
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 { useAppStore } from '@/store'
const ONBOARDING_INLINE_TERMINAL_WORKTREE_ID = 'onboarding-inline-terminal'
const AUTO_INSERT_DELAY_MS = 700
const READY_RETRY_MS = 100
const READY_MAX_ATTEMPTS = 50
type OnboardingInlineCommandTerminalProps = {
command: string
title: string
description: string
ariaLabel: string
onOpened?: () => void
onInteracted?: (method: 'keyboard' | 'pointer', event?: KeyboardEvent<HTMLElement>) => void
}
export function OnboardingInlineCommandTerminal({
command,
title,
description,
ariaLabel,
onOpened,
onInteracted
}: OnboardingInlineCommandTerminalProps): 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<string | null>(null)
const [tabId, setTabId] = useState<string | null>(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<HTMLElement>(null)
const autoInsertedRef = useRef<string | null>(null)
useEffect(() => {
onOpened?.()
}, [onOpened])
useEffect(() => {
void window.api.app.getFloatingTerminalCwd({ path: '~' }).then(setCwd)
}, [])
useEffect(() => {
const tab = createTab(ONBOARDING_INLINE_TERMINAL_WORKTREE_ID, undefined, undefined, {
activate: false
})
setActiveTabForWorktree(ONBOARDING_INLINE_TERMINAL_WORKTREE_ID, tab.id)
setTabCustomTitle(tab.id, title)
setTabId(tab.id)
}, [createTab, setActiveTabForWorktree, setTabCustomTitle, title])
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<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])
// 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 (
<div
aria-hidden={!entered}
className="grid transition-[grid-template-rows,opacity,margin-top] duration-[700ms] ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none"
style={{
gridTemplateRows: entered ? '1fr' : '0fr',
opacity: entered ? 1 : 0,
marginTop: entered ? 20 : 0
}}
>
<section
ref={terminalSectionRef}
aria-label={ariaLabel}
className="min-h-0 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">{description}</p>
</div>
<div
className="relative h-[280px] min-h-0 bg-background"
onKeyDownCapture={(event) => onInteracted?.('keyboard', event)}
onPointerDownCapture={() => onInteracted?.('pointer')}
>
{cwd && tabId ? (
<TerminalPane
tabId={tabId}
worktreeId={ONBOARDING_INLINE_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>
</div>
)
}
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
}

View File

@ -211,6 +211,14 @@ export function usePersistCurrentStep({
onOnboardingChange(await persistStep(3))
return { ok: true, featureSetupResult }
}
if (currentStepId === 'integrations') {
// Why: GitHub and Linear connections persist through their own
// store slices when the user actually wires them up. The step itself
// is a no-op for settings/onboarding state beyond marking it
// completed.
onOnboardingChange(await persistStep(4))
return { ok: true }
}
return { ok: false }
} catch (err) {
setError(err instanceof Error ? err.message : String(err))

View File

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

View File

@ -7,7 +7,9 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { applyDocumentTheme } from '@/lib/document-theme'
import { track } from '@/lib/telemetry'
import { buildAgentPickedPayload } from './agent-picked-payload'
import { ONBOARDING_FINAL_STEP } from '../../../../shared/constants'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { EventProps } from '../../../../shared/telemetry-events'
import type { GlobalSettings, OnboardingState, Repo, TuiAgent } from '../../../../shared/types'
import type { NotificationDraft } from './NotificationStep'
import {
@ -27,6 +29,34 @@ export type { StepId, StepNumber } from './use-onboarding-flow-types'
export type OnboardingFlowController = ReturnType<typeof useOnboardingFlow>
type TaskSourcesSnapshotProps = EventProps<'onboarding_task_sources_snapshot'>
type TaskSourcesGithubStatus = TaskSourcesSnapshotProps['github_status']
type TaskSourcesLinearStatus = TaskSourcesSnapshotProps['linear_status']
type TaskSourcesExitAction = TaskSourcesSnapshotProps['exit_action']
function getGitHubTaskSourceStatus(
status: ReturnType<typeof useAppStore.getState>['preflightStatus'],
loading: boolean
): TaskSourcesGithubStatus {
if (loading || !status) {
return 'checking'
}
if (!status.gh.installed) {
return 'not_installed'
}
return status.gh.authenticated ? 'connected' : 'not_authenticated'
}
function getLinearTaskSourceStatus(
status: ReturnType<typeof useAppStore.getState>['linearStatus'],
checked: boolean
): TaskSourcesLinearStatus {
if (status.connected) {
return 'connected'
}
return checked ? 'not_connected' : 'checking'
}
export function useOnboardingFlow(
onboarding: OnboardingState,
onOnboardingChange: (state: OnboardingState) => void,
@ -46,6 +76,10 @@ export function useOnboardingFlow(
const openModal = useAppStore((s) => s.openModal)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const preflightStatus = useAppStore((s) => s.preflightStatus)
const preflightStatusLoading = useAppStore((s) => s.preflightStatusLoading)
const linearStatus = useAppStore((s) => s.linearStatus)
const linearStatusChecked = useAppStore((s) => s.linearStatusChecked)
const initialStep = Math.min(Math.max(onboarding.lastCompletedStep, 0), STEPS.length - 1)
const [stepIndex, setStepIndex] = useState(initialStep)
@ -236,6 +270,25 @@ export function useOnboardingFlow(
return Math.max(0, Date.now() - stepStartedAtRef.current)
}, [])
const trackTaskSourcesSnapshot = useCallback(
(
exitAction: TaskSourcesExitAction,
durationMs: number,
advancedVia: 'button' | 'keyboard'
): void => {
// Why: one low-cardinality snapshot answers whether task sources were
// usable at step exit without paying for per-button telemetry.
track('onboarding_task_sources_snapshot', {
github_status: getGitHubTaskSourceStatus(preflightStatus, preflightStatusLoading),
linear_status: getLinearTaskSourceStatus(linearStatus, linearStatusChecked),
exit_action: exitAction,
duration_ms: durationMs,
advanced_via: advancedVia
})
},
[linearStatus, linearStatusChecked, preflightStatus, preflightStatusLoading]
)
// Why: only auto-pick on first mount when detection completes; otherwise
// selecting an agent would re-trigger this effect and clobber/race user clicks.
const didAutoSelectRef = useRef(false)
@ -274,24 +327,25 @@ export function useOnboardingFlow(
const startup = isGit ? undefined : buildOnboardingFolderAgentStartup(settings)
activateAndRevealWorktree(worktree.id, startup ? { startup } : undefined)
}
// Why: next() short-circuits step 4, so emit step_completed here once the
// repo is successfully added to keep the funnel consistent. Gate on
// closeWith's success so a persistence failure doesn't double-count.
// Why: next() short-circuits the repo step, so emit step_completed here
// once the repo is successfully added to keep the funnel consistent.
// Gate on closeWith's success so a persistence failure doesn't
// double-count.
const closed = await closeWith(
'completed',
isGit ? { addedRepo: true } : { addedFolder: true },
4,
ONBOARDING_FINAL_STEP,
path
)
if (!closed) {
return
}
// Why: step 4 has no keyboard-vs-button advance — Cmd+Enter routes to
// `openFolder()` which collapses both into the path-clicked path. Emit
// `duration_ms` only; `advanced_via` is intentionally absent for step 4.
// See docs/onboarding-telemetry-extensions.md §3.
// Why: the repo step has no keyboard-vs-button advance — Cmd+Enter
// routes to `openFolder()` which collapses both into the path-clicked
// path. Emit `duration_ms` only; `advanced_via` is intentionally absent
// for the final step. See docs/onboarding-telemetry-extensions.md §3.
track('onboarding_step_completed', {
step: 4,
step: ONBOARDING_FINAL_STEP,
value_kind: 'repo',
duration_ms: consumeStepDurationMs()
})
@ -367,12 +421,16 @@ export function useOnboardingFlow(
// not double-count the same step completion.
notificationsStepCompletedTrackedRef.current = true
}
const durationMs = consumeStepDurationMs()
track('onboarding_step_completed', {
step: currentStep.stepNumber,
value_kind: currentStep.valueKind,
duration_ms: consumeStepDurationMs(),
duration_ms: durationMs,
advanced_via: advancedVia
})
if (currentStep.id === 'integrations') {
trackTaskSourcesSnapshot('continue', durationMs, advancedVia)
}
}
const result = await persistCurrentStep()
const nextCommand = result.featureSetupResult?.skillInstallCommand ?? null
@ -402,7 +460,8 @@ export function useOnboardingFlow(
featureSetupSelection,
featureSetupTerminalCommand,
hasSelectedFeatureSetup,
persistCurrentStep
persistCurrentStep,
trackTaskSourcesSnapshot
]
)
@ -542,6 +601,9 @@ export function useOnboardingFlow(
duration_ms: durationMs,
advanced_via: 'button'
})
if (currentStep.id === 'integrations') {
trackTaskSourcesSnapshot('skip_to_project_setup', durationMs, 'button')
}
setStepIndex(repoStepIndex)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
@ -556,16 +618,43 @@ export function useOnboardingFlow(
onOnboardingChange,
selectedAgent,
settings,
trackTaskSourcesSnapshot,
updateSettings
])
const skipAgentSetup = useCallback(async () => {
if (busyLabel || currentStep.id !== 'notifications') {
return
}
setError(null)
const durationMs = consumeStepDurationMs()
try {
// Why: this step's primary action can request notification permission and
// run selected feature setup. Skip is the explicit "not now" path.
const nextState = await persistStep(currentStep.stepNumber)
onOnboardingChange(nextState)
track('onboarding_step_skipped', {
step: currentStep.stepNumber,
duration_ms: durationMs,
advanced_via: 'button'
})
setFeatureSetupTerminalCommand(null)
setFeatureSetupTerminalSelection(null)
setStepIndex((idx) => Math.min(idx + 1, STEPS.length - 1))
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
toast.error('Could not skip agent setup', { description: message })
}
}, [busyLabel, consumeStepDurationMs, currentStep.id, currentStep.stepNumber, onOnboardingChange])
const openSshSettings = useCallback(async () => {
if (busyLabel || currentStep.id !== 'repo') {
return
}
setError(null)
try {
onOnboardingChange(await persistStep(3))
onOnboardingChange(await persistStep(currentStep.stepNumber - 1))
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
@ -584,6 +673,7 @@ export function useOnboardingFlow(
}, [
busyLabel,
currentStep.id,
currentStep.stepNumber,
onOnboardingChange,
onSettingsDetourStart,
openSettingsPage,
@ -625,6 +715,7 @@ export function useOnboardingFlow(
detectedSet,
isDetectingAgents,
next,
skipAgentSetup,
skipToRepo,
back,
jumpToStep,

View File

@ -42,14 +42,16 @@ function DialogOverlay({
function DialogContent({
className,
children,
overlayClassName,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
overlayClassName?: string
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(

View File

@ -28,7 +28,7 @@ export const DEFAULT_APP_FONT_FAMILY = 'Geist'
// Why: the onboarding wizard's last step index. Centralized so backfill,
// clamps, and UI step references all agree on the same upper bound.
export const ONBOARDING_FINAL_STEP = 4
export const ONBOARDING_FINAL_STEP = 5
export const ORCA_BROWSER_PARTITION = 'persist:orca-browser'
// Why: blank browser tabs must start from an inert guest URL that does not

View File

@ -353,7 +353,27 @@ const onboardingFailureReasonSchema = z.enum([
'cancelled',
'unknown'
])
const onboardingValueKindSchema = z.enum(['agent', 'theme', 'notifications', 'repo'])
const onboardingValueKindSchema = z.enum([
'agent',
'theme',
'notifications',
'integrations',
'repo'
])
const onboardingTaskSourcesGithubStatusSchema = z.enum([
'connected',
'not_authenticated',
'not_installed',
'checking',
'unknown'
])
const onboardingTaskSourcesLinearStatusSchema = z.enum([
'connected',
'not_connected',
'checking',
'unknown'
])
const onboardingTaskSourcesExitActionSchema = z.enum(['continue', 'skip_to_project_setup'])
// `dismissed` from `OnboardingChecklistState` is intentionally excluded —
// it is a UI panel-visibility flag, not an activation event, so it never
// fires `activation_checklist_item_completed`. Keep this list in sync with
@ -465,6 +485,16 @@ const onboardingStep4PathFailedSchema = z
cohort: cohortSchema
})
.strict()
const onboardingTaskSourcesSnapshotSchema = z
.object({
github_status: onboardingTaskSourcesGithubStatusSchema,
linear_status: onboardingTaskSourcesLinearStatusSchema,
exit_action: onboardingTaskSourcesExitActionSchema,
duration_ms: z.number().int().nonnegative().optional(),
advanced_via: advancedViaSchema,
cohort: cohortSchema
})
.strict()
const onboardingCompletedSchema = z
.object({
path: onboardingPathSchema,
@ -704,6 +734,7 @@ export const eventSchemas = {
onboarding_step_skipped: onboardingStepSkippedSchema,
onboarding_step4_path_clicked: onboardingStep4PathClickedSchema,
onboarding_step4_path_failed: onboardingStep4PathFailedSchema,
onboarding_task_sources_snapshot: onboardingTaskSourcesSnapshotSchema,
onboarding_completed: onboardingCompletedSchema,
onboarding_dismissed: onboardingDismissedSchema,
onboarding_agent_picked: onboardingAgentPickedSchema,
@ -810,6 +841,7 @@ type _OnboardingCohortRoster =
| 'onboarding_step_skipped'
| 'onboarding_step4_path_clicked'
| 'onboarding_step4_path_failed'
| 'onboarding_task_sources_snapshot'
| 'onboarding_completed'
| 'onboarding_dismissed'
| 'onboarding_agent_picked'