Add GitHub and Linear onboarding setup (#2275)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
71a3bba90d
commit
ececd27fd8
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 → 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue