feat: onboarding flow for new users (#1596)
* wip
* WIP: Changes before auto-review fixes
Co-authored-by: Orca <help@stably.ai>
* WIP: Changes before auto-review fixes
Co-authored-by: Orca <help@stably.ai>
* WIP: Changes before auto-review fixes
Co-authored-by: Orca <help@stably.ai>
* fix: address auto-review findings (iteration 1)
Co-authored-by: Orca <help@stably.ai>
* fix: address auto-review findings (iteration 2)
Co-authored-by: Orca <help@stably.ai>
* fix: archive review context and improve agent detection on wizard mount
Co-authored-by: Orca <help@stably.ai>
* fix: address CI lint failures and split use-onboarding-flow.ts
Co-authored-by: Orca <help@stably.ai>
* fix: mock ./onboarding in register-core-handlers test
Co-authored-by: Orca <help@stably.ai>
* fix: also toggle light class on documentElement so onboarding e2e theme wait resolves
The onboarding e2e calls waitForFunction(() => classList.contains('dark') || classList.contains('light')) before snapshotting the starting theme. applyDocumentTheme only toggled 'dark', so on a host that resolves system to light the wait timed out (CI Linux headless). Toggle 'light' as the inverse class so consumers can observe the resolved theme symmetrically; Tailwind keys only on 'dark' so styling is unchanged.
Co-authored-by: Orca <help@stably.ai>
* fix: add braces to Landing menu close-on-outside-click handler
oxlint config requires braces for all if statements.
Co-authored-by: Orca <help@stably.ai>
* chore: trigger CI
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
2050fa87a0
commit
e0851ec722
|
|
@ -0,0 +1,43 @@
|
|||
#!/usr/bin/env bash
|
||||
# Launch `pn dev` with a fresh, isolated userData profile so the app behaves
|
||||
# like a first-time install (onboarding overlay paints, no persisted repos,
|
||||
# no saved sessions). Your real `orca-dev` profile is left untouched.
|
||||
#
|
||||
# Usage:
|
||||
# ./config/scripts/dev-fresh-profile.sh # ephemeral temp profile, deleted on exit
|
||||
# ./config/scripts/dev-fresh-profile.sh --keep # keep the profile dir after exit
|
||||
# ORCA_FRESH_PROFILE_DIR=/some/path ./config/scripts/dev-fresh-profile.sh # use a fixed dir
|
||||
set -euo pipefail
|
||||
|
||||
KEEP=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--keep) KEEP=1 ;;
|
||||
-h|--help)
|
||||
sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0 ;;
|
||||
*)
|
||||
echo "Unknown argument: $arg" >&2
|
||||
echo "Usage: $0 [--keep] [--help]" >&2
|
||||
exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
PROFILE_DIR="${ORCA_FRESH_PROFILE_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/orca-fresh-profile.XXXXXXXX")}"
|
||||
mkdir -p "$PROFILE_DIR"
|
||||
|
||||
cleanup() {
|
||||
if [[ "$KEEP" -eq 0 && -z "${ORCA_FRESH_PROFILE_DIR:-}" ]]; then
|
||||
# Guard rm -rf against accidental empty/unrelated PROFILE_DIR.
|
||||
[[ -n "${PROFILE_DIR:-}" && -d "$PROFILE_DIR" && "$PROFILE_DIR" == */orca-fresh-profile* ]] || return 0
|
||||
rm -rf "$PROFILE_DIR"
|
||||
echo "[dev-fresh-profile] removed $PROFILE_DIR"
|
||||
else
|
||||
echo "[dev-fresh-profile] kept $PROFILE_DIR"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "[dev-fresh-profile] using userData=$PROFILE_DIR"
|
||||
# Don't exec — we need the EXIT trap to fire so the temp profile gets cleaned up.
|
||||
ORCA_DEV_USER_DATA_PATH="$PROFILE_DIR" pnpm dev
|
||||
|
|
@ -614,7 +614,15 @@ app.whenReady().then(async () => {
|
|||
// dialog either doesn't appear or gets immediately covered by the maximized
|
||||
// window, making it impossible for the user to click "Allow".
|
||||
win.once('show', () => {
|
||||
triggerStartupNotificationRegistration(store!)
|
||||
// Why: store can be null if init failed earlier; bail rather than risk a
|
||||
// throw inside an Electron event listener.
|
||||
if (!store) {
|
||||
return
|
||||
}
|
||||
const onboarding = store.getOnboarding()
|
||||
if (onboarding.closedAt !== null) {
|
||||
triggerStartupNotificationRegistration(store)
|
||||
}
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { Store } from '../persistence'
|
|||
import type {
|
||||
NotificationDispatchRequest,
|
||||
NotificationDispatchResult,
|
||||
NotificationPermissionStatusResult,
|
||||
NotificationSoundDataResult
|
||||
} from '../../shared/types'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
|
|
@ -31,6 +32,8 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
|
|||
const recentNotifications = new Map<string, number>()
|
||||
|
||||
ipcMain.removeHandler('notifications:openSystemSettings')
|
||||
ipcMain.removeHandler('notifications:getPermissionStatus')
|
||||
ipcMain.removeHandler('notifications:requestPermission')
|
||||
ipcMain.handle('notifications:openSystemSettings', (): void => {
|
||||
if (process.platform === 'darwin') {
|
||||
// Deep-link into the macOS Notifications settings pane.
|
||||
|
|
@ -40,6 +43,25 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
|
|||
}
|
||||
})
|
||||
|
||||
// Why: Electron's main-process `Notification` class exposes no synchronous
|
||||
// way to read macOS auth status — the renderer-side `Notification.permission`
|
||||
// does not exist here. We expose what we can reliably observe: whether the
|
||||
// platform supports notifications and whether we've already kicked off the
|
||||
// first-permission prompt. A 'denied' OS result is invisible to us; the
|
||||
// dispatch path simply won't deliver in that case, which the user can
|
||||
// diagnose via the System Settings deep-link.
|
||||
const getPermissionStatus = (): NotificationPermissionStatusResult => ({
|
||||
supported: Notification.isSupported(),
|
||||
platform: process.platform,
|
||||
requested: store.getUI().notificationPermissionRequested === true
|
||||
})
|
||||
|
||||
ipcMain.handle('notifications:getPermissionStatus', getPermissionStatus)
|
||||
ipcMain.handle('notifications:requestPermission', (): NotificationPermissionStatusResult => {
|
||||
triggerStartupNotificationRegistration(store)
|
||||
return getPermissionStatus()
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('notifications:dispatch')
|
||||
ipcMain.handle(
|
||||
'notifications:dispatch',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import { ipcMain } from 'electron'
|
||||
import { sanitizeOnboardingUpdate, type Store } from '../persistence'
|
||||
import type { OnboardingState } from '../../shared/types'
|
||||
|
||||
export function registerOnboardingHandlers(store: Store): void {
|
||||
ipcMain.removeHandler('onboarding:get')
|
||||
ipcMain.removeHandler('onboarding:update')
|
||||
|
||||
ipcMain.handle('onboarding:get', (): OnboardingState => store.getOnboarding())
|
||||
// Why: never trust renderer input — a compromised/buggy caller could send
|
||||
// unknown keys or wrong-typed values that would poison persisted state.
|
||||
// Run every update through the shared whitelist sanitizer.
|
||||
ipcMain.handle('onboarding:update', (_event, updates: unknown): OnboardingState => {
|
||||
return store.updateOnboarding(sanitizeOnboardingUpdate(updates))
|
||||
})
|
||||
}
|
||||
|
|
@ -32,7 +32,8 @@ const {
|
|||
registerFilesystemWatcherHandlersMock,
|
||||
registerAppHandlersMock,
|
||||
registerLinearHandlersMock,
|
||||
registerExportHandlersMock
|
||||
registerExportHandlersMock,
|
||||
registerOnboardingHandlersMock
|
||||
} = vi.hoisted(() => ({
|
||||
registerCliHandlersMock: vi.fn(),
|
||||
registerPreflightHandlersMock: vi.fn(),
|
||||
|
|
@ -65,7 +66,12 @@ const {
|
|||
registerFilesystemWatcherHandlersMock: vi.fn(),
|
||||
registerAppHandlersMock: vi.fn(),
|
||||
registerLinearHandlersMock: vi.fn(),
|
||||
registerExportHandlersMock: vi.fn()
|
||||
registerExportHandlersMock: vi.fn(),
|
||||
registerOnboardingHandlersMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./onboarding', () => ({
|
||||
registerOnboardingHandlers: registerOnboardingHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./cli', () => ({
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { registerMemoryHandlers } from './memory'
|
|||
import { registerRateLimitHandlers } from './rate-limits'
|
||||
import { registerRuntimeHandlers } from './runtime'
|
||||
import { registerNotificationHandlers } from './notifications'
|
||||
import { registerOnboardingHandlers } from './onboarding'
|
||||
import { registerDeveloperPermissionHandlers } from './developer-permissions'
|
||||
import { setTrustedBrowserRendererWebContentsId, setAgentBrowserBridgeRef } from './browser'
|
||||
import { registerSessionHandlers } from './session'
|
||||
|
|
@ -83,6 +84,7 @@ export function registerCoreHandlers(
|
|||
registerStatsHandlers(stats)
|
||||
registerMemoryHandlers(store)
|
||||
registerNotificationHandlers(store, runtime)
|
||||
registerOnboardingHandlers(store)
|
||||
registerDeveloperPermissionHandlers()
|
||||
registerSettingsHandlers(store)
|
||||
registerTelemetryHandlers(store)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ import type {
|
|||
Repo,
|
||||
SparsePreset,
|
||||
WorktreeMeta,
|
||||
GlobalSettings
|
||||
GlobalSettings,
|
||||
OnboardingChecklistState,
|
||||
OnboardingOutcome,
|
||||
OnboardingState
|
||||
} from '../shared/types'
|
||||
import type { SshTarget } from '../shared/ssh-types'
|
||||
import { isFolderRepo } from '../shared/repo-kind'
|
||||
|
|
@ -20,9 +23,11 @@ import { getGitUsername } from './git/repo'
|
|||
import {
|
||||
getDefaultPersistedState,
|
||||
getDefaultNotificationSettings,
|
||||
getDefaultOnboardingState,
|
||||
getDefaultUIState,
|
||||
getDefaultRepoHookSettings,
|
||||
getDefaultWorkspaceSession
|
||||
getDefaultWorkspaceSession,
|
||||
ONBOARDING_FINAL_STEP
|
||||
} from '../shared/constants'
|
||||
import { parseWorkspaceSession } from '../shared/workspace-session-schema'
|
||||
|
||||
|
|
@ -103,6 +108,68 @@ function normalizeSshTarget(t: SshTarget): SshTarget {
|
|||
return { ...t, configHost: t.configHost ?? t.label ?? t.host }
|
||||
}
|
||||
|
||||
// Why: shared by load-time merge and the IPC update handler so the same
|
||||
// strict whitelist guards every entry into onboarding state — arbitrary
|
||||
// renderer/disk input cannot inject unknown keys or wrong-typed values.
|
||||
// Returns only validated fields; unknown keys are dropped silently.
|
||||
// Why: returns Partial<...> with a partial checklist so the IPC update path
|
||||
// merges over current state without wiping previously-true keys. Invalid
|
||||
// top-level fields are OMITTED (not coerced to fallbacks) so partial updates
|
||||
// don't clobber valid persisted state; the load-path caller spreads defaults.
|
||||
export function sanitizeOnboardingUpdate(
|
||||
input: unknown
|
||||
): Partial<Omit<OnboardingState, 'checklist'>> & { checklist?: Partial<OnboardingChecklistState> } {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
return {}
|
||||
}
|
||||
const raw = input as Record<string, unknown>
|
||||
const out: Partial<Omit<OnboardingState, 'checklist'>> & {
|
||||
checklist?: Partial<OnboardingChecklistState>
|
||||
} = {}
|
||||
|
||||
if ('closedAt' in raw) {
|
||||
if (typeof raw.closedAt === 'number') {
|
||||
out.closedAt = raw.closedAt
|
||||
} else if (raw.closedAt === null) {
|
||||
out.closedAt = null
|
||||
}
|
||||
// else: omit — preserve existing persisted value on merge.
|
||||
}
|
||||
if ('outcome' in raw) {
|
||||
const v = raw.outcome
|
||||
if (v === 'completed' || v === 'dismissed') {
|
||||
out.outcome = v as OnboardingOutcome
|
||||
} else if (v === null) {
|
||||
out.outcome = null
|
||||
}
|
||||
// else: omit.
|
||||
}
|
||||
if ('lastCompletedStep' in raw) {
|
||||
const v = raw.lastCompletedStep
|
||||
if (typeof v === 'number' && Number.isInteger(v) && v >= -1 && v <= ONBOARDING_FINAL_STEP) {
|
||||
out.lastCompletedStep = v
|
||||
}
|
||||
// else: omit.
|
||||
}
|
||||
if ('checklist' in raw) {
|
||||
const rawChecklist = raw.checklist
|
||||
if (rawChecklist && typeof rawChecklist === 'object' && !Array.isArray(rawChecklist)) {
|
||||
// Why: copy ONLY caller-sent boolean keys so partial updates (e.g.
|
||||
// `{ addedRepo: true }`) don't reset other checklist items to false.
|
||||
const defaults = getDefaultOnboardingState().checklist
|
||||
const rc = rawChecklist as Record<string, unknown>
|
||||
const checklist: Partial<OnboardingChecklistState> = {}
|
||||
for (const key of Object.keys(defaults) as (keyof OnboardingChecklistState)[]) {
|
||||
if (key in rc && typeof rc[key] === 'boolean') {
|
||||
checklist[key] = rc[key] as boolean
|
||||
}
|
||||
}
|
||||
out.checklist = checklist
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Why: read a settings field that was removed from the GlobalSettings type
|
||||
// but still round-trips on disk via the ...parsed.settings spread. One-shot
|
||||
// use only — for the inline-agents default-on migration's Case B discriminator.
|
||||
|
|
@ -284,7 +351,37 @@ export class Store {
|
|||
}
|
||||
return { ...defaults.workspaceSession, ...result.value }
|
||||
})(),
|
||||
sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget)
|
||||
sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget),
|
||||
onboarding: (() => {
|
||||
// Why: if we successfully parsed an existing orca-data.json that
|
||||
// lacks an onboarding block, this is an upgrade-cohort user —
|
||||
// backfill as completed (not dismissed) so they don't get dropped
|
||||
// into the wizard regardless of whether they currently have repos,
|
||||
// SSH targets, or just non-default settings. Analytics still
|
||||
// distinguish this from users who explicitly bailed mid-funnel.
|
||||
if (!parsed.onboarding) {
|
||||
return {
|
||||
...defaults.onboarding,
|
||||
closedAt: Date.now(),
|
||||
outcome: 'completed' as const,
|
||||
lastCompletedStep: ONBOARDING_FINAL_STEP
|
||||
}
|
||||
}
|
||||
// Why: validate every persisted onboarding key explicitly via the
|
||||
// shared sanitizer instead of spreading raw values. A type-flipped
|
||||
// field on disk (string where number expected, unknown checklist
|
||||
// key) is dropped or coerced to the default rather than poisoning
|
||||
// in-memory state.
|
||||
const sanitized = sanitizeOnboardingUpdate(parsed.onboarding)
|
||||
return {
|
||||
...defaults.onboarding,
|
||||
...sanitized,
|
||||
checklist: {
|
||||
...defaults.onboarding.checklist,
|
||||
...sanitized.checklist
|
||||
}
|
||||
}
|
||||
})()
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -671,6 +768,38 @@ export class Store {
|
|||
this.scheduleSave()
|
||||
}
|
||||
|
||||
// ── Onboarding ────────────────────────────────────────────────────
|
||||
|
||||
getOnboarding(): PersistedState['onboarding'] {
|
||||
const defaults = getDefaultOnboardingState()
|
||||
return {
|
||||
...defaults,
|
||||
...this.state.onboarding,
|
||||
checklist: {
|
||||
...defaults.checklist,
|
||||
...this.state.onboarding?.checklist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateOnboarding(
|
||||
updates: Partial<Omit<PersistedState['onboarding'], 'checklist'>> & {
|
||||
checklist?: Partial<OnboardingChecklistState>
|
||||
}
|
||||
): PersistedState['onboarding'] {
|
||||
const current = this.getOnboarding()
|
||||
this.state.onboarding = {
|
||||
...current,
|
||||
...updates,
|
||||
checklist: {
|
||||
...current.checklist,
|
||||
...updates.checklist
|
||||
}
|
||||
}
|
||||
this.scheduleSave()
|
||||
return this.getOnboarding()
|
||||
}
|
||||
|
||||
// ── GitHub Cache ──────────────────────────────────────────────────
|
||||
|
||||
getGitHubCache(): PersistedState['githubCache'] {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,9 @@ import type {
|
|||
GetRateLimitResult,
|
||||
NotificationDispatchRequest,
|
||||
NotificationDispatchResult,
|
||||
NotificationPermissionStatusResult,
|
||||
NotificationSoundResult,
|
||||
OnboardingState,
|
||||
OrcaHooks,
|
||||
PersistedUIState,
|
||||
PRCheckDetail,
|
||||
|
|
@ -750,8 +752,21 @@ export type PreloadApi = {
|
|||
notifications: {
|
||||
dispatch: (args: NotificationDispatchRequest) => Promise<NotificationDispatchResult>
|
||||
openSystemSettings: () => Promise<void>
|
||||
getPermissionStatus: () => Promise<NotificationPermissionStatusResult>
|
||||
requestPermission: () => Promise<NotificationPermissionStatusResult>
|
||||
playSound: (options?: { force?: boolean }) => Promise<NotificationSoundResult>
|
||||
}
|
||||
onboarding: {
|
||||
get: () => Promise<OnboardingState>
|
||||
// Why: main-process `updateOnboarding` merges checklist field-by-field, so
|
||||
// callers can pass a partial checklist (e.g. just `{ addedRepo: true }`)
|
||||
// without re-supplying every flag.
|
||||
update: (
|
||||
updates: Partial<Omit<OnboardingState, 'checklist'>> & {
|
||||
checklist?: Partial<OnboardingState['checklist']>
|
||||
}
|
||||
) => Promise<OnboardingState>
|
||||
}
|
||||
developerPermissions: {
|
||||
getStatus: () => Promise<DeveloperPermissionState[]>
|
||||
request: (args: { id: DeveloperPermissionId }) => Promise<DeveloperPermissionRequestResult>
|
||||
|
|
|
|||
|
|
@ -21,9 +21,11 @@ import type {
|
|||
ListWorkItemsResult,
|
||||
MemorySnapshot,
|
||||
NotificationDispatchResult,
|
||||
NotificationPermissionStatusResult,
|
||||
NotificationSoundDataResult,
|
||||
NotificationSoundPathResult,
|
||||
NotificationSoundResult,
|
||||
OnboardingState,
|
||||
SearchResult
|
||||
} from '../shared/types'
|
||||
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types'
|
||||
|
|
@ -910,6 +912,10 @@ const api = {
|
|||
dispatch: (args: Record<string, unknown>): Promise<NotificationDispatchResult> =>
|
||||
ipcRenderer.invoke('notifications:dispatch', args),
|
||||
openSystemSettings: (): Promise<void> => ipcRenderer.invoke('notifications:openSystemSettings'),
|
||||
getPermissionStatus: (): Promise<NotificationPermissionStatusResult> =>
|
||||
ipcRenderer.invoke('notifications:getPermissionStatus'),
|
||||
requestPermission: (): Promise<NotificationPermissionStatusResult> =>
|
||||
ipcRenderer.invoke('notifications:requestPermission'),
|
||||
playSound: async (options?: { force?: boolean }): Promise<NotificationSoundResult> => {
|
||||
try {
|
||||
// Why: drop replays while the sound is still ringing. The "test"
|
||||
|
|
@ -971,6 +977,15 @@ const api = {
|
|||
}
|
||||
},
|
||||
|
||||
onboarding: {
|
||||
get: (): Promise<OnboardingState> => ipcRenderer.invoke('onboarding:get'),
|
||||
update: (
|
||||
updates: Partial<Omit<OnboardingState, 'checklist'>> & {
|
||||
checklist?: Partial<OnboardingState['checklist']>
|
||||
}
|
||||
): Promise<OnboardingState> => ipcRenderer.invoke('onboarding:update', updates)
|
||||
},
|
||||
|
||||
developerPermissions: {
|
||||
getStatus: (): Promise<unknown> => ipcRenderer.invoke('developerPermissions:getStatus'),
|
||||
request: (args: { id: string }): Promise<unknown> =>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { UpdateCard } from './components/UpdateCard'
|
|||
import { StarNagCard } from './components/StarNagCard'
|
||||
import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface'
|
||||
import { ZoomOverlay } from './components/ZoomOverlay'
|
||||
import { shouldShowOnboarding } from './components/onboarding/should-show-onboarding'
|
||||
import { SshPassphraseDialog } from './components/settings/SshPassphraseDialog'
|
||||
import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling'
|
||||
import { useEditorExternalWatch } from './hooks/useEditorExternalWatch'
|
||||
|
|
@ -45,12 +46,14 @@ import { buildWorkspaceSessionPayload } from './lib/workspace-session'
|
|||
import { countWorkingAgents, getWorkingAgentsPerWorktree } from './lib/agent-status'
|
||||
import { activateAndRevealWorktree } from './lib/worktree-activation'
|
||||
import { applyDocumentTheme } from './lib/document-theme'
|
||||
import { isEditableTarget } from './lib/editable-target'
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
|
||||
import { findWorktreeById, getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers'
|
||||
import {
|
||||
canGoBackWorktreeHistory,
|
||||
canGoForwardWorktreeHistory
|
||||
} from '@/store/slices/worktree-nav-history'
|
||||
import type { OnboardingState } from '../../shared/types'
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const isWindows = !isMac && navigator.userAgent.includes('Windows')
|
||||
|
|
@ -117,28 +120,10 @@ const NewWorkspaceComposerModal = lazy(() => import('./components/NewWorkspaceCo
|
|||
// Why: lazy-loaded so the WebP asset + overlay module aren't fetched unless
|
||||
// the user opts into the experimental flag.
|
||||
const PetOverlay = lazy(() => import('./components/pet/PetOverlay'))
|
||||
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// xterm.js focuses a hidden <textarea class="xterm-helper-textarea"> for
|
||||
// keyboard input. That element IS an editable target, but we must NOT
|
||||
// suppress global shortcuts when the terminal itself is focused — otherwise
|
||||
// Cmd/Ctrl+P and other app-level keybindings become unreachable.
|
||||
if (target.classList.contains('xterm-helper-textarea')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (target.isContentEditable) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]') !==
|
||||
null
|
||||
)
|
||||
}
|
||||
// Why: lazy so onboarding's step modules + assets aren't fetched for users
|
||||
// past first-launch. The gate `shouldShowOnboarding` lives in its own tiny
|
||||
// module so no eager import path pulls OnboardingFlow into the main chunk.
|
||||
const OnboardingFlow = lazy(() => import('./components/onboarding/OnboardingFlow'))
|
||||
|
||||
function App(): React.JSX.Element {
|
||||
// Why: Zustand actions are referentially stable, but each individual
|
||||
|
|
@ -213,6 +198,7 @@ function App(): React.JSX.Element {
|
|||
const titlebarLeftControlsRef = useRef<HTMLDivElement | null>(null)
|
||||
const [collapsedSidebarHeaderWidth, setCollapsedSidebarHeaderWidth] = useState(0)
|
||||
const [mountedLazyModalIds, setMountedLazyModalIds] = useState(() => new Set<string>())
|
||||
const [onboarding, setOnboarding] = useState<OnboardingState | null>(null)
|
||||
|
||||
// Subscribe to IPC push events
|
||||
useIpcEvents()
|
||||
|
|
@ -285,6 +271,10 @@ function App(): React.JSX.Element {
|
|||
actions.pruneLastVisitedTimestamps()
|
||||
actions.seedActiveWorktreeLastVisitedIfMissing()
|
||||
await actions.fetchBrowserSessionProfiles()
|
||||
const onboardingState = await window.api.onboarding.get()
|
||||
if (!cancelled) {
|
||||
setOnboarding(onboardingState)
|
||||
}
|
||||
|
||||
// Why: SSH connections must be re-established BEFORE terminal
|
||||
// reconnect so that reconnectPersistedTerminals can route SSH-backed
|
||||
|
|
@ -1162,6 +1152,11 @@ function App(): React.JSX.Element {
|
|||
<TelemetryFirstLaunchSurface />
|
||||
<ZoomOverlay />
|
||||
<SshPassphraseDialog />
|
||||
{onboarding && shouldShowOnboarding(onboarding) ? (
|
||||
<Suspense fallback={null}>
|
||||
<OnboardingFlow onboarding={onboarding} onOnboardingChange={setOnboarding} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
|
||||
{/* Why: rendered last so it sits after all -webkit-app-region:drag elements
|
||||
in DOM order. Electron's hit-test for drag regions is DOM-order-based and
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { AlertTriangle, ExternalLink, FolderPlus, GitBranchPlus, Star } from 'lucide-react'
|
||||
import { cn } from '../lib/utils'
|
||||
import { useAppStore } from '../store'
|
||||
|
|
@ -61,6 +61,8 @@ type StarState = 'loading' | 'starred' | 'not-starred' | 'hidden'
|
|||
|
||||
function GitHubStarButton({ hasRepos }: { hasRepos: boolean }): React.JSX.Element | null {
|
||||
const [state, setState] = useState<StarState>('loading')
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -79,7 +81,24 @@ function GitHubStarButton({ hasRepos }: { hasRepos: boolean }): React.JSX.Elemen
|
|||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) {
|
||||
return
|
||||
}
|
||||
const onDocClick = (e: MouseEvent): void => {
|
||||
if (!wrapperRef.current?.contains(e.target as Node)) {
|
||||
setMenuOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onDocClick)
|
||||
return () => document.removeEventListener('mousedown', onDocClick)
|
||||
}, [menuOpen])
|
||||
|
||||
const handleClick = async (): Promise<void> => {
|
||||
if (state === 'starred') {
|
||||
setMenuOpen((v) => !v)
|
||||
return
|
||||
}
|
||||
if (state !== 'not-starred') {
|
||||
return
|
||||
}
|
||||
|
|
@ -101,25 +120,43 @@ function GitHubStarButton({ hasRepos }: { hasRepos: boolean }): React.JSX.Elemen
|
|||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-full border px-4 py-1.5 text-[13px] font-medium transition-all duration-300',
|
||||
state === 'loading' && 'pointer-events-none opacity-0',
|
||||
state === 'not-starred' &&
|
||||
'border-amber-400/30 text-amber-300/90 hover:border-amber-400/50 hover:bg-amber-400/[0.08] cursor-pointer',
|
||||
state === 'starred' && 'border-amber-400/25 bg-amber-400/[0.06] text-amber-400/60'
|
||||
)}
|
||||
onClick={handleClick}
|
||||
disabled={state === 'starred' || state === 'loading'}
|
||||
>
|
||||
<Star
|
||||
<div ref={wrapperRef} className="relative inline-block">
|
||||
<button
|
||||
className={cn(
|
||||
'size-3.5 transition-all duration-300',
|
||||
state === 'starred' ? 'fill-amber-400/60 text-amber-400/60' : 'text-amber-400/80'
|
||||
'inline-flex items-center gap-2 rounded-full border px-4 py-1.5 text-[13px] font-medium transition-all duration-300',
|
||||
state === 'loading' && 'pointer-events-none opacity-0',
|
||||
state === 'not-starred' &&
|
||||
'cursor-pointer border-amber-500/60 text-amber-700 hover:border-amber-500/80 hover:bg-amber-400/10 dark:border-amber-400/30 dark:text-amber-300/90 dark:hover:border-amber-400/50 dark:hover:bg-amber-400/[0.08]',
|
||||
state === 'starred' &&
|
||||
'cursor-pointer border-amber-500/50 bg-amber-400/10 text-amber-700 dark:border-amber-400/25 dark:bg-amber-400/[0.06] dark:text-amber-400/60'
|
||||
)}
|
||||
/>
|
||||
{state === 'starred' ? 'Starred on GitHub' : 'Star on GitHub'}
|
||||
</button>
|
||||
onClick={handleClick}
|
||||
disabled={state === 'loading'}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
'size-3.5 transition-all duration-300',
|
||||
state === 'starred'
|
||||
? 'fill-amber-500/70 text-amber-500/70 dark:fill-amber-400/60 dark:text-amber-400/60'
|
||||
: 'text-amber-600 dark:text-amber-400/80'
|
||||
)}
|
||||
/>
|
||||
{state === 'starred' ? 'Starred on GitHub' : 'Star on GitHub'}
|
||||
</button>
|
||||
{state === 'starred' && menuOpen && (
|
||||
<div className="absolute right-0 top-[calc(100%+4px)] z-10 min-w-[100px] rounded-md border border-border bg-popover py-1 shadow-md">
|
||||
<button
|
||||
className="w-full px-3 py-1.5 text-left text-[13px] text-foreground hover:bg-muted"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
setState('hidden')
|
||||
}}
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
|
||||
type AgentStepProps = {
|
||||
selectedAgent: TuiAgent | null
|
||||
onSelect: (agent: TuiAgent) => void
|
||||
detectedSet: Set<TuiAgent>
|
||||
isDetecting: boolean
|
||||
}
|
||||
|
||||
export function AgentStep({
|
||||
selectedAgent,
|
||||
onSelect,
|
||||
detectedSet,
|
||||
isDetecting
|
||||
}: AgentStepProps) {
|
||||
const detected = AGENT_CATALOG.filter((agent) => detectedSet.has(agent.id))
|
||||
const rest = AGENT_CATALOG.filter((agent) => !detectedSet.has(agent.id))
|
||||
const hasDetected = detected.length > 0
|
||||
const primary = hasDetected ? detected : AGENT_CATALOG.slice(0, 6)
|
||||
const fallbackRest = hasDetected ? rest : AGENT_CATALOG.slice(6)
|
||||
const selectedEntry =
|
||||
selectedAgent && !detectedSet.has(selectedAgent)
|
||||
? AGENT_CATALOG.find((a) => a.id === selectedAgent)
|
||||
: undefined
|
||||
// Why: keep the collapsed bucket open when the selected agent lives there, so
|
||||
// the active card is visible without forcing the user to expand the disclosure.
|
||||
const selectedEntryIsCollapsed =
|
||||
selectedAgent != null && fallbackRest.some((a) => a.id === selectedAgent)
|
||||
// Why: one-way latch — auto-open when selection lands in the fallback bucket,
|
||||
// but never force-close. The user can freely toggle via the native <details>
|
||||
// disclosure once it's open; controlling `open` directly off the prop would
|
||||
// slam it shut as soon as `selectedEntryIsCollapsed` flips back to false.
|
||||
const [openState, setOpenState] = useState(selectedEntryIsCollapsed)
|
||||
useEffect(() => {
|
||||
if (selectedEntryIsCollapsed) {
|
||||
setOpenState(true)
|
||||
}
|
||||
}, [selectedEntryIsCollapsed])
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{!hasDetected && !isDetecting && (
|
||||
<div className="rounded-lg border border-amber-400/30 bg-amber-400/10 px-4 py-3 text-xs text-amber-700 dark:text-amber-200/90">
|
||||
No agents detected on your PATH. Pick one to install later, or continue with a blank
|
||||
terminal.
|
||||
</div>
|
||||
)}
|
||||
{selectedEntry && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-amber-400/30 bg-amber-400/10 px-4 py-2.5 text-xs text-amber-700 dark:text-amber-200/90">
|
||||
<span>
|
||||
<span className="font-medium">{selectedEntry.label}</span> isn't on your PATH yet —
|
||||
Orca will set it as your default and you can install it any time.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-amber-400/40 bg-amber-400/10 px-2 py-1 font-medium text-amber-800 hover:bg-amber-400/20 dark:text-amber-100"
|
||||
onClick={() => void window.api.shell.openUrl(selectedEntry.homepageUrl)}
|
||||
>
|
||||
Install instructions
|
||||
<ExternalLink className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<section className="space-y-3">
|
||||
<SectionHeader
|
||||
label={hasDetected ? 'Detected on your system' : 'Popular agents'}
|
||||
count={primary.length}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2.5 md:grid-cols-3">
|
||||
{primary.map((agent) => (
|
||||
<AgentButton
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
selected={selectedAgent === agent.id}
|
||||
onClick={() => onSelect(agent.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{fallbackRest.length > 0 && (
|
||||
<details
|
||||
className="group space-y-3"
|
||||
open={openState}
|
||||
onToggle={(e) => setOpenState(e.currentTarget.open)}
|
||||
>
|
||||
<summary className="cursor-pointer list-none text-xs font-medium text-muted-foreground hover:text-foreground group-open:mb-3">
|
||||
Show {fallbackRest.length} more {hasDetected ? 'agents' : ''}→
|
||||
</summary>
|
||||
<div className="grid grid-cols-2 gap-2.5 md:grid-cols-3">
|
||||
{fallbackRest.map((agent) => (
|
||||
<AgentButton
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
selected={selectedAgent === agent.id}
|
||||
onClick={() => onSelect(agent.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader({ label, count }: { label: string; count: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
<span>{label}</span>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span className="tabular-nums text-muted-foreground">{count}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentButton({
|
||||
agent,
|
||||
selected,
|
||||
onClick
|
||||
}: {
|
||||
agent: (typeof AGENT_CATALOG)[number]
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
'group relative overflow-hidden rounded-xl border p-3.5 text-left transition-all',
|
||||
selected
|
||||
? 'border-foreground/50 bg-muted ring-2 ring-foreground/20'
|
||||
: 'border-border bg-muted/30 hover:bg-muted/60'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-muted text-foreground">
|
||||
<AgentIcon agent={agent.id} size={16} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-foreground">{agent.label}</div>
|
||||
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground">
|
||||
{agent.cmd}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Why: wizard uses positive framing ("notify when focused"); persisted
|
||||
// setting stays `suppressWhenFocused` and is inverted at the boundary.
|
||||
export type NotificationDraft = {
|
||||
agentTaskComplete: boolean
|
||||
terminalBell: boolean
|
||||
notifyWhenFocused: boolean
|
||||
}
|
||||
|
||||
type NotificationStepProps = {
|
||||
value: NotificationDraft
|
||||
onChange: (value: NotificationDraft) => void
|
||||
}
|
||||
|
||||
export function NotificationStep({ value, onChange }: NotificationStepProps) {
|
||||
const rows: { key: keyof NotificationDraft; title: string; description: string }[] = [
|
||||
{
|
||||
key: 'agentTaskComplete',
|
||||
title: 'Agent task complete',
|
||||
description: 'Ping me when an agent finishes its work.'
|
||||
},
|
||||
{
|
||||
key: 'terminalBell',
|
||||
title: 'Terminal bell',
|
||||
description: 'Play a sound when a terminal rings — usually a question waiting on you.'
|
||||
},
|
||||
{
|
||||
key: 'notifyWhenFocused',
|
||||
title: 'Notify even when Orca is focused',
|
||||
description: "Show notifications while you're already in the app."
|
||||
}
|
||||
]
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-muted/20">
|
||||
{rows.map((row, idx) => (
|
||||
<button
|
||||
key={row.key}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={value[row.key]}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between gap-6 px-5 py-4 text-left transition-colors hover:bg-muted/50',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
idx > 0 && 'border-t border-border'
|
||||
)}
|
||||
onClick={() => onChange({ ...value, [row.key]: !value[row.key] })}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">{row.title}</div>
|
||||
<div className="mt-0.5 text-[13px] text-muted-foreground">{row.description}</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'relative h-6 w-11 shrink-0 rounded-full transition-colors',
|
||||
value[row.key] ? 'bg-primary' : 'bg-muted-foreground/40'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute left-0.5 top-0.5 size-5 rounded-full bg-background shadow-sm transition-transform',
|
||||
value[row.key] && 'translate-x-5'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-[13px] text-muted-foreground">
|
||||
Configure other agent status personalization — like custom sounds or pet sidekicks — under{' '}
|
||||
<span className="font-medium text-foreground">Settings → Notifications</span>.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
import { useEffect } from 'react'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isEditableTarget } from '@/lib/editable-target'
|
||||
import type { OnboardingState } from '../../../../shared/types'
|
||||
import { AgentStep } from './AgentStep'
|
||||
import { ThemeStep } from './ThemeStep'
|
||||
import { NotificationStep } from './NotificationStep'
|
||||
import { RepoStep } from './RepoStep'
|
||||
import { STEPS, useOnboardingFlow } from './use-onboarding-flow'
|
||||
import logo from '../../../../../resources/logo.svg'
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
// Why: AGENTS.md mandates `Ctrl+Enter` style on non-Mac; bare `Ctrl↵` reads as one glyph.
|
||||
const enterLabel = isMac ? '⌘↵' : 'Ctrl+Enter'
|
||||
|
||||
const stepCopy = {
|
||||
agent: {
|
||||
title: 'Pick your default agent',
|
||||
subtitle:
|
||||
"Orca works with every CLI agent. Choose the one you'll reach for most — switch any time."
|
||||
},
|
||||
theme: {
|
||||
title: 'Make it feel like home',
|
||||
subtitle: 'Pick the look you want to stare at for hours.'
|
||||
},
|
||||
notifications: {
|
||||
title: 'Know when an agent needs you',
|
||||
subtitle: 'Get a desktop notification when your agent finishes or asks a question.'
|
||||
},
|
||||
repo: {
|
||||
title: 'Point Orca at some code',
|
||||
subtitle: 'Open a folder, clone a repo, or skip and add one later.'
|
||||
}
|
||||
} as const
|
||||
|
||||
type OnboardingFlowProps = {
|
||||
onboarding: OnboardingState
|
||||
onOnboardingChange: (state: OnboardingState) => void
|
||||
}
|
||||
|
||||
export default function OnboardingFlow({
|
||||
onboarding,
|
||||
onOnboardingChange
|
||||
}: OnboardingFlowProps): React.JSX.Element {
|
||||
const flow = useOnboardingFlow(onboarding, onOnboardingChange)
|
||||
const { currentStep, stepIndex, busyLabel } = flow
|
||||
const copy = stepCopy[currentStep.id]
|
||||
// Why: depend on stable callbacks + step id only so the listener doesn't
|
||||
// re-bind on every render of the parent (flow object identity changes).
|
||||
const { next: flowNext, openFolder: flowOpenFolder } = flow
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
// Why: don't hijack Enter / Cmd+Enter while the user is typing into the
|
||||
// clone-URL input or any other editable field on a step.
|
||||
if (isEditableTarget(event.target)) {
|
||||
return
|
||||
}
|
||||
const mod = isMac ? event.metaKey : event.ctrlKey
|
||||
if (!mod || event.key !== 'Enter') {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (currentStep.id === 'repo') {
|
||||
void flowOpenFolder()
|
||||
} else {
|
||||
void flowNext()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
}, [currentStep.id, flowNext, flowOpenFolder])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] overflow-auto bg-background text-foreground">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 opacity-70 dark:opacity-70"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(60% 50% at 20% 0%, color-mix(in srgb, var(--foreground) 6%, transparent) 0%, transparent 60%), radial-gradient(45% 40% at 90% 100%, color-mix(in srgb, var(--foreground) 4%, transparent) 0%, transparent 60%)'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 h-12"
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
/>
|
||||
|
||||
<div className="relative mx-auto flex min-h-screen w-full max-w-[820px] flex-col px-8 pb-10 pt-16">
|
||||
<div className="flex items-center gap-2.5 text-sm font-semibold tracking-tight">
|
||||
<div
|
||||
className="flex size-7 items-center justify-center rounded-md"
|
||||
style={{ backgroundColor: '#12181e' }}
|
||||
>
|
||||
<img src={logo} alt="Orca logo" className="size-5" />
|
||||
</div>
|
||||
<span>Orca</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex items-center gap-2">
|
||||
{STEPS.map((step, idx) => {
|
||||
const isActive = idx === stepIndex
|
||||
const isDone = idx < stepIndex
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className={cn(
|
||||
'h-1 rounded-full transition-all duration-300',
|
||||
isActive
|
||||
? 'w-10 bg-foreground'
|
||||
: isDone
|
||||
? 'w-6 bg-muted-foreground/70'
|
||||
: 'w-6 bg-muted'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<span className="ml-3 text-xs font-medium text-muted-foreground">
|
||||
{stepIndex + 1} of {STEPS.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{stepIndex === 0 && (
|
||||
<div className="mb-2 text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Welcome to Orca
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-[34px] font-semibold leading-[1.15] tracking-tight text-foreground">
|
||||
{copy.title}
|
||||
</h1>
|
||||
<p className="mt-3 max-w-[58ch] text-[15px] leading-relaxed text-muted-foreground">
|
||||
{copy.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex-1">
|
||||
{currentStep.id === 'agent' && (
|
||||
<AgentStep
|
||||
selectedAgent={flow.selectedAgent}
|
||||
onSelect={flow.setSelectedAgent}
|
||||
detectedSet={flow.detectedSet}
|
||||
isDetecting={flow.isDetectingAgents}
|
||||
/>
|
||||
)}
|
||||
{currentStep.id === 'theme' && (
|
||||
<ThemeStep
|
||||
theme={flow.theme}
|
||||
onThemeChange={flow.setTheme}
|
||||
settings={flow.settings}
|
||||
updateSettings={flow.updateSettings}
|
||||
/>
|
||||
)}
|
||||
{currentStep.id === 'notifications' && (
|
||||
<NotificationStep value={flow.notifications} onChange={flow.setNotifications} />
|
||||
)}
|
||||
{currentStep.id === 'repo' && (
|
||||
<RepoStep
|
||||
cloneUrl={flow.cloneUrl}
|
||||
onCloneUrlChange={flow.setCloneUrl}
|
||||
onOpenFolder={() => void flow.openFolder()}
|
||||
onClone={() => void flow.clone()}
|
||||
workspaceDir={flow.settings?.workspaceDir ?? ''}
|
||||
busyLabel={flow.busyLabel}
|
||||
error={flow.error}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="mt-10 flex items-center justify-between border-t border-border pt-5">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<kbd className="rounded-md border border-border bg-muted/60 px-1.5 py-0.5 font-mono text-[11px] text-foreground">
|
||||
{enterLabel}
|
||||
</kbd>
|
||||
<span>{currentStep.id === 'repo' ? 'open folder' : 'continue'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className={
|
||||
currentStep.id === 'repo'
|
||||
? 'rounded-md border border-foreground/20 bg-muted px-3 py-2 text-sm font-medium text-foreground hover:bg-muted-foreground/10'
|
||||
: 'rounded-md px-3 py-2 text-sm text-muted-foreground hover:text-foreground'
|
||||
}
|
||||
onClick={() => void flow.skip()}
|
||||
>
|
||||
{currentStep.id === 'repo' ? "I'll add one later" : 'Skip'}
|
||||
</button>
|
||||
{stepIndex > 0 && (
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/60 px-3 py-2 text-sm text-foreground hover:bg-muted disabled:opacity-60"
|
||||
disabled={Boolean(busyLabel)}
|
||||
onClick={flow.back}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
{currentStep.id !== 'repo' && (
|
||||
<button
|
||||
className="rounded-md bg-primary px-5 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-60"
|
||||
disabled={Boolean(busyLabel)}
|
||||
onClick={() => void flow.next()}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import { FolderOpen, GitBranch, Server } from 'lucide-react'
|
||||
|
||||
type RepoStepProps = {
|
||||
cloneUrl: string
|
||||
onCloneUrlChange: (value: string) => void
|
||||
onOpenFolder: () => void
|
||||
onClone: () => void
|
||||
workspaceDir: string
|
||||
busyLabel: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export function RepoStep({
|
||||
cloneUrl,
|
||||
onCloneUrlChange,
|
||||
onOpenFolder,
|
||||
onClone,
|
||||
workspaceDir,
|
||||
busyLabel,
|
||||
error
|
||||
}: RepoStepProps) {
|
||||
const disabled = Boolean(busyLabel)
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center gap-4 rounded-xl border border-border bg-muted/30 p-5 text-left transition hover:border-foreground/40 hover:bg-muted/60 disabled:opacity-60"
|
||||
disabled={disabled}
|
||||
onClick={onOpenFolder}
|
||||
>
|
||||
<div className="grid size-11 shrink-0 place-items-center rounded-lg bg-muted text-foreground">
|
||||
<FolderOpen className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-base font-semibold text-foreground">Open a folder</div>
|
||||
<div className="mt-0.5 text-[13px] text-muted-foreground">
|
||||
Choose any local directory — git repo or not.
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition group-hover:border-foreground/40">
|
||||
Browse…
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<form
|
||||
className="rounded-xl border border-border bg-muted/30 p-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
onClone()
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="grid size-11 shrink-0 place-items-center rounded-lg bg-muted text-foreground">
|
||||
<GitBranch className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-base font-semibold text-foreground">Clone a repo</div>
|
||||
<div className="mt-0.5 text-[13px] text-muted-foreground">
|
||||
Paste an HTTPS or SSH URL.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm text-foreground outline-none transition focus:border-foreground/50 focus:ring-2 focus:ring-foreground/15"
|
||||
placeholder="git@github.com:org/repo.git"
|
||||
value={cloneUrl}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onCloneUrlChange(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 rounded-lg bg-primary px-5 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-40"
|
||||
disabled={!cloneUrl.trim() || disabled}
|
||||
>
|
||||
Clone
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-1 pt-1 text-xs text-muted-foreground">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span>Workspace</span>
|
||||
<span className="truncate font-mono text-foreground">{workspaceDir}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Server className="size-3.5" />
|
||||
<span>SSH? Set hosts up in Settings</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{busyLabel && (
|
||||
<div className="rounded-lg border border-blue-400/30 bg-blue-400/10 px-4 py-2.5 text-sm text-blue-700 dark:text-blue-200">
|
||||
{busyLabel}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-400/30 bg-red-400/10 px-4 py-2.5 text-sm text-red-700 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Check, Monitor, Moon, Settings2, Sun } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { GhosttyImportPreview, GlobalSettings } from '../../../../shared/types'
|
||||
import ghosttyIcon from '../../../../../resources/ghostty.svg'
|
||||
|
||||
type ThemeStepProps = {
|
||||
theme: GlobalSettings['theme']
|
||||
onThemeChange: (theme: GlobalSettings['theme']) => void
|
||||
settings: GlobalSettings | null
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void>
|
||||
}
|
||||
|
||||
type DiscoveryState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'detecting' }
|
||||
| { status: 'found'; preview: GhosttyImportPreview; fields: string[] }
|
||||
| { status: 'imported'; fields: string[] }
|
||||
| { status: 'absent' }
|
||||
|
||||
export function ThemeStep({ theme, onThemeChange, settings, updateSettings }: ThemeStepProps) {
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [discovery, setDiscovery] = useState<DiscoveryState>({ status: 'idle' })
|
||||
|
||||
// Why: read-only IPC. Auto-detect on step mount so the user sees a clear
|
||||
// "we found your Ghostty config" prompt instead of a buried Import button.
|
||||
// Settings are not applied until the user clicks Import (per design doc).
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setDiscovery({ status: 'detecting' })
|
||||
void window.api.settings
|
||||
.previewGhosttyImport()
|
||||
.then((preview) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
// Why: hide the row when there's nothing to import. An empty diff can
|
||||
// mean "settings already match" *or* "every key in the config was
|
||||
// unsupported by the mapper" (e.g. theme = some-named-theme); we can't
|
||||
// tell, so don't make a claim either way.
|
||||
if (!preview.found || Object.keys(preview.diff).length === 0) {
|
||||
setDiscovery({ status: 'absent' })
|
||||
return
|
||||
}
|
||||
setDiscovery({ status: 'found', preview, fields: humanFields(preview.diff) })
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
setDiscovery({ status: 'absent' })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const importGhostty = async (preview: GhosttyImportPreview) => {
|
||||
if (!settings || importing) {
|
||||
return
|
||||
}
|
||||
setImporting(true)
|
||||
try {
|
||||
const resolved = preview.found ? preview : await window.api.settings.previewGhosttyImport()
|
||||
if (!resolved.found || Object.keys(resolved.diff).length === 0) {
|
||||
toast.info('No Ghostty settings found to import')
|
||||
return
|
||||
}
|
||||
await updateSettings({
|
||||
...resolved.diff,
|
||||
...(resolved.diff.terminalColorOverrides
|
||||
? {
|
||||
terminalColorOverrides: {
|
||||
...settings.terminalColorOverrides,
|
||||
...resolved.diff.terminalColorOverrides
|
||||
}
|
||||
}
|
||||
: {})
|
||||
})
|
||||
// Why: parent controller holds local `theme` state that overwrites
|
||||
// settings.theme on Continue; sync it so the import isn't clobbered.
|
||||
if (resolved.diff.theme) {
|
||||
onThemeChange(resolved.diff.theme)
|
||||
}
|
||||
setDiscovery({ status: 'imported', fields: humanFields(resolved.diff) })
|
||||
} catch (err) {
|
||||
toast.error('Failed to import Ghostty settings', {
|
||||
description: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const themes: {
|
||||
id: GlobalSettings['theme']
|
||||
label: string
|
||||
hint: string
|
||||
icon: typeof Monitor
|
||||
}[] = [
|
||||
{ id: 'system', label: 'System', hint: 'Match OS', icon: Monitor },
|
||||
{ id: 'dark', label: 'Dark', hint: 'Easy on the eyes', icon: Moon },
|
||||
{ id: 'light', label: 'Light', hint: 'Bright & crisp', icon: Sun }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{themes.map(({ id, label, hint, icon: Icon }) => {
|
||||
const selected = theme === id
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
className={cn(
|
||||
'group overflow-hidden rounded-xl border p-3 text-left transition-all',
|
||||
selected
|
||||
? 'border-violet-500/60 bg-violet-500/10 ring-2 ring-violet-500/30'
|
||||
: 'border-border bg-muted/30 hover:bg-muted/60'
|
||||
)}
|
||||
onClick={() => onThemeChange(id)}
|
||||
>
|
||||
<div className="relative mb-3 h-24 overflow-hidden rounded-lg border border-border">
|
||||
<ChromePreview variant={id} />
|
||||
{selected && (
|
||||
<div className="absolute right-1.5 top-1.5 grid size-5 place-items-center rounded-full bg-violet-500 text-white shadow-sm">
|
||||
<Check className="size-3" strokeWidth={3} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<Icon className="size-3.5 text-muted-foreground" />
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">{hint}</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<GhosttyDiscoveryRow
|
||||
discovery={discovery}
|
||||
importing={importing}
|
||||
disabled={!settings}
|
||||
onImport={importGhostty}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 px-1 text-[12px] text-muted-foreground">
|
||||
<Settings2 className="size-3.5" />
|
||||
<span>
|
||||
More terminal options — font, cursor, palette — in{' '}
|
||||
<span className="font-medium text-foreground">Settings → Terminal</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GhosttyDiscoveryRow({
|
||||
discovery,
|
||||
importing,
|
||||
disabled,
|
||||
onImport
|
||||
}: {
|
||||
discovery: DiscoveryState
|
||||
importing: boolean
|
||||
disabled: boolean
|
||||
onImport: (preview: GhosttyImportPreview) => void
|
||||
}) {
|
||||
if (discovery.status === 'absent') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (discovery.status === 'detecting' || discovery.status === 'idle') {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-lg border border-dashed border-border bg-transparent px-3.5 py-2.5 text-[12px] text-muted-foreground">
|
||||
<span className="size-1.5 animate-pulse rounded-full bg-muted-foreground/60" />
|
||||
Looking for a Ghostty config…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (discovery.status === 'imported') {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-lg border border-emerald-500/30 bg-emerald-500/[0.07] px-3.5 py-2.5 text-[12px] text-foreground">
|
||||
<Check className="size-3.5 text-emerald-600 dark:text-emerald-400" strokeWidth={3} />
|
||||
<span className="flex-1">
|
||||
<span className="font-medium">Imported from Ghostty.</span>
|
||||
{discovery.fields.length > 0 && (
|
||||
<span className="text-muted-foreground"> {discovery.fields.join(' · ')}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { preview, fields } = discovery
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-violet-500/30 bg-violet-500/[0.06] px-3.5 py-2.5">
|
||||
<img src={ghosttyIcon} alt="" className="size-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12px] text-foreground">
|
||||
<span className="font-medium">Ghostty config detected.</span>{' '}
|
||||
<span className="text-muted-foreground">
|
||||
Import {fields.length > 0 ? fields.map((f) => f.toLowerCase()).join(', ') : 'settings'}
|
||||
?
|
||||
</span>
|
||||
</div>
|
||||
{preview.configPath && (
|
||||
<div
|
||||
className="mt-0.5 truncate font-mono text-[10.5px] text-muted-foreground"
|
||||
title={preview.configPath}
|
||||
>
|
||||
{preview.configPath}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 rounded-md bg-foreground px-3 py-1.5 text-[11.5px] font-semibold text-background hover:bg-foreground/90 disabled:opacity-50"
|
||||
disabled={importing || disabled}
|
||||
onClick={() => onImport(preview)}
|
||||
>
|
||||
{importing ? 'Importing…' : 'Import'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChromePreview({ variant }: { variant: GlobalSettings['theme'] }) {
|
||||
if (variant === 'system') {
|
||||
return (
|
||||
<div className="relative size-full">
|
||||
<div className="absolute inset-0" style={{ clipPath: 'polygon(0 0, 50% 0, 50% 100%, 0 100%)' }}>
|
||||
<ChromeMock dark />
|
||||
</div>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ clipPath: 'polygon(50% 0, 100% 0, 100% 100%, 50% 100%)' }}
|
||||
>
|
||||
<ChromeMock dark={false} />
|
||||
</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border/70"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <ChromeMock dark={variant === 'dark'} />
|
||||
}
|
||||
|
||||
function ChromeMock({ dark }: { dark: boolean }) {
|
||||
// Tiny Orca chrome: sidebar with two rows + a content area with a tab and
|
||||
// a composer line. Pure Tailwind so it stays lightweight inside the tile.
|
||||
const bg = dark ? 'bg-[#0f1115]' : 'bg-[#f7f8fa]'
|
||||
const sidebar = dark ? 'bg-[#16181d]' : 'bg-[#eceef2]'
|
||||
const sidebarBorder = dark ? 'border-white/5' : 'border-black/5'
|
||||
const row = dark ? 'bg-white/10' : 'bg-black/10'
|
||||
const rowDim = dark ? 'bg-white/5' : 'bg-black/5'
|
||||
const tab = dark ? 'bg-[#1d2026] border-white/5' : 'bg-white border-black/5'
|
||||
const accent = 'bg-violet-500/80'
|
||||
return (
|
||||
<div className={cn('flex size-full', bg)}>
|
||||
<div className={cn('flex w-[34%] flex-col gap-1 border-r p-1.5', sidebar, sidebarBorder)}>
|
||||
<div className={cn('h-1 w-7 rounded-sm', rowDim)} />
|
||||
<div className="mt-0.5 flex items-center gap-1">
|
||||
<span className={cn('size-1 rounded-full', accent)} />
|
||||
<span className={cn('h-1 flex-1 rounded-sm', row)} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={cn('size-1 rounded-full', rowDim)} />
|
||||
<span className={cn('h-1 flex-1 rounded-sm', rowDim)} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={cn('size-1 rounded-full', rowDim)} />
|
||||
<span className={cn('h-1 w-3/4 rounded-sm', rowDim)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col p-1.5">
|
||||
<div className="flex gap-1">
|
||||
<div className={cn('h-2 w-8 rounded-sm border', tab)} />
|
||||
<div className={cn('h-2 w-5 rounded-sm', rowDim)} />
|
||||
</div>
|
||||
<div className="mt-1.5 flex-1 space-y-1">
|
||||
<div className={cn('h-1 w-full rounded-sm', rowDim)} />
|
||||
<div className={cn('h-1 w-5/6 rounded-sm', rowDim)} />
|
||||
<div className={cn('h-1 w-2/3 rounded-sm', rowDim)} />
|
||||
</div>
|
||||
<div className={cn('mt-1 flex h-2.5 items-center gap-1 rounded-sm border px-1', tab)}>
|
||||
<span className={cn('size-1 rounded-full', accent)} />
|
||||
<span className={cn('h-0.5 flex-1 rounded-sm', rowDim)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function humanFields(diff: Partial<GlobalSettings>): string[] {
|
||||
// Why: chip labels are a friendly summary, not a strict 1:1 of mapper keys.
|
||||
// Group related diff keys (font weight + family + size → "Font") so the row
|
||||
// stays tidy. Anything in the diff that doesn't match a label still gets
|
||||
// imported — it just isn't surfaced as a chip.
|
||||
const groups: { label: string; keys: (keyof GlobalSettings)[] }[] = [
|
||||
{ label: 'Font', keys: ['terminalFontFamily', 'terminalFontSize', 'terminalFontWeight'] },
|
||||
{
|
||||
label: 'Cursor',
|
||||
keys: ['terminalCursorStyle', 'terminalCursorBlink', 'terminalCursorOpacity']
|
||||
},
|
||||
{ label: 'Theme palette', keys: ['terminalThemeDark', 'terminalThemeLight'] },
|
||||
{ label: 'Colors', keys: ['terminalColorOverrides'] },
|
||||
{ label: 'Padding', keys: ['terminalPaddingX', 'terminalPaddingY'] },
|
||||
{
|
||||
label: 'Window',
|
||||
keys: ['terminalBackgroundOpacity', 'windowBackgroundBlur', 'terminalInactivePaneOpacity']
|
||||
},
|
||||
{
|
||||
label: 'Dividers',
|
||||
keys: ['terminalDividerColorDark', 'terminalDividerColorLight']
|
||||
},
|
||||
{ label: 'Mouse', keys: ['terminalMouseHideWhileTyping', 'terminalFocusFollowsMouse'] },
|
||||
{ label: 'macOS Option key', keys: ['terminalMacOptionAsAlt'] }
|
||||
]
|
||||
return groups
|
||||
.filter(({ keys }) => keys.some((k) => k in diff))
|
||||
.map(({ label }) => label)
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { OnboardingState } from '../../../../shared/types'
|
||||
|
||||
// Why: split out so App.tsx can gate the lazy <OnboardingFlow> without an
|
||||
// eager static import path that pulls the whole flow into the main chunk.
|
||||
export function shouldShowOnboarding(onboarding: OnboardingState | null): boolean {
|
||||
return onboarding !== null && onboarding.closedAt === null
|
||||
}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
import { useCallback } from 'react'
|
||||
import { track } from '@/lib/telemetry'
|
||||
import { ONBOARDING_FINAL_STEP } from '../../../../shared/constants'
|
||||
import type { GlobalSettings, OnboardingState, TuiAgent } from '../../../../shared/types'
|
||||
import type { NotificationDraft } from './NotificationStep'
|
||||
import type { StepId, StepNumber } from './use-onboarding-flow-types'
|
||||
|
||||
export async function persistStep(
|
||||
stepNumber: number,
|
||||
updates: Partial<OnboardingState> = {}
|
||||
): Promise<OnboardingState> {
|
||||
return window.api.onboarding.update({
|
||||
lastCompletedStep: Math.max(stepNumber, -1),
|
||||
...updates
|
||||
})
|
||||
}
|
||||
|
||||
function selectedAgentOrBlank(agent: TuiAgent | null): TuiAgent | 'blank' {
|
||||
return agent ?? 'blank'
|
||||
}
|
||||
|
||||
type CloseWithDeps = {
|
||||
onOnboardingChange: (state: OnboardingState) => void
|
||||
onboardingChecklist: OnboardingState['checklist']
|
||||
startTimeRef: { current: number }
|
||||
setError: (msg: string | null) => void
|
||||
}
|
||||
|
||||
export function useCloseWith({
|
||||
onOnboardingChange,
|
||||
onboardingChecklist,
|
||||
startTimeRef,
|
||||
setError
|
||||
}: CloseWithDeps) {
|
||||
return useCallback(
|
||||
async (
|
||||
outcome: 'completed' | 'dismissed',
|
||||
checklist: Partial<OnboardingState['checklist']>,
|
||||
lastStepReached: StepNumber,
|
||||
completedPath?: 'open_folder' | 'clone_url'
|
||||
): Promise<boolean> => {
|
||||
let nextState: OnboardingState
|
||||
try {
|
||||
// Why: main-process updateOnboarding already merges with current state,
|
||||
// so spreading the local (potentially stale) onboarding.checklist would
|
||||
// overwrite concurrent updates.
|
||||
nextState = await window.api.onboarding.update({
|
||||
closedAt: Date.now(),
|
||||
outcome,
|
||||
lastCompletedStep: outcome === 'completed' ? ONBOARDING_FINAL_STEP : -1,
|
||||
checklist: {
|
||||
...checklist,
|
||||
dismissed: outcome === 'dismissed'
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
return false
|
||||
}
|
||||
onOnboardingChange(nextState)
|
||||
if (outcome === 'completed' && completedPath) {
|
||||
const total = Math.max(0, Date.now() - startTimeRef.current)
|
||||
track('onboarding_completed', {
|
||||
path: completedPath,
|
||||
is_git_repo: checklist.addedRepo === true,
|
||||
total_duration_ms: total
|
||||
})
|
||||
// Why: checklist items completed by the wizard itself must fire
|
||||
// `activation_checklist_item_completed` so the post-wizard panel and
|
||||
// analytics agree. Other items (ranFirstAgent, triedCmdJ, …) emit
|
||||
// from their own product surfaces.
|
||||
if (checklist.addedRepo && !onboardingChecklist.addedRepo) {
|
||||
track('activation_checklist_item_completed', {
|
||||
item: 'addedRepo',
|
||||
time_since_completed_ms: 0
|
||||
})
|
||||
}
|
||||
if (checklist.addedFolder && !onboardingChecklist.addedFolder) {
|
||||
track('activation_checklist_item_completed', {
|
||||
item: 'addedFolder',
|
||||
time_since_completed_ms: 0
|
||||
})
|
||||
}
|
||||
} else if (outcome === 'dismissed') {
|
||||
track('onboarding_dismissed', { last_step: lastStepReached })
|
||||
}
|
||||
return true
|
||||
},
|
||||
[onOnboardingChange, onboardingChecklist, startTimeRef, setError]
|
||||
)
|
||||
}
|
||||
|
||||
type PersistCurrentStepDeps = {
|
||||
currentStepId: StepId
|
||||
selectedAgent: TuiAgent | null
|
||||
theme: GlobalSettings['theme']
|
||||
notifications: NotificationDraft
|
||||
settings: GlobalSettings | null
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void> | void
|
||||
onboardingChecklist: OnboardingState['checklist']
|
||||
onOnboardingChange: (state: OnboardingState) => void
|
||||
setError: (msg: string | null) => void
|
||||
}
|
||||
|
||||
export function usePersistCurrentStep({
|
||||
currentStepId,
|
||||
selectedAgent,
|
||||
theme,
|
||||
notifications,
|
||||
settings,
|
||||
updateSettings,
|
||||
onboardingChecklist,
|
||||
onOnboardingChange,
|
||||
setError
|
||||
}: PersistCurrentStepDeps) {
|
||||
return useCallback(async (): Promise<boolean> => {
|
||||
if (!settings) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
if (currentStepId === 'agent') {
|
||||
const defaultTuiAgent = selectedAgentOrBlank(selectedAgent)
|
||||
await updateSettings({ defaultTuiAgent })
|
||||
const choseAgent = defaultTuiAgent !== 'blank'
|
||||
const wasAlreadyChosen = onboardingChecklist.choseAgent
|
||||
onOnboardingChange(
|
||||
await persistStep(1, {
|
||||
checklist: { ...onboardingChecklist, choseAgent }
|
||||
})
|
||||
)
|
||||
if (choseAgent && !wasAlreadyChosen) {
|
||||
track('activation_checklist_item_completed', {
|
||||
item: 'choseAgent',
|
||||
time_since_completed_ms: 0
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (currentStepId === 'theme') {
|
||||
await updateSettings({ theme })
|
||||
onOnboardingChange(await persistStep(2))
|
||||
return true
|
||||
}
|
||||
if (currentStepId === 'notifications') {
|
||||
const enabled = notifications.agentTaskComplete || notifications.terminalBell
|
||||
if (enabled) {
|
||||
// Why: triggers macOS first-prompt notification on first call. Only fire
|
||||
// on Continue; Skip uses the persistence-only path below.
|
||||
await window.api.notifications.requestPermission()
|
||||
}
|
||||
await updateSettings({
|
||||
notifications: {
|
||||
...settings.notifications,
|
||||
enabled,
|
||||
agentTaskComplete: notifications.agentTaskComplete,
|
||||
terminalBell: notifications.terminalBell,
|
||||
// Why: invert positive UX framing back to persisted negative field.
|
||||
suppressWhenFocused: !notifications.notifyWhenFocused
|
||||
}
|
||||
})
|
||||
onOnboardingChange(await persistStep(3))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
return false
|
||||
}
|
||||
}, [
|
||||
currentStepId,
|
||||
notifications,
|
||||
onboardingChecklist,
|
||||
onOnboardingChange,
|
||||
selectedAgent,
|
||||
settings,
|
||||
theme,
|
||||
updateSettings,
|
||||
setError
|
||||
])
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
export type StepNumber = 1 | 2 | 3 | 4
|
||||
export type StepId = 'agent' | 'theme' | 'notifications' | 'repo'
|
||||
|
||||
export const STEPS: readonly {
|
||||
id: StepId
|
||||
stepNumber: StepNumber
|
||||
valueKind: 'agent' | 'theme' | 'notifications' | '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' }
|
||||
]
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { AGENT_CATALOG } from '@/lib/agent-catalog'
|
||||
import { useAppStore } from '@/store'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { applyDocumentTheme } from '@/lib/document-theme'
|
||||
import { track } from '@/lib/telemetry'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import type { GlobalSettings, OnboardingState, TuiAgent } from '../../../../shared/types'
|
||||
import type { NotificationDraft } from './NotificationStep'
|
||||
import { STEPS, type StepNumber } from './use-onboarding-flow-types'
|
||||
import {
|
||||
persistStep,
|
||||
useCloseWith,
|
||||
usePersistCurrentStep
|
||||
} from './use-onboarding-flow-persistence'
|
||||
|
||||
export { STEPS } from './use-onboarding-flow-types'
|
||||
export type { StepId, StepNumber } from './use-onboarding-flow-types'
|
||||
|
||||
export type OnboardingFlowController = ReturnType<typeof useOnboardingFlow>
|
||||
|
||||
export function useOnboardingFlow(
|
||||
onboarding: OnboardingState,
|
||||
onOnboardingChange: (state: OnboardingState) => void
|
||||
) {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const refreshDetectedAgents = useAppStore((s) => s.refreshDetectedAgents)
|
||||
const detectedAgentIds = useAppStore((s) => s.detectedAgentIds)
|
||||
const isDetectingAgents = useAppStore(
|
||||
(s) => s.isDetectingAgents || s.isRefreshingAgents
|
||||
)
|
||||
const fetchRepos = useAppStore((s) => s.fetchRepos)
|
||||
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
|
||||
const initialStep = Math.min(Math.max(onboarding.lastCompletedStep, 0), STEPS.length - 1)
|
||||
const [stepIndex, setStepIndex] = useState(initialStep)
|
||||
const [selectedAgent, setSelectedAgent] = useState<TuiAgent | null>(
|
||||
settings?.defaultTuiAgent && settings.defaultTuiAgent !== 'blank'
|
||||
? settings.defaultTuiAgent
|
||||
: null
|
||||
)
|
||||
// Why: hydrate theme from saved settings instead of hardcoding 'dark' so users
|
||||
// who already configured a theme see their choice preselected.
|
||||
const [theme, setTheme] = useState<GlobalSettings['theme']>(settings?.theme ?? 'dark')
|
||||
// Why: wizard force-defaults every toggle on (ignoring stored settings) so
|
||||
// first-run users land in the most attentive state and choose what to dial
|
||||
// back. Positive framing ("Notify when focused") inverts back to the
|
||||
// persisted `suppressWhenFocused` field at save time.
|
||||
const [notifications, setNotifications] = useState<NotificationDraft>({
|
||||
agentTaskComplete: true,
|
||||
terminalBell: true,
|
||||
notifyWhenFocused: true
|
||||
})
|
||||
const [cloneUrl, setCloneUrl] = useState('')
|
||||
const [busyLabel, setBusyLabel] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Why: settings load async; the lazy useState initializers above run before
|
||||
// settings hydrates. Re-sync once when settings transitions to non-null,
|
||||
// unless the user has already interacted with that field.
|
||||
const themeInteractedRef = useRef(false)
|
||||
const agentInteractedRef = useRef(false)
|
||||
const settingsHydratedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!settings || settingsHydratedRef.current) {
|
||||
return
|
||||
}
|
||||
settingsHydratedRef.current = true
|
||||
if (!themeInteractedRef.current) {
|
||||
setTheme(settings.theme)
|
||||
}
|
||||
if (!agentInteractedRef.current) {
|
||||
const fromSettings =
|
||||
settings.defaultTuiAgent && settings.defaultTuiAgent !== 'blank'
|
||||
? settings.defaultTuiAgent
|
||||
: null
|
||||
if (fromSettings !== null) {
|
||||
setSelectedAgent(fromSettings)
|
||||
}
|
||||
}
|
||||
}, [settings])
|
||||
|
||||
// Why: track user interaction so async settings hydration above doesn't
|
||||
// overwrite a value the user explicitly chose.
|
||||
const setThemeInteractive = useCallback((value: GlobalSettings['theme']) => {
|
||||
themeInteractedRef.current = true
|
||||
setTheme(value)
|
||||
}, [])
|
||||
const setSelectedAgentInteractive = useCallback((value: TuiAgent | null) => {
|
||||
agentInteractedRef.current = true
|
||||
setSelectedAgent(value)
|
||||
}, [])
|
||||
|
||||
const detectedSet = useMemo(() => new Set(detectedAgentIds ?? []), [detectedAgentIds])
|
||||
const currentStep = STEPS[stepIndex]
|
||||
|
||||
// Why: pin start time once so onboarding_completed reports a real funnel duration.
|
||||
const startTimeRef = useRef<number>(Date.now())
|
||||
|
||||
// Why: track the latest persisted theme in a ref so the unmount-only revert
|
||||
// below uses the freshest value without retriggering on each settings change.
|
||||
const persistedThemeRef = useRef<GlobalSettings['theme']>(settings?.theme ?? 'dark')
|
||||
useEffect(() => {
|
||||
persistedThemeRef.current = settings?.theme ?? 'dark'
|
||||
}, [settings?.theme])
|
||||
|
||||
// Apply preview when local theme changes.
|
||||
useEffect(() => {
|
||||
applyDocumentTheme(theme)
|
||||
}, [theme])
|
||||
|
||||
// Why: the theme step previews on the document before persistence. Revert to
|
||||
// the persisted theme only on wizard unmount so saving (which updates
|
||||
// settings.theme) doesn't trigger a one-frame revert/reapply flicker.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
applyDocumentTheme(persistedThemeRef.current)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Why: ref guard prevents StrictMode's double-invoke from emitting
|
||||
// `onboarding_started` twice on mount.
|
||||
const startedTrackedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (startedTrackedRef.current) {
|
||||
return
|
||||
}
|
||||
startedTrackedRef.current = true
|
||||
// Why: `resumed_from_step` is the step the user finished (1..3), not the
|
||||
// step we resume into.
|
||||
const lastCompleted = onboarding.lastCompletedStep
|
||||
track(
|
||||
'onboarding_started',
|
||||
lastCompleted >= 1 && lastCompleted <= 3
|
||||
? { resumed_from_step: lastCompleted as StepNumber }
|
||||
: {}
|
||||
)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
track('onboarding_step_viewed', { step: currentStep.stepNumber })
|
||||
}, [currentStep.stepNumber])
|
||||
|
||||
// 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)
|
||||
const selectedAgentRef = useRef(selectedAgent)
|
||||
useEffect(() => {
|
||||
selectedAgentRef.current = selectedAgent
|
||||
}, [selectedAgent])
|
||||
useEffect(() => {
|
||||
if (didAutoSelectRef.current) {
|
||||
return
|
||||
}
|
||||
didAutoSelectRef.current = true
|
||||
// Why: re-read PATH on wizard mount instead of reusing the session cache.
|
||||
// The cache can be poisoned if a prior caller ran before shell PATH
|
||||
// hydration finished, leaving the wizard with a false "no agents" state.
|
||||
void refreshDetectedAgents().then((ids) => {
|
||||
if (selectedAgentRef.current !== null) {
|
||||
return
|
||||
}
|
||||
const preferred = AGENT_CATALOG.find((agent) => ids.includes(agent.id))?.id ?? null
|
||||
setSelectedAgent(preferred)
|
||||
})
|
||||
}, [refreshDetectedAgents])
|
||||
|
||||
const closeWith = useCloseWith({
|
||||
onOnboardingChange,
|
||||
onboardingChecklist: onboarding.checklist,
|
||||
startTimeRef,
|
||||
setError
|
||||
})
|
||||
|
||||
const completeRepo = useCallback(
|
||||
async (repoId: string, isGit: boolean, path: 'open_folder' | 'clone_url') => {
|
||||
await fetchRepos()
|
||||
await fetchWorktrees(repoId)
|
||||
const worktree = useAppStore.getState().worktreesByRepo[repoId]?.[0]
|
||||
if (worktree) {
|
||||
activateAndRevealWorktree(worktree.id)
|
||||
}
|
||||
// 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.
|
||||
const closed = await closeWith(
|
||||
'completed',
|
||||
isGit ? { addedRepo: true } : { addedFolder: true },
|
||||
4,
|
||||
path
|
||||
)
|
||||
if (!closed) {
|
||||
return
|
||||
}
|
||||
track('onboarding_step_completed', { step: 4, value_kind: 'repo' })
|
||||
if (isGit) {
|
||||
openModal('new-workspace-composer', {
|
||||
initialRepoId: repoId,
|
||||
prefilledName: 'onboarding',
|
||||
telemetrySource: 'onboarding'
|
||||
})
|
||||
}
|
||||
},
|
||||
[closeWith, fetchRepos, fetchWorktrees, openModal]
|
||||
)
|
||||
|
||||
const persistCurrentStep = usePersistCurrentStep({
|
||||
currentStepId: currentStep.id,
|
||||
selectedAgent,
|
||||
theme,
|
||||
notifications,
|
||||
settings,
|
||||
updateSettings,
|
||||
onboardingChecklist: onboarding.checklist,
|
||||
onOnboardingChange,
|
||||
setError
|
||||
})
|
||||
|
||||
const next = useCallback(async () => {
|
||||
if (busyLabel || currentStep.id === 'repo') {
|
||||
return
|
||||
}
|
||||
const ok = await persistCurrentStep()
|
||||
if (ok) {
|
||||
track('onboarding_step_completed', {
|
||||
step: currentStep.stepNumber,
|
||||
value_kind: currentStep.valueKind
|
||||
})
|
||||
setStepIndex((idx) => Math.min(idx + 1, STEPS.length - 1))
|
||||
}
|
||||
}, [busyLabel, currentStep.id, currentStep.stepNumber, currentStep.valueKind, persistCurrentStep])
|
||||
|
||||
const openFolder = useCallback(async () => {
|
||||
// Why: re-entry guard — rapid Cmd+Enter must not launch duplicate pickers.
|
||||
if (busyLabel !== null) {
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
track('onboarding_step4_path_clicked', { path: 'open_folder' })
|
||||
const path = await window.api.repos.pickFolder()
|
||||
if (!path) {
|
||||
track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'cancelled' })
|
||||
return
|
||||
}
|
||||
setBusyLabel('Opening project…')
|
||||
try {
|
||||
let result = await window.api.repos.add({ path })
|
||||
if ('error' in result && result.error.includes('Not a valid git repository')) {
|
||||
result = await window.api.repos.add({ path, kind: 'folder' })
|
||||
}
|
||||
if ('error' in result) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
await completeRepo(result.repo.id, isGitRepoKind(result.repo), 'open_folder')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' })
|
||||
} finally {
|
||||
setBusyLabel(null)
|
||||
}
|
||||
}, [busyLabel, completeRepo])
|
||||
|
||||
const clone = useCallback(async () => {
|
||||
// Why: re-entry guard — prevents Enter spamming from triggering duplicate clones.
|
||||
if (busyLabel !== null) {
|
||||
return
|
||||
}
|
||||
const trimmed = cloneUrl.trim()
|
||||
if (!trimmed || !settings) {
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
track('onboarding_step4_path_clicked', { path: 'clone_url' })
|
||||
setBusyLabel('Cloning repo…')
|
||||
try {
|
||||
const repo = await window.api.repos.clone({ url: trimmed, destination: settings.workspaceDir })
|
||||
await completeRepo(repo.id, true, 'clone_url')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
track('onboarding_step4_path_failed', { path: 'clone_url', reason: 'clone_failed' })
|
||||
toast.error('Clone failed', {
|
||||
description: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
} finally {
|
||||
setBusyLabel(null)
|
||||
}
|
||||
}, [busyLabel, cloneUrl, completeRepo, settings])
|
||||
|
||||
const skip = useCallback(async () => {
|
||||
if (busyLabel) {
|
||||
return
|
||||
}
|
||||
track('onboarding_step_skipped', { step: currentStep.stepNumber })
|
||||
// Why: theme step previews on the document without persisting. On skip,
|
||||
// revert to the saved theme before advancing so the preview doesn't leak.
|
||||
if (currentStep.id === 'theme' && settings) {
|
||||
setTheme(settings.theme)
|
||||
applyDocumentTheme(settings.theme)
|
||||
}
|
||||
if (currentStep.id === 'repo') {
|
||||
await closeWith('dismissed', {}, currentStep.stepNumber)
|
||||
return
|
||||
}
|
||||
// Why: persistence-only path — does NOT trigger requestPermission, so
|
||||
// skipping step 3 never fires the OS permission prompt.
|
||||
try {
|
||||
onOnboardingChange(await persistStep(currentStep.stepNumber))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
return
|
||||
}
|
||||
setStepIndex((idx) => Math.min(idx + 1, STEPS.length - 1))
|
||||
}, [busyLabel, closeWith, currentStep.id, currentStep.stepNumber, onOnboardingChange, settings])
|
||||
|
||||
const back = useCallback(() => {
|
||||
setStepIndex((idx) => Math.max(idx - 1, 0))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
settings,
|
||||
updateSettings,
|
||||
stepIndex,
|
||||
currentStep,
|
||||
selectedAgent,
|
||||
setSelectedAgent: setSelectedAgentInteractive,
|
||||
theme,
|
||||
setTheme: setThemeInteractive,
|
||||
notifications,
|
||||
setNotifications,
|
||||
cloneUrl,
|
||||
setCloneUrl,
|
||||
busyLabel,
|
||||
error,
|
||||
detectedSet,
|
||||
isDetectingAgents,
|
||||
next,
|
||||
skip,
|
||||
back,
|
||||
openFolder,
|
||||
clone
|
||||
}
|
||||
}
|
||||
|
|
@ -1327,9 +1327,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
// "create" path is the new-workspace surface; request_kind is
|
||||
// `'new'` because this is always a fresh session (issue/PR-driven
|
||||
// follow-ups go through launch-work-item-direct.ts).
|
||||
// Why: when the composer is opened from onboarding, the first
|
||||
// `agent_started` must attribute to `onboarding` so D1 activation
|
||||
// can be measured against the funnel.
|
||||
const composerTelemetry: AgentStartedTelemetry = {
|
||||
agent_kind: tuiAgentToAgentKind(tuiAgent),
|
||||
launch_source: 'new_workspace_composer',
|
||||
launch_source: telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer',
|
||||
request_kind: 'new'
|
||||
}
|
||||
activateAndRevealWorktree(worktree.id, {
|
||||
|
|
@ -1527,7 +1530,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
? null
|
||||
: {
|
||||
agent_kind: tuiAgentToAgentKind(agent),
|
||||
launch_source: 'new_workspace_composer',
|
||||
launch_source:
|
||||
telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer',
|
||||
request_kind: 'new'
|
||||
}
|
||||
activateAndRevealWorktree(worktree.id, {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ export function applyDocumentTheme(
|
|||
}
|
||||
|
||||
root.classList.toggle('dark', shouldUseDarkTheme)
|
||||
// Mirror with `light` so consumers can observe the resolved theme
|
||||
// symmetrically (Tailwind keys only on `dark`, so this is style-neutral).
|
||||
root.classList.toggle('light', !shouldUseDarkTheme)
|
||||
|
||||
if (!disableTransitions) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
// Why: shared across global keyboard listeners (App-level shortcuts and the
|
||||
// onboarding flow) so an in-progress text edit never gets hijacked by a
|
||||
// capture-phase keydown handler.
|
||||
export function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// xterm.js focuses a hidden <textarea class="xterm-helper-textarea"> for
|
||||
// keyboard input. That element IS an editable target, but we must NOT
|
||||
// suppress global shortcuts when the terminal itself is focused — otherwise
|
||||
// Cmd/Ctrl+P and other app-level keybindings become unreachable.
|
||||
if (target.classList.contains('xterm-helper-textarea')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (target.isContentEditable) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]') !==
|
||||
null
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import type {
|
||||
GlobalSettings,
|
||||
NotificationSettings,
|
||||
OnboardingChecklistState,
|
||||
OnboardingState,
|
||||
PersistedState,
|
||||
PersistedUIState,
|
||||
RepoHookSettings,
|
||||
|
|
@ -13,6 +15,10 @@ import { DEFAULT_TERMINAL_FONT_WEIGHT } from './terminal-fonts'
|
|||
export const SCHEMA_VERSION = 1
|
||||
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 ORCA_BROWSER_PARTITION = 'persist:orca-browser'
|
||||
// Why: blank browser tabs must start from an inert guest URL that does not
|
||||
// navigate the privileged main window to about:blank. Renderer and main both
|
||||
|
|
@ -111,6 +117,28 @@ export function getDefaultNotificationSettings(): NotificationSettings {
|
|||
}
|
||||
}
|
||||
|
||||
export function getDefaultOnboardingState(): OnboardingState {
|
||||
return {
|
||||
closedAt: null,
|
||||
outcome: null,
|
||||
lastCompletedStep: -1,
|
||||
checklist: {
|
||||
addedRepo: false,
|
||||
choseAgent: false,
|
||||
ranFirstAgent: false,
|
||||
ranSecondAgentOnSameTask: false,
|
||||
triedCmdJ: false,
|
||||
shapedSidebar: false,
|
||||
reviewedDiff: false,
|
||||
openedPr: false,
|
||||
addedFolder: false,
|
||||
openedFile: false,
|
||||
ranAgentOnFile: false,
|
||||
dismissed: false
|
||||
} satisfies OnboardingChecklistState
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultSettings(homedir: string): GlobalSettings {
|
||||
return {
|
||||
workspaceDir: `${homedir}/orca/workspaces`,
|
||||
|
|
@ -240,7 +268,8 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
|
|||
ui: getDefaultUIState(),
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
workspaceSession: getDefaultWorkspaceSession(),
|
||||
sshTargets: []
|
||||
sshTargets: [],
|
||||
onboarding: getDefaultOnboardingState()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@
|
|||
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { GlobalSettings } from './types'
|
||||
import { ONBOARDING_FINAL_STEP } from './constants'
|
||||
import type { GlobalSettings, OnboardingChecklistState } from './types'
|
||||
|
||||
// ── Shared property enums ───────────────────────────────────────────────
|
||||
|
||||
|
|
@ -108,6 +109,7 @@ export const workspaceSourceSchema = z.enum([
|
|||
'sidebar',
|
||||
'shortcut',
|
||||
'drag_drop',
|
||||
'onboarding',
|
||||
'unknown'
|
||||
])
|
||||
export type WorkspaceSource = z.infer<typeof workspaceSourceSchema>
|
||||
|
|
@ -120,6 +122,7 @@ export const launchSourceSchema = z.enum([
|
|||
'new_workspace_composer',
|
||||
'workspace_jump_palette',
|
||||
'shortcut',
|
||||
'onboarding',
|
||||
'diff_notes_send',
|
||||
'unknown'
|
||||
])
|
||||
|
|
@ -246,6 +249,86 @@ const workspaceCreateFailedSchema = z
|
|||
})
|
||||
.strict()
|
||||
|
||||
// ── Onboarding ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Closed enums only — no raw paths, repo names, clone URLs, or error
|
||||
// strings. The funnel exists to measure activation, not to debug specific
|
||||
// user repos.
|
||||
// Why: bound is derived from ONBOARDING_FINAL_STEP so adding a wizard step
|
||||
// only requires bumping the constant. Zod can't build a literal-union from a
|
||||
// numeric constant without runtime gymnastics, so we use a clamped int range.
|
||||
const onboardingStepSchema = z.number().int().min(1).max(ONBOARDING_FINAL_STEP)
|
||||
const onboardingPathSchema = z.enum(['open_folder', 'clone_url'])
|
||||
const onboardingFailureReasonSchema = z.enum([
|
||||
'invalid_path',
|
||||
'clone_failed',
|
||||
'cancelled',
|
||||
'unknown'
|
||||
])
|
||||
const onboardingValueKindSchema = z.enum(['agent', 'theme', 'notifications', 'repo'])
|
||||
// `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
|
||||
// the activation keys of `OnboardingChecklistState` in shared/types.ts.
|
||||
const onboardingChecklistItemSchema = z.enum([
|
||||
'addedRepo',
|
||||
'addedFolder',
|
||||
'choseAgent',
|
||||
'ranFirstAgent',
|
||||
'ranSecondAgentOnSameTask',
|
||||
'triedCmdJ',
|
||||
'shapedSidebar',
|
||||
'reviewedDiff',
|
||||
'openedPr',
|
||||
'openedFile',
|
||||
'ranAgentOnFile'
|
||||
])
|
||||
|
||||
// Why: compile-time guard that the enum above stays in lockstep with the
|
||||
// activation keys of OnboardingChecklistState (everything except the UI-only
|
||||
// `dismissed` flag). Adding/removing a checklist key without updating this
|
||||
// schema breaks the build here rather than silently dropping telemetry.
|
||||
type _OnboardingChecklistItemSync =
|
||||
z.infer<typeof onboardingChecklistItemSchema> extends Exclude<
|
||||
keyof OnboardingChecklistState,
|
||||
'dismissed'
|
||||
>
|
||||
? Exclude<keyof OnboardingChecklistState, 'dismissed'> extends z.infer<
|
||||
typeof onboardingChecklistItemSchema
|
||||
>
|
||||
? true
|
||||
: never
|
||||
: never
|
||||
const _onboardingChecklistItemSyncCheck: _OnboardingChecklistItemSync = true
|
||||
void _onboardingChecklistItemSyncCheck
|
||||
|
||||
const onboardingStartedSchema = z
|
||||
.object({ resumed_from_step: onboardingStepSchema.optional() })
|
||||
.strict()
|
||||
const onboardingStepViewedSchema = z.object({ step: onboardingStepSchema }).strict()
|
||||
const onboardingStepCompletedSchema = z
|
||||
.object({ step: onboardingStepSchema, value_kind: onboardingValueKindSchema })
|
||||
.strict()
|
||||
const onboardingStepSkippedSchema = z.object({ step: onboardingStepSchema }).strict()
|
||||
const onboardingStep4PathClickedSchema = z.object({ path: onboardingPathSchema }).strict()
|
||||
const onboardingStep4PathFailedSchema = z
|
||||
.object({ path: onboardingPathSchema, reason: onboardingFailureReasonSchema })
|
||||
.strict()
|
||||
const onboardingCompletedSchema = z
|
||||
.object({
|
||||
path: onboardingPathSchema,
|
||||
is_git_repo: z.boolean(),
|
||||
total_duration_ms: z.number().int().nonnegative()
|
||||
})
|
||||
.strict()
|
||||
const onboardingDismissedSchema = z.object({ last_step: onboardingStepSchema }).strict()
|
||||
const activationChecklistItemCompletedSchema = z
|
||||
.object({
|
||||
item: onboardingChecklistItemSchema,
|
||||
time_since_completed_ms: z.number().int().nonnegative()
|
||||
})
|
||||
.strict()
|
||||
|
||||
// ── Event registry: the one record the validator consumes ───────────────
|
||||
//
|
||||
// The validator does `eventSchemas[name].safeParse(props)`. `EventMap` is
|
||||
|
|
@ -273,7 +356,17 @@ export const eventSchemas = {
|
|||
settings_changed: settingsChangedSchema,
|
||||
|
||||
telemetry_opted_in: telemetryOptedInSchema,
|
||||
telemetry_opted_out: telemetryOptedOutSchema
|
||||
telemetry_opted_out: telemetryOptedOutSchema,
|
||||
|
||||
onboarding_started: onboardingStartedSchema,
|
||||
onboarding_step_viewed: onboardingStepViewedSchema,
|
||||
onboarding_step_completed: onboardingStepCompletedSchema,
|
||||
onboarding_step_skipped: onboardingStepSkippedSchema,
|
||||
onboarding_step4_path_clicked: onboardingStep4PathClickedSchema,
|
||||
onboarding_step4_path_failed: onboardingStep4PathFailedSchema,
|
||||
onboarding_completed: onboardingCompletedSchema,
|
||||
onboarding_dismissed: onboardingDismissedSchema,
|
||||
activation_checklist_item_completed: activationChecklistItemCompletedSchema
|
||||
} as const
|
||||
|
||||
export type EventMap = { [N in keyof typeof eventSchemas]: z.infer<(typeof eventSchemas)[N]> }
|
||||
|
|
|
|||
|
|
@ -1316,6 +1316,41 @@ export type NotificationSoundPathResult =
|
|||
| { ok: true; path: string }
|
||||
| { ok: false; reason: 'missing-path' | 'invalid-path' | 'unsupported-type' }
|
||||
|
||||
export type OnboardingOutcome = 'completed' | 'dismissed'
|
||||
|
||||
export type OnboardingChecklistState = {
|
||||
addedRepo: boolean
|
||||
choseAgent: boolean
|
||||
ranFirstAgent: boolean
|
||||
ranSecondAgentOnSameTask: boolean
|
||||
triedCmdJ: boolean
|
||||
shapedSidebar: boolean
|
||||
reviewedDiff: boolean
|
||||
openedPr: boolean
|
||||
addedFolder: boolean
|
||||
openedFile: boolean
|
||||
ranAgentOnFile: boolean
|
||||
// Why: UI state flag (panel visibility), not an activation event. The
|
||||
// telemetry checklist enum in telemetry-events.ts intentionally omits this.
|
||||
dismissed: boolean
|
||||
}
|
||||
|
||||
export type OnboardingState = {
|
||||
closedAt: number | null
|
||||
outcome: OnboardingOutcome | null
|
||||
// Sentinel `-1` = not started; `1..4` = highest wizard step the user
|
||||
// finished. Kept as `number` (not a literal union) because callers clamp
|
||||
// via `Math.max`/`Math.min` against arbitrary numerics.
|
||||
lastCompletedStep: number
|
||||
checklist: OnboardingChecklistState
|
||||
}
|
||||
|
||||
export type NotificationPermissionStatusResult = {
|
||||
supported: boolean
|
||||
platform: NodeJS.Platform
|
||||
requested: boolean
|
||||
}
|
||||
|
||||
export type WorktreeCardProperty =
|
||||
| 'status'
|
||||
| 'unread'
|
||||
|
|
@ -1534,6 +1569,7 @@ export type PersistedState = {
|
|||
}
|
||||
workspaceSession: WorkspaceSessionState
|
||||
sshTargets: SshTarget[]
|
||||
onboarding: OnboardingState
|
||||
}
|
||||
|
||||
// ─── Filesystem ─────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -37,7 +37,29 @@ type OrcaWorkerFixtures = {
|
|||
testRepoPath: string
|
||||
}
|
||||
|
||||
// Why: parse + warn at module scope so a bad ORCA_E2E_SLOWMO_MS value logs once
|
||||
// per worker instead of once per test (otherwise hundreds of lines per CI run).
|
||||
const ORCA_E2E_SLOWMO_MS_RAW = process.env.ORCA_E2E_SLOWMO_MS
|
||||
const ORCA_E2E_SLOWMO_MS = ((): number => {
|
||||
if (ORCA_E2E_SLOWMO_MS_RAW === undefined) {
|
||||
return 0
|
||||
}
|
||||
const parsed = Number(ORCA_E2E_SLOWMO_MS_RAW)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
console.warn(
|
||||
`[orca-e2e] ORCA_E2E_SLOWMO_MS="${ORCA_E2E_SLOWMO_MS_RAW}" is not a number; ignoring (using 0).`
|
||||
)
|
||||
return 0
|
||||
}
|
||||
return Math.max(parsed, 0)
|
||||
})()
|
||||
|
||||
function shouldLaunchHeadful(testInfo: TestInfo): boolean {
|
||||
// Why: ORCA_E2E_FORCE_HEADFUL lets a developer watch any spec in a real
|
||||
// window without retagging it `@headful` or switching projects.
|
||||
if (process.env.ORCA_E2E_FORCE_HEADFUL === '1') {
|
||||
return true
|
||||
}
|
||||
return testInfo.project.metadata.orcaHeadful === true
|
||||
}
|
||||
|
||||
|
|
@ -129,8 +151,26 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
|
|||
// which Node rejects with "bad option" and the process exits immediately.
|
||||
const { ELECTRON_RUN_AS_NODE: _unused, ...cleanEnv } = process.env
|
||||
void _unused
|
||||
// Why: ORCA_E2E_SLOWMO_MS adds a pause between every Playwright action so a
|
||||
// developer running with ORCA_E2E_FORCE_HEADFUL=1 can actually watch what
|
||||
// the test does. Defaults to 0 (no slowdown) for normal runs.
|
||||
const slowMo = ORCA_E2E_SLOWMO_MS
|
||||
// Why: ORCA_E2E_RECORD_VIDEO=1 captures a webm of the renderer so a
|
||||
// developer can replay the run later — Electron's Playwright trace viewer
|
||||
// does not produce DOM snapshots, so video is the only reliable replay.
|
||||
// Why: testInfo.outputDir is created lazily by Playwright; on Windows the
|
||||
// dir may not exist when the fixture initializes, and Electron silently
|
||||
// drops the recording. mkdir up-front so the recorder always has a home.
|
||||
const recordVideoDir = process.env.ORCA_E2E_RECORD_VIDEO === '1'
|
||||
? testInfo.outputDir
|
||||
: null
|
||||
if (recordVideoDir) {
|
||||
mkdirSync(recordVideoDir, { recursive: true })
|
||||
}
|
||||
const app = await electron.launch({
|
||||
args: [mainPath],
|
||||
...(slowMo > 0 ? { slowMo } : {}),
|
||||
...(recordVideoDir ? { recordVideo: { dir: recordVideoDir } } : {}),
|
||||
// Why: keep NODE_ENV=development so window.__store is exposed and
|
||||
// dev-only helpers activate. ORCA_E2E_USER_DATA_DIR overrides the usual
|
||||
// shared dev profile so every spec gets a clean persistence root.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,377 @@
|
|||
/**
|
||||
* E2E tests for the first-launch Onboarding flow.
|
||||
*
|
||||
* The onboarding overlay is gated by `OnboardingState.closedAt === null` (see
|
||||
* `shouldShowOnboarding` in `should-show-onboarding.ts`). Each test gets a fresh
|
||||
* Electron instance + isolated userData dir, so persistence starts clean and
|
||||
* the overlay renders on first paint without any setup.
|
||||
*/
|
||||
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForSessionReady } from './helpers/store'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import type { GlobalSettings, TuiAgent } from '../../src/shared/types'
|
||||
|
||||
type OnboardingState = {
|
||||
closedAt: number | null
|
||||
outcome: 'completed' | 'dismissed' | null
|
||||
lastCompletedStep: number
|
||||
checklist: Record<string, boolean>
|
||||
}
|
||||
|
||||
async function getOnboardingState(page: Page): Promise<OnboardingState> {
|
||||
return page.evaluate(() => window.api.onboarding.get() as Promise<OnboardingState>)
|
||||
}
|
||||
|
||||
async function getSettings(page: Page): Promise<GlobalSettings> {
|
||||
return page.evaluate(() => window.api.settings.get())
|
||||
}
|
||||
|
||||
async function getDocumentThemeClass(page: Page): Promise<'dark' | 'light'> {
|
||||
return page.evaluate(() =>
|
||||
document.documentElement.classList.contains('dark') ? 'dark' : 'light'
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('Onboarding flow', () => {
|
||||
test.beforeEach(async ({ orcaPage }) => {
|
||||
// Per-test userData is freshly minted by the orcaPage fixture, so persisted
|
||||
// onboarding state defaults to `closedAt: null, lastCompletedStep: -1` and
|
||||
// the overlay paints on its own once App's bootstrap effect resolves.
|
||||
await waitForSessionReady(orcaPage)
|
||||
})
|
||||
|
||||
test('renders on first launch with the agent step active', async ({ orcaPage }) => {
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Pick your default agent/i })
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
await expect(orcaPage.getByText('1 of 4')).toBeVisible()
|
||||
await expect(orcaPage.getByRole('button', { name: 'Continue' })).toBeVisible()
|
||||
await expect(orcaPage.getByRole('button', { name: 'Skip' })).toBeVisible()
|
||||
// Why: Back is not rendered on the first step (was previously rendered-but-
|
||||
// disabled with `disabled:invisible`, now conditionally mounted).
|
||||
await expect(orcaPage.getByRole('button', { name: 'Back', exact: true })).toHaveCount(0)
|
||||
// Footer hint shows the platform-correct continue shortcut (⌘↵ on Mac,
|
||||
// Ctrl+Enter elsewhere). Match either form so the test runs cross-platform.
|
||||
// Why: scope to the footer's <kbd> element so background UI (e.g. menus or
|
||||
// command palette hints) can't false-positive this assertion.
|
||||
await expect(orcaPage.locator('footer kbd').filter({ hasText: /⌘↵|Ctrl\+Enter/ })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Continue advances steps, persists progress, and applies user-visible settings', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Pick your default agent/i })
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// --- Step 1: agent ---
|
||||
// Force a deterministic, non-default selection so the assertion below
|
||||
// proves the wizard actually wrote the user's choice (not just the
|
||||
// pre-selected detected agent). Codex sits in the top-6 catalog when no
|
||||
// agents are detected, otherwise behind the "Show N more agents" details
|
||||
// expander — open it if codex isn't visible.
|
||||
const targetAgent: TuiAgent = 'codex'
|
||||
const codexButton = orcaPage.getByRole('button', { name: /^Codex\s/ })
|
||||
// Why: isVisible() is a one-shot probe — on slow renderer paint it would
|
||||
// race the wizard mount and falsely take the "show more agents" branch.
|
||||
// waitFor with a small timeout actually retries until the button paints.
|
||||
const codexVisible = await codexButton
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 1_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!codexVisible) {
|
||||
await orcaPage.getByText(/Show \d+ more agents/).click()
|
||||
}
|
||||
await codexButton.click()
|
||||
|
||||
await orcaPage.getByRole('button', { name: 'Continue' }).click()
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Make it feel like home/i })
|
||||
).toBeVisible()
|
||||
await expect(orcaPage.getByText('2 of 4')).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
|
||||
timeout: 5_000,
|
||||
message: 'lastCompletedStep did not advance to 1 after first Continue'
|
||||
})
|
||||
.toBe(1)
|
||||
// The agent choice must be persisted to settings (the user will see this
|
||||
// pre-selected when they later open a new tab / agent picker).
|
||||
await expect
|
||||
.poll(async () => (await getSettings(orcaPage)).defaultTuiAgent, { timeout: 5_000 })
|
||||
.toBe(targetAgent)
|
||||
|
||||
// --- Step 2: theme ---
|
||||
// Default settings.theme is 'system', so the document class can resolve to
|
||||
// either 'dark' or 'light' depending on the host. Click the opposite tile
|
||||
// so we always observe a live flip — the assertion that proves the wizard
|
||||
// applies the choice immediately, not just on Continue.
|
||||
// Why: 'system' resolves async on mount, so wait for the class to settle
|
||||
// before snapshotting — otherwise startingTheme can be stale.
|
||||
await orcaPage.waitForFunction(
|
||||
() =>
|
||||
document.documentElement.classList.contains('dark') ||
|
||||
document.documentElement.classList.contains('light')
|
||||
)
|
||||
const startingTheme = await getDocumentThemeClass(orcaPage)
|
||||
const oppositeTheme: 'dark' | 'light' = startingTheme === 'dark' ? 'light' : 'dark'
|
||||
const oppositeTileName = oppositeTheme === 'light' ? /Bright & crisp/ : /Easy on the eyes/
|
||||
await orcaPage.getByRole('button', { name: oppositeTileName }).click()
|
||||
await expect
|
||||
.poll(async () => getDocumentThemeClass(orcaPage), { timeout: 5_000 })
|
||||
.toBe(oppositeTheme)
|
||||
|
||||
await orcaPage.getByRole('button', { name: 'Continue' }).click()
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Know when an agent needs you/i })
|
||||
).toBeVisible()
|
||||
await expect(orcaPage.getByText('3 of 4')).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
|
||||
timeout: 5_000,
|
||||
message: 'lastCompletedStep did not advance to 2 after second Continue'
|
||||
})
|
||||
.toBe(2)
|
||||
await expect.poll(async () => (await getSettings(orcaPage)).theme, { timeout: 5_000 }).toBe(
|
||||
oppositeTheme
|
||||
)
|
||||
|
||||
// --- Step 3: notifications ---
|
||||
// Why: the wizard force-defaults every toggle ON (use-onboarding-flow.ts),
|
||||
// which intentionally diverges from the app defaults (terminalBell=false,
|
||||
// suppressWhenFocused=true). Click Continue without touching the toggles —
|
||||
// the post-Continue assertion proves the wizard wrote its opt-in defaults
|
||||
// through the IPC boundary, including the inverted suppressWhenFocused.
|
||||
await orcaPage.getByRole('button', { name: 'Continue' }).click()
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Point Orca at some code/i })
|
||||
).toBeVisible()
|
||||
await expect(orcaPage.getByText('4 of 4')).toBeVisible()
|
||||
await expect(orcaPage.getByRole('button', { name: 'Continue' })).toHaveCount(0)
|
||||
await expect(orcaPage.getByRole('button', { name: /I'll add one later/ })).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
|
||||
timeout: 5_000
|
||||
})
|
||||
.toBe(3)
|
||||
|
||||
// Verify all three notification fields landed in settings, including the
|
||||
// inverted suppressWhenFocused boundary (UI: notifyWhenFocused=true →
|
||||
// persisted: suppressWhenFocused=false).
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const s = await getSettings(orcaPage)
|
||||
return {
|
||||
agentTaskComplete: s.notifications.agentTaskComplete,
|
||||
terminalBell: s.notifications.terminalBell,
|
||||
suppressWhenFocused: s.notifications.suppressWhenFocused,
|
||||
enabled: s.notifications.enabled
|
||||
}
|
||||
},
|
||||
{ timeout: 5_000 }
|
||||
)
|
||||
.toEqual({
|
||||
agentTaskComplete: true,
|
||||
terminalBell: true,
|
||||
suppressWhenFocused: false,
|
||||
enabled: true
|
||||
})
|
||||
})
|
||||
|
||||
test('Cmd/Ctrl+Enter advances steps like Continue', async ({ orcaPage }) => {
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Pick your default agent/i })
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// Why: the OS the renderer reports drives whether Cmd or Ctrl is the
|
||||
// accelerator (OnboardingFlow.tsx checks navigator.userAgent).
|
||||
const isMac = await orcaPage.evaluate(() => navigator.userAgent.includes('Mac'))
|
||||
const accelerator = isMac ? 'Meta+Enter' : 'Control+Enter'
|
||||
|
||||
// Why: in headless Linux CI the window-level capture-phase listener can
|
||||
// miss synthetic keyboard events when no element holds focus. Click an
|
||||
// inert area inside the overlay first to anchor focus, then press.
|
||||
await orcaPage.locator('footer').click({ position: { x: 1, y: 1 } })
|
||||
await orcaPage.keyboard.press(accelerator)
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Make it feel like home/i })
|
||||
).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
|
||||
timeout: 5_000
|
||||
})
|
||||
.toBe(1)
|
||||
})
|
||||
|
||||
test('selected agent button reports aria-pressed=true', async ({ orcaPage }) => {
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Pick your default agent/i })
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
const codexButton = orcaPage.getByRole('button', { name: /^Codex\s/ })
|
||||
const codexVisible = await codexButton
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 1_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!codexVisible) {
|
||||
await orcaPage.getByText(/Show \d+ more agents/).click()
|
||||
}
|
||||
await codexButton.click()
|
||||
// Why: AgentButton now sets aria-pressed so screen readers and assistive
|
||||
// tech can announce the selection. Verify the attribute reflects state.
|
||||
await expect(codexButton).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('notification toggles flip independently and persist on Continue', async ({ orcaPage }) => {
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Pick your default agent/i })
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
await orcaPage.getByRole('button', { name: 'Skip' }).click()
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Make it feel like home/i })
|
||||
).toBeVisible()
|
||||
await orcaPage.getByRole('button', { name: 'Skip' }).click()
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Know when an agent needs you/i })
|
||||
).toBeVisible()
|
||||
|
||||
// Why: NotificationStep buttons expose role="switch" + aria-checked. Flip
|
||||
// terminalBell off and verify the toggle reflects + persists. The other
|
||||
// two toggles stay at their wizard-default ON state.
|
||||
const bellSwitch = orcaPage.getByRole('switch', { name: /Terminal bell/i })
|
||||
await expect(bellSwitch).toHaveAttribute('aria-checked', 'true')
|
||||
await bellSwitch.click()
|
||||
await expect(bellSwitch).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
await orcaPage.getByRole('button', { name: 'Continue' }).click()
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Point Orca at some code/i })
|
||||
).toBeVisible()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const s = await getSettings(orcaPage)
|
||||
return {
|
||||
agentTaskComplete: s.notifications.agentTaskComplete,
|
||||
terminalBell: s.notifications.terminalBell
|
||||
}
|
||||
},
|
||||
{ timeout: 5_000 }
|
||||
)
|
||||
.toEqual({ agentTaskComplete: true, terminalBell: false })
|
||||
})
|
||||
|
||||
test('typing in the clone-url input does not hijack Enter as a global shortcut', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Pick your default agent/i })
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
// Skip to the repo step.
|
||||
await orcaPage.getByRole('button', { name: 'Skip' }).click()
|
||||
await orcaPage.getByRole('button', { name: 'Skip' }).click()
|
||||
await orcaPage.getByRole('button', { name: 'Skip' }).click()
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Point Orca at some code/i })
|
||||
).toBeVisible()
|
||||
|
||||
// Why: focus the clone-url input and press Cmd/Ctrl+Enter. The capture-
|
||||
// phase keydown handler should bail via isEditableTarget, so the folder
|
||||
// picker IPC must NOT fire (the heading should remain visible — no
|
||||
// navigation, no opened OS dialog). A bare Enter press also must not
|
||||
// submit the empty form (the Clone button is disabled when blank).
|
||||
const isMac = await orcaPage.evaluate(() => navigator.userAgent.includes('Mac'))
|
||||
const accelerator = isMac ? 'Meta+Enter' : 'Control+Enter'
|
||||
const input = orcaPage.getByPlaceholder('git@github.com:org/repo.git')
|
||||
await input.click()
|
||||
await input.press(accelerator)
|
||||
// Brief wait so any (incorrect) handler firing would have already happened.
|
||||
await orcaPage.waitForTimeout(250)
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Point Orca at some code/i })
|
||||
).toBeVisible()
|
||||
// Onboarding must still be open (closedAt remains null).
|
||||
expect((await getOnboardingState(orcaPage)).closedAt).toBeNull()
|
||||
})
|
||||
|
||||
test('Back returns to the previous step without losing progress', async ({ orcaPage }) => {
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Pick your default agent/i })
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
await orcaPage.getByRole('button', { name: 'Continue' }).click()
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Make it feel like home/i })
|
||||
).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
|
||||
timeout: 5_000
|
||||
})
|
||||
.toBe(1)
|
||||
|
||||
// Why: exact match — the app sidebar also exposes a "Go back" button that
|
||||
// would otherwise match this regex.
|
||||
await orcaPage.getByRole('button', { name: 'Back', exact: true }).click()
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Pick your default agent/i })
|
||||
).toBeVisible()
|
||||
await expect(orcaPage.getByText('1 of 4')).toBeVisible()
|
||||
|
||||
// Why: "without losing progress" means persisted lastCompletedStep stays
|
||||
// at 1 — Back rewinds the visible step but must not roll persistence back.
|
||||
// Poll because persistence flushes async via IPC after the Back click.
|
||||
await expect
|
||||
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
|
||||
timeout: 5_000
|
||||
})
|
||||
.toBe(1)
|
||||
})
|
||||
|
||||
test('"I\'ll add one later" on the repo step dismisses onboarding', async ({ orcaPage }) => {
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Pick your default agent/i })
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// Skip through the first three steps. On steps 1–3 the affordance is
|
||||
// labelled "Skip"; on the repo step it is "I'll add one later".
|
||||
await orcaPage.getByRole('button', { name: 'Skip' }).click()
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Make it feel like home/i })
|
||||
).toBeVisible()
|
||||
await orcaPage.getByRole('button', { name: 'Skip' }).click()
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Know when an agent needs you/i })
|
||||
).toBeVisible()
|
||||
await orcaPage.getByRole('button', { name: 'Skip' }).click()
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Point Orca at some code/i })
|
||||
).toBeVisible()
|
||||
|
||||
await orcaPage.getByRole('button', { name: /I'll add one later/ }).click()
|
||||
|
||||
// The overlay is unmounted once `closedAt` is set, so the heading must
|
||||
// disappear from the DOM, not merely become invisible.
|
||||
await expect(
|
||||
orcaPage.getByRole('heading', { name: /Point Orca at some code/i })
|
||||
).toHaveCount(0, { timeout: 10_000 })
|
||||
|
||||
// Why: DOM unmount fires when closedAt flips in the renderer, but the
|
||||
// main-process write can lag by an IPC tick. Poll until the persisted
|
||||
// record reflects the dismissal before asserting on its shape.
|
||||
await expect
|
||||
.poll(async () => (await getOnboardingState(orcaPage)).closedAt !== null, {
|
||||
timeout: 5_000
|
||||
})
|
||||
.toBe(true)
|
||||
const final = await getOnboardingState(orcaPage)
|
||||
expect(final.outcome).toBe('dismissed')
|
||||
expect(final.checklist.dismissed).toBe(true)
|
||||
// Why: dismiss path resets lastCompletedStep to -1 (use-onboarding-flow.ts
|
||||
// closeWith) so a future re-open would start at step 1. Lock that in.
|
||||
expect(final.lastCompletedStep).toBe(-1)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue