Revamp onboarding notification setup (#2670)

This commit is contained in:
Neil 2026-05-22 23:47:31 -07:00 committed by GitHub
parent de96ac5678
commit 58accdf8a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 868 additions and 288 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -80,6 +80,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
agentTaskComplete: true,
terminalBell: false,
suppressWhenFocused: true,
customSoundId: 'system',
customSoundPath: null,
customSoundVolume: 100
},

View File

@ -73,6 +73,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
agentTaskComplete: true,
terminalBell: false,
suppressWhenFocused: true,
customSoundId: 'system',
customSoundPath: null,
customSoundVolume: 100
},

View File

@ -1,11 +1,22 @@
/* eslint-disable max-lines -- Why: notification IPC keeps permission, dispatch, custom sound asset, and sound-loading handlers colocated so renderer/main contracts stay auditable. */
import { app, BrowserWindow, Notification, ipcMain, shell } from 'electron'
import { readFile, stat } from 'node:fs/promises'
import { extname, isAbsolute, normalize } from 'node:path'
import beepSoundPath from '../../../resources/notification-sounds/beep.mp3?asset'
import blipSoundPath from '../../../resources/notification-sounds/blip.mp3?asset'
import blopSoundPath from '../../../resources/notification-sounds/blop.mp3?asset'
import bongSoundPath from '../../../resources/notification-sounds/bong.mp3?asset'
import clackSoundPath from '../../../resources/notification-sounds/clack.mp3?asset'
import dingSoundPath from '../../../resources/notification-sounds/ding.mp3?asset'
import sonarSoundPath from '../../../resources/notification-sounds/sonar.mp3?asset'
import thumpSoundPath from '../../../resources/notification-sounds/thump.mp3?asset'
import twoToneSoundPath from '../../../resources/notification-sounds/two-tone.mp3?asset'
import type { Store } from '../persistence'
import type {
NotificationDispatchRequest,
NotificationDispatchResult,
NotificationPermissionStatusResult,
NotificationSettings,
NotificationSoundDataResult
} from '../../shared/types'
import { getRepoIdFromWorktreeId } from '../../shared/worktree-id'
@ -27,6 +38,18 @@ const NOTIFICATION_SOUND_MIME_BY_EXTENSION: ReadonlyMap<string, string> = new Ma
['.aac', 'audio/aac'],
['.flac', 'audio/flac']
])
const BUILT_IN_NOTIFICATION_SOUNDS: ReadonlyMap<string, string> = new Map([
['two-tone', twoToneSoundPath],
['bong', bongSoundPath],
['thump', thumpSoundPath],
['blip', blipSoundPath],
['sonar', sonarSoundPath],
['blop', blopSoundPath],
['ding', dingSoundPath],
['clack', clackSoundPath],
['beep', beepSoundPath]
])
type NotificationSoundId = NotificationSettings['customSoundId']
// Why: Electron Notification objects are normal JS objects — if the only
// reference is a local variable inside the ipcMain handler, the GC can
@ -48,6 +71,35 @@ function openNotificationSystemSettings(): void {
}
}
function getEffectiveNotificationSoundId(settings: NotificationSettings): NotificationSoundId {
return settings.customSoundId ?? (settings.customSoundPath ? 'custom' : 'system')
}
function getSelectedNotificationSoundPath(settings: NotificationSettings): {
path: string | null
reason?: 'missing-path' | 'invalid-path' | 'unsupported-type'
} {
const customSoundId = getEffectiveNotificationSoundId(settings)
if (customSoundId === 'system') {
return { path: null, reason: 'missing-path' }
}
if (customSoundId !== 'custom') {
const builtInPath = BUILT_IN_NOTIFICATION_SOUNDS.get(customSoundId)
return builtInPath ? { path: builtInPath } : { path: null, reason: 'missing-path' }
}
if (!settings.customSoundPath) {
return { path: null, reason: 'missing-path' }
}
const normalizedPath = normalize(settings.customSoundPath)
if (!isAbsolute(normalizedPath)) {
return { path: null, reason: 'invalid-path' }
}
if (!NOTIFICATION_SOUND_MIME_BY_EXTENSION.has(extname(normalizedPath).toLowerCase())) {
return { path: null, reason: 'unsupported-type' }
}
return { path: normalizedPath }
}
function waitForNotificationDisplay(notification: Notification): Promise<boolean> {
return new Promise((resolve) => {
let settled = false
@ -171,7 +223,7 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
}
const notificationOptions = buildNotificationOptions(args)
if (settings.customSoundPath) {
if (getEffectiveNotificationSoundId(settings) !== 'system') {
notificationOptions.silent = true
} else if (process.platform === 'darwin') {
// Why: macOS treats an unset notification sound as silent. When Orca is
@ -262,14 +314,11 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
():
| { ok: true; path: string }
| { ok: false; reason: 'missing-path' | 'invalid-path' | 'unsupported-type' } => {
const pathValue = store.getSettings().notifications.customSoundPath
if (!pathValue) {
return { ok: false, reason: 'missing-path' }
}
const normalizedPath = normalize(pathValue)
if (!isAbsolute(normalizedPath)) {
return { ok: false, reason: 'invalid-path' }
const selectedSound = getSelectedNotificationSoundPath(store.getSettings().notifications)
if (!selectedSound.path) {
return { ok: false, reason: selectedSound.reason ?? 'missing-path' }
}
const normalizedPath = normalize(selectedSound.path)
if (!NOTIFICATION_SOUND_MIME_BY_EXTENSION.has(extname(normalizedPath).toLowerCase())) {
return { ok: false, reason: 'unsupported-type' }
}
@ -279,15 +328,12 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
ipcMain.removeHandler('notifications:loadSound')
ipcMain.handle('notifications:loadSound', async (): Promise<NotificationSoundDataResult> => {
const pathValue = store.getSettings().notifications.customSoundPath
if (!pathValue) {
return { ok: false, reason: 'missing-path' }
const selectedSound = getSelectedNotificationSoundPath(store.getSettings().notifications)
if (!selectedSound.path) {
return { ok: false, reason: selectedSound.reason ?? 'missing-path' }
}
const normalizedPath = normalize(pathValue)
if (!isAbsolute(normalizedPath)) {
return { ok: false, reason: 'invalid-path' }
}
const normalizedPath = normalize(selectedSound.path)
const mimeType = NOTIFICATION_SOUND_MIME_BY_EXTENSION.get(extname(normalizedPath).toLowerCase())
if (!mimeType) {

View File

@ -197,6 +197,27 @@ function normalizeNotificationSettings(value: unknown): NotificationSettings {
const defaults = getDefaultNotificationSettings()
const candidate =
value && typeof value === 'object' ? (value as Partial<NotificationSettings>) : {}
const rawSoundId = (candidate as { customSoundId?: unknown }).customSoundId
const customSoundId =
rawSoundId === 'system' ||
rawSoundId === 'two-tone' ||
rawSoundId === 'bong' ||
rawSoundId === 'thump' ||
rawSoundId === 'blip' ||
rawSoundId === 'sonar' ||
rawSoundId === 'blop' ||
rawSoundId === 'ding' ||
rawSoundId === 'clack' ||
rawSoundId === 'beep' ||
rawSoundId === 'custom'
? rawSoundId
: rawSoundId === 'orca' || rawSoundId === 'chime'
? 'two-tone'
: rawSoundId === 'pop'
? 'blop'
: typeof candidate.customSoundPath === 'string'
? 'custom'
: defaults.customSoundId
const rawVolume = candidate.customSoundVolume
const customSoundVolume =
typeof rawVolume === 'number' && Number.isFinite(rawVolume)
@ -205,6 +226,7 @@ function normalizeNotificationSettings(value: unknown): NotificationSettings {
return {
...defaults,
...candidate,
customSoundId,
customSoundVolume
}
}

View File

@ -818,6 +818,42 @@
animation: settings-shell-enter 180ms ease-out;
}
.collapsible-height-content {
overflow: hidden;
}
.collapsible-height-content[data-state='open'] {
animation: collapsible-down 200ms ease-out;
}
.collapsible-height-content[data-state='closed'] {
animation: collapsible-up 180ms ease-out;
}
@keyframes collapsible-down {
from {
height: 0;
opacity: 0;
}
to {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
}
@keyframes collapsible-up {
from {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
to {
height: 0;
opacity: 0;
}
}
.sidebar.collapsed {
width: 0;
border-right: none;

View File

@ -0,0 +1,26 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { AgentFeatureSetupStep } from './AgentFeatureSetupStep'
describe('AgentFeatureSetupStep', () => {
it('renders the agent feature setup checklist', () => {
const html = renderToStaticMarkup(
<AgentFeatureSetupStep
featureSetup={{
browserUse: true,
computerUse: true,
orchestration: true
}}
onFeatureSetupChange={vi.fn()}
featureSetupCommand={null}
featureSetupCommandSelection={null}
/>
)
expect(html).toContain('Set up agent features')
expect(html).toContain('Agent Browser Use')
expect(html).toContain('Computer Use')
expect(html).toContain('Agent Orchestration')
expect(html).toContain('role="checkbox"')
})
})

View File

@ -0,0 +1,29 @@
import { FeatureSetupChecklist } from './FeatureSetupChecklist'
import { FeatureSetupInlineTerminal } from './FeatureSetupInlineTerminal'
import type { OnboardingFeatureSetupSelection } from './onboarding-feature-setup'
type AgentFeatureSetupStepProps = {
featureSetup: OnboardingFeatureSetupSelection
onFeatureSetupChange: (value: OnboardingFeatureSetupSelection) => void
featureSetupCommand: string | null
featureSetupCommandSelection: OnboardingFeatureSetupSelection | null
}
export function AgentFeatureSetupStep({
featureSetup,
onFeatureSetupChange,
featureSetupCommand,
featureSetupCommandSelection
}: AgentFeatureSetupStepProps): React.JSX.Element {
return (
<>
<FeatureSetupChecklist value={featureSetup} onChange={onFeatureSetupChange} />
{featureSetupCommand ? (
<FeatureSetupInlineTerminal
command={featureSetupCommand}
selection={featureSetupCommandSelection ?? featureSetup}
/>
) : null}
</>
)
}

View File

@ -0,0 +1,33 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { AgentStep } from './AgentStep'
describe('AgentStep', () => {
it('shows the collapsed fallback agents summary', () => {
const html = renderToStaticMarkup(
<AgentStep
selectedAgent={null}
onSelect={vi.fn()}
detectedSet={new Set([AGENT_CATALOG[0].id])}
isDetecting={false}
/>
)
expect(html).toContain(`Show ${AGENT_CATALOG.length - 1} more agents→`)
})
it('labels the fallback agents summary as hide when expanded', () => {
const html = renderToStaticMarkup(
<AgentStep
selectedAgent={AGENT_CATALOG[1].id}
onSelect={vi.fn()}
detectedSet={new Set([AGENT_CATALOG[0].id])}
isDetecting={false}
/>
)
expect(html).toContain('Hide agents')
expect(html).not.toContain(`Show ${AGENT_CATALOG.length - 1} more agents→`)
})
})

View File

@ -1,7 +1,8 @@
import { useEffect, useState } from 'react'
import { ExternalLink } from 'lucide-react'
import { Check, ExternalLink } from 'lucide-react'
import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog'
import { cn } from '@/lib/utils'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import type { TuiAgent } from '../../../../shared/types'
type AgentStepProps = {
@ -33,6 +34,7 @@ export function AgentStep({ selectedAgent, onSelect, detectedSet, isDetecting }:
// 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)
const fallbackRestLabel = openState ? 'Hide agents' : `Show ${fallbackRest.length} more agents→`
useEffect(() => {
if (selectedEntryIsCollapsed) {
setOpenState(true)
@ -79,25 +81,23 @@ export function AgentStep({ selectedAgent, onSelect, detectedSet, isDetecting }:
</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, true)}
/>
))}
</div>
</details>
<Collapsible className="space-y-3" open={openState} onOpenChange={setOpenState}>
<CollapsibleTrigger className="cursor-pointer text-xs font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 data-[state=open]:mb-3">
{fallbackRestLabel}
</CollapsibleTrigger>
<CollapsibleContent className="collapsible-height-content">
<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, true)}
/>
))}
</div>
</CollapsibleContent>
</Collapsible>
)}
</div>
)
@ -129,12 +129,17 @@ function AgentButton({
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-violet-500/60 bg-violet-500/10 ring-2 ring-violet-500/30'
: 'border-border bg-muted/30 hover:bg-muted/60'
)}
onClick={onClick}
>
<div className="flex min-w-0 items-start gap-2.5">
{selected ? (
<div className="absolute right-2 top-2 grid size-5 place-items-center rounded-full bg-violet-500 text-white shadow-sm">
<Check className="size-3" strokeWidth={3} />
</div>
) : null}
<div className="flex min-w-0 items-start gap-2.5 pr-6">
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-muted text-foreground">
<AgentIcon agent={agent.id} size={16} />
</span>

View File

@ -70,7 +70,7 @@ export function FeatureSetupChecklist({
'flex min-h-40 flex-col rounded-lg border px-4 py-3 text-left transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
selected
? 'border-foreground/40 bg-card text-foreground'
? 'border-violet-500/60 bg-violet-500/10 text-foreground ring-2 ring-violet-500/30'
: 'border-border bg-muted/20 text-muted-foreground hover:bg-muted/40'
)}
onClick={() => onChange({ ...value, [row.id]: !selected })}
@ -91,11 +91,11 @@ export function FeatureSetupChecklist({
className={cn(
'flex size-5 items-center justify-center rounded-full border transition-colors',
selected
? 'border-primary bg-primary text-primary-foreground'
? 'border-violet-500 bg-violet-500 text-white'
: 'border-border bg-background'
)}
>
{selected ? <Check className="size-3.5" /> : null}
{selected ? <Check className="size-3" strokeWidth={3} /> : null}
</span>
</span>
<span className="mt-3 text-sm font-medium text-foreground">{row.title}</span>

View File

@ -1,33 +1,36 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../../../shared/types'
import { NotificationStep } from './NotificationStep'
function createSettings(): GlobalSettings {
return {
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false,
customSoundId: 'system',
customSoundPath: null,
customSoundVolume: 80
}
} as GlobalSettings
}
describe('NotificationStep', () => {
it('renders feature setup in the notification step', () => {
it('renders sound setup without the old notification source switches', () => {
const html = renderToStaticMarkup(
<NotificationStep
value={{
agentTaskComplete: true,
terminalBell: true,
notifyWhenFocused: true
}}
onChange={vi.fn()}
featureSetup={{
browserUse: true,
computerUse: true,
orchestration: true
}}
onFeatureSetupChange={vi.fn()}
featureSetupCommand={null}
featureSetupCommandSelection={null}
/>
<NotificationStep settings={createSettings()} updateSettings={vi.fn()} />
)
expect(html).toContain('Set up agent features')
expect(html).toContain('Agent Browser Use')
expect(html).toContain('Computer Use')
expect(html).toContain('Agent Orchestration')
expect(html).toContain('role="checkbox"')
expect(html).toContain('System Default')
expect(html).toContain('Two Tone')
expect(html).toContain('Sonar')
expect(html).toContain('Ding')
expect(html).toContain('Send Test Notification')
expect(html).not.toContain('Agent task complete')
expect(html).not.toContain('Terminal bell')
expect(html).not.toContain('Set up agent features')
expect(html).not.toContain('Connect task sources')
})
})

View File

@ -1,10 +1,36 @@
/* eslint-disable max-lines -- Why: this onboarding step owns the full notification setup surface, including macOS guidance, sound choices, upload, and volume controls. */
import { useEffect, useRef, useState } from 'react'
import type { LucideIcon } from 'lucide-react'
import {
Activity,
AudioWaveform,
Bell,
BellRing,
Check,
ChevronDown,
CircleDot,
FileAudio,
Keyboard,
MousePointer2,
Radio,
Radar,
Settings,
Upload,
Volume1,
Volume2,
X,
Zap
} from 'lucide-react'
import { toast } from 'sonner'
import type { GlobalSettings, NotificationPermissionStatusResult } from '../../../../shared/types'
import { cn } from '@/lib/utils'
import { FeatureSetupInlineTerminal } from './FeatureSetupInlineTerminal'
import { FeatureSetupChecklist } from './FeatureSetupChecklist'
import type { OnboardingFeatureSetupSelection } from './onboarding-feature-setup'
import { basename } from '@/lib/path'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Slider } from '@/components/ui/slider'
import { sendNotificationSettingsTestNotification } from '@/components/settings/NotificationsPane'
import logo from '../../../../../resources/logo.svg'
// 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
@ -12,86 +38,393 @@ export type NotificationDraft = {
}
type NotificationStepProps = {
value: NotificationDraft
onChange: (value: NotificationDraft) => void
featureSetup: OnboardingFeatureSetupSelection
onFeatureSetupChange: (value: OnboardingFeatureSetupSelection) => void
featureSetupCommand: string | null
featureSetupCommandSelection: OnboardingFeatureSetupSelection | null
settings: GlobalSettings | null
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void> | void
}
type NotificationSoundOption = {
id: GlobalSettings['notifications']['customSoundId']
title: string
icon: LucideIcon
}
const SOUND_OPTIONS: readonly NotificationSoundOption[] = [
{
id: 'system',
title: 'System Default',
icon: Bell
},
{
id: 'two-tone',
title: 'Two Tone',
icon: AudioWaveform
},
{
id: 'bong',
title: 'Bong',
icon: CircleDot
},
{
id: 'thump',
title: 'Thump',
icon: Volume1
},
{
id: 'blip',
title: 'Blip',
icon: Zap
},
{
id: 'sonar',
title: 'Sonar',
icon: Radar
},
{
id: 'blop',
title: 'Blop',
icon: Activity
},
{
id: 'ding',
title: 'Ding',
icon: Radio
},
{
id: 'clack',
title: 'Clack',
icon: Keyboard
},
{
id: 'beep',
title: 'Beep',
icon: MousePointer2
}
]
export function NotificationStep({
value,
onChange,
featureSetup,
onFeatureSetupChange,
featureSetupCommand,
featureSetupCommandSelection
}: 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."
settings,
updateSettings
}: NotificationStepProps): React.JSX.Element {
const notificationSettings = settings?.notifications
const notificationSettingsRef = useRef(notificationSettings)
const [permissionStatus, setPermissionStatus] =
useState<NotificationPermissionStatusResult | null>(null)
const [volumeDraft, setVolumeDraft] = useState(notificationSettings?.customSoundVolume ?? 100)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [isPickingSound, setIsPickingSound] = useState(false)
const [showMacSettingsPreview, setShowMacSettingsPreview] = useState(false)
useEffect(() => {
notificationSettingsRef.current = notificationSettings
setVolumeDraft(notificationSettings?.customSoundVolume ?? 100)
}, [notificationSettings])
useEffect(() => {
let cancelled = false
void window.api.notifications.getPermissionStatus().then((status) => {
if (!cancelled) {
setPermissionStatus(status)
}
})
return () => {
cancelled = true
}
]
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>
))}
}, [])
const updateNotificationSettings = async (
updates: Partial<GlobalSettings['notifications']>
): Promise<void> => {
const current = notificationSettingsRef.current
if (!current) {
return
}
const nextNotifications = {
...current,
...updates
}
notificationSettingsRef.current = nextNotifications
await updateSettings({
notifications: nextNotifications
})
}
const handleMacPermission = async (): Promise<void> => {
setShowMacSettingsPreview(true)
const status = await window.api.notifications.requestPermission()
setPermissionStatus(status)
await window.api.notifications.openSystemSettings()
}
const previewSound = async (
customSoundId: GlobalSettings['notifications']['customSoundId']
): Promise<void> => {
if (customSoundId === 'system') {
return
}
const result = await window.api.notifications.playSound({
force: true,
volume: volumeDraft
})
if (!result.played) {
toast.error('Notification sound could not be played')
}
}
const handleChooseBuiltInSound = async (
customSoundId: GlobalSettings['notifications']['customSoundId']
): Promise<void> => {
await updateNotificationSettings({ customSoundId })
await previewSound(customSoundId)
}
const handleChooseCustomSound = async (): Promise<void> => {
setIsPickingSound(true)
try {
const soundPath = await window.api.shell.pickAudio()
if (soundPath) {
await updateNotificationSettings({ customSoundId: 'custom', customSoundPath: soundPath })
await previewSound('custom')
setAdvancedOpen(true)
}
} finally {
setIsPickingSound(false)
}
}
const handleVolumeCommit = (value: number): void => {
if (notificationSettingsRef.current?.customSoundVolume !== value) {
void updateNotificationSettings({ customSoundVolume: value })
}
}
const handleSendTestNotification = async (): Promise<void> => {
if (!notificationSettings) {
toast.error('Notification settings are still loading')
return
}
await sendNotificationSettingsTestNotification(notificationSettings, volumeDraft)
}
if (!notificationSettings) {
return (
<div className="rounded-xl border border-border bg-muted/20 px-5 py-4 text-sm text-muted-foreground">
Loading notification settings
</div>
<p className="mt-3 text-[13px] text-muted-foreground">
Configure other agent status personalization, like custom sounds, under{' '}
<span className="font-medium text-foreground">Settings Notifications</span>.
</p>
<FeatureSetupChecklist value={featureSetup} onChange={onFeatureSetupChange} />
{featureSetupCommand ? (
<FeatureSetupInlineTerminal
command={featureSetupCommand}
selection={featureSetupCommandSelection ?? featureSetup}
/>
)
}
const customPath = notificationSettings.customSoundPath
const selectedSoundId = notificationSettings.customSoundId
const soundOptions = customPath
? [
...SOUND_OPTIONS,
{
id: 'custom' as const,
title: basename(customPath),
icon: FileAudio
}
]
: SOUND_OPTIONS
const canAdjustVolume = selectedSoundId !== 'system'
const isMac = permissionStatus?.platform === 'darwin'
return (
<div className="space-y-5">
{isMac ? (
<section className="rounded-xl border border-border bg-card px-5 py-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Settings className="size-4" />
Allow Orca in macOS
</div>
<p className="max-w-[58ch] text-[13px] leading-relaxed text-muted-foreground">
macOS controls notifications per app. Open System Settings and make sure Orca is
allowed to show alerts and play sounds.
</p>
</div>
<Button
type="button"
size="sm"
className="gap-2"
onClick={() => void handleMacPermission()}
>
<Settings className="size-3.5" />
Open Mac Settings
</Button>
</div>
{showMacSettingsPreview ? (
<div className="mt-4 rounded-xl border border-border bg-[#1f1d24] p-3 text-white shadow-sm">
<div className="flex items-center justify-between">
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-8 items-center justify-center rounded-lg bg-white/10 ring-1 ring-white/15">
<img src={logo} alt="" aria-hidden className="size-5 rounded-md" />
</div>
<div className="min-w-0">
<div className="text-sm font-medium leading-tight">Allow notifications</div>
<div className="text-xs leading-tight text-white/55">Orca</div>
</div>
</div>
<div
aria-hidden
className="relative h-6 w-11 rounded-full bg-[#0a84ff] shadow-inner"
>
<div className="absolute right-0.5 top-0.5 size-5 rounded-full bg-white shadow-sm" />
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-4 rounded-lg bg-white/[0.03] px-8 py-5">
<div className="h-14 rounded-sm bg-gradient-to-b from-sky-300 to-violet-400">
<div className="ml-auto mr-2 mt-1 h-1.5 w-5 rounded-full bg-white/80" />
</div>
<div className="h-14 rounded-sm bg-gradient-to-b from-sky-300 to-violet-400">
<div className="ml-auto mr-2 mt-1 h-1.5 w-5 rounded-full bg-white/80" />
<div className="ml-auto mr-2 mt-2 h-1.5 w-6 rounded-full bg-white/80" />
<div className="ml-auto mr-2 mt-1 h-1.5 w-6 rounded-full bg-white/80" />
</div>
<div className="h-14 rounded-sm bg-gradient-to-b from-sky-300 to-violet-400">
<div className="mr-2 mt-1 text-right text-[10px] font-medium text-white/90">
9:41
</div>
</div>
</div>
<div className="mt-3 flex justify-end">
<button
type="button"
className="inline-flex items-center gap-1 text-xs font-medium text-white/60 hover:text-white"
onClick={() => setShowMacSettingsPreview(false)}
>
<X className="size-3.5" />
Dismiss
</button>
</div>
</div>
) : null}
</section>
) : null}
</>
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1">
<h2 className="text-sm font-semibold text-foreground">Choose a sound</h2>
<p className="text-[13px] leading-relaxed text-muted-foreground">
Pick the alert Orca plays after a desktop notification is delivered.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="gap-2"
onClick={() => void handleSendTestNotification()}
>
<BellRing className="size-3.5" />
Send Test Notification
</Button>
</div>
<div className="grid grid-cols-2 gap-2 md:grid-cols-3">
{soundOptions.map((option) => {
const selected = selectedSoundId === option.id
const OptionIcon = option.icon
return (
<button
key={option.id}
type="button"
aria-pressed={selected}
className={cn(
'group relative flex min-h-14 items-center gap-3 overflow-hidden rounded-xl border p-3 text-left transition-all',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
selected
? 'border-violet-500/60 bg-violet-500/10 text-foreground ring-2 ring-violet-500/30'
: 'border-border bg-muted/20 text-muted-foreground hover:bg-muted/40'
)}
onClick={() => void handleChooseBuiltInSound(option.id)}
>
<span className="grid size-5 shrink-0 place-items-center text-muted-foreground">
<OptionIcon className="size-4" />
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{option.title}
</span>
{selected ? (
<span
aria-hidden
className="grid size-5 shrink-0 place-items-center rounded-full bg-violet-500 text-white shadow-sm"
>
<Check className="size-3" strokeWidth={3} />
</span>
) : null}
</button>
)
})}
</div>
{canAdjustVolume ? (
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3">
<div className="flex items-center gap-3">
<Volume2 className="size-4 text-muted-foreground" />
<Slider
value={[volumeDraft]}
min={0}
max={100}
step={5}
onValueChange={([value]) => setVolumeDraft(value)}
onValueCommit={([value]) => handleVolumeCommit(value)}
className="flex-1"
aria-label="Notification sound volume"
/>
<span className="w-10 text-right font-mono text-xs tabular-nums text-muted-foreground">
{volumeDraft}%
</span>
</div>
</div>
) : null}
</section>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground"
>
<ChevronDown
className={cn('size-4 transition-transform', advancedOpen && 'rotate-180')}
/>
Advanced sound file
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-3 rounded-lg border border-border bg-muted/20 px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<FileAudio className="size-4" />
Upload a sound
</div>
<p className="text-xs text-muted-foreground">
MP3, WAV, OGG, M4A, AAC, or FLAC. Orca stores only the local file path.
</p>
{customPath ? (
<p className="truncate font-mono text-[11px] text-muted-foreground">
{customPath}
</p>
) : null}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="gap-2"
disabled={isPickingSound}
onClick={() => void handleChooseCustomSound()}
>
<Upload className="size-3.5" />
{customPath ? 'Change File' : 'Choose File'}
</Button>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
)
}

View File

@ -1,5 +1,16 @@
import { useEffect } from 'react'
import { ChevronLeft, CornerDownLeft, Loader2 } from 'lucide-react'
import {
Bell,
Bot,
ChevronLeft,
CornerDownLeft,
FolderOpen,
Loader2,
Palette,
Plug,
Wrench,
type LucideIcon
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { isEditableTarget } from '@/lib/editable-target'
import { getScreenSubmitModifierLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
@ -8,6 +19,7 @@ import type { OnboardingState } from '../../../../shared/types'
import { AgentStep } from './AgentStep'
import { ThemeStep } from './ThemeStep'
import { NotificationStep } from './NotificationStep'
import { AgentFeatureSetupStep } from './AgentFeatureSetupStep'
import { IntegrationsStep } from './IntegrationsStep'
import { RepoStep } from './RepoStep'
import { STEPS, useOnboardingFlow } from './use-onboarding-flow'
@ -24,9 +36,12 @@ const stepCopy = {
subtitle: 'Pick the look you want to stare at for hours.'
},
notifications: {
title: 'Set up notifications',
subtitle: 'Allow desktop alerts and choose the sound Orca uses when work needs attention.'
},
agentSetup: {
title: 'Set up Orca for agents',
subtitle:
'Get notifications when agents need you, and choose the capabilities Orca should enable on this computer.'
subtitle: 'Choose the capabilities Orca should enable on this computer.'
},
integrations: {
title: 'Connect your task sources',
@ -41,11 +56,21 @@ const stepCopy = {
const stepTooltipLabels = {
agent: 'Default Agent',
theme: 'Appearance',
notifications: 'Agent tools',
notifications: 'Notifications',
agentSetup: 'Agent setup',
integrations: 'Integrations',
repo: 'Create project'
} as const
const stepIcons = {
agent: Bot,
theme: Palette,
notifications: Bell,
agentSetup: Wrench,
integrations: Plug,
repo: FolderOpen
} satisfies Record<keyof typeof stepCopy, LucideIcon>
type OnboardingFlowProps = {
onboarding: OnboardingState
onOnboardingChange: (state: OnboardingState) => void
@ -61,8 +86,9 @@ export default function OnboardingFlow({
const continueShortcutModifierLabel = getScreenSubmitModifierLabel()
const { currentStep, stepIndex, busyLabel } = flow
const copy = stepCopy[currentStep.id]
const StepIcon = stepIcons[currentStep.id]
const shouldShowSetupAction =
currentStep.id === 'notifications' &&
currentStep.id === 'agentSetup' &&
flow.hasSelectedFeatureSetup &&
!flow.featureSetupTerminalCommand
const primaryActionLabel = busyLabel ?? (shouldShowSetupAction ? 'Set up' : 'Continue')
@ -162,12 +188,17 @@ export default function OnboardingFlow({
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 className="flex items-start justify-between gap-6">
<div className="min-w-0">
<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>
<StepIcon aria-hidden className="mt-1 size-8 shrink-0 text-muted-foreground/70" />
</div>
</div>
<div className="mt-10 flex-1">
@ -188,9 +219,10 @@ export default function OnboardingFlow({
/>
)}
{currentStep.id === 'notifications' && (
<NotificationStep
value={flow.notifications}
onChange={flow.setNotifications}
<NotificationStep settings={flow.settings} updateSettings={flow.updateSettings} />
)}
{currentStep.id === 'agentSetup' && (
<AgentFeatureSetupStep
featureSetup={flow.featureSetupSelection}
onFeatureSetupChange={flow.setFeatureSetupSelection}
featureSetupCommand={flow.featureSetupTerminalCommand}

View File

@ -3,7 +3,6 @@ import { toast } from 'sonner'
import { track } from '@/lib/telemetry'
import { ONBOARDING_FINAL_STEP } from '../../../../shared/constants'
import type { GlobalSettings, OnboardingState, TuiAgent } from '../../../../shared/types'
import type { NotificationDraft } from './NotificationStep'
import {
hasSelectedOnboardingFeatureSetup,
onboardingFeatureSetupRunTelemetry,
@ -116,7 +115,6 @@ type PersistCurrentStepDeps = {
currentStepId: StepId
selectedAgent: TuiAgent | null
theme: GlobalSettings['theme']
notifications: NotificationDraft
featureSetupSelection: OnboardingFeatureSetupSelection
settings: GlobalSettings | null
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void> | void
@ -134,7 +132,6 @@ export function usePersistCurrentStep({
currentStepId,
selectedAgent,
theme,
notifications,
featureSetupSelection,
settings,
updateSettings,
@ -171,22 +168,18 @@ export function usePersistCurrentStep({
return { ok: 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
enabled: true,
agentTaskComplete: true,
terminalBell: true
}
})
onOnboardingChange(await persistStep(3))
return { ok: true }
}
if (currentStepId === 'agentSetup') {
const setupResult = await runOnboardingFeatureSetup(featureSetupSelection)
const featureSetupResult: OnboardingFeatureSetupResult = setupResult
track('onboarding_feature_setup_run', {
@ -208,7 +201,7 @@ export function usePersistCurrentStep({
toast.message('Opened Computer Use permissions')
}
}
onOnboardingChange(await persistStep(3))
onOnboardingChange(await persistStep(4))
return { ok: true, featureSetupResult }
}
if (currentStepId === 'integrations') {
@ -216,7 +209,7 @@ export function usePersistCurrentStep({
// store slices when the user actually wires them up. The step itself
// is a no-op for settings/onboarding state beyond marking it
// completed.
onOnboardingChange(await persistStep(4))
onOnboardingChange(await persistStep(5))
return { ok: true }
}
return { ok: false }
@ -227,7 +220,6 @@ export function usePersistCurrentStep({
}, [
currentStepId,
featureSetupSelection,
notifications,
onboardingChecklist,
onOnboardingChange,
selectedAgent,

View File

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

View File

@ -11,7 +11,6 @@ import { ONBOARDING_FINAL_STEP } from '../../../../shared/constants'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { EventProps } from '../../../../shared/telemetry-events'
import type { GlobalSettings, OnboardingState, Repo, TuiAgent } from '../../../../shared/types'
import type { NotificationDraft } from './NotificationStep'
import {
DEFAULT_ONBOARDING_FEATURE_SETUP_SELECTION,
ONBOARDING_FEATURE_SETUP_IDS,
@ -91,15 +90,6 @@ export function useOnboardingFlow(
// 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 [featureSetupSelection, setFeatureSetupSelection] =
useState<OnboardingFeatureSetupSelection>(DEFAULT_ONBOARDING_FEATURE_SETUP_SELECTION)
const [featureSetupTerminalCommand, setFeatureSetupTerminalCommand] = useState<string | null>(
@ -243,12 +233,12 @@ export function useOnboardingFlow(
return
}
startedTrackedRef.current = true
// Why: `resumed_from_step` is the step the user finished (1..3), not the
// Why: `resumed_from_step` is the step the user finished, not the
// step we resume into.
const lastCompleted = onboarding.lastCompletedStep
track(
'onboarding_started',
lastCompleted >= 1 && lastCompleted <= 3
lastCompleted >= 1 && lastCompleted < ONBOARDING_FINAL_STEP
? { resumed_from_step: lastCompleted as StepNumber }
: {}
)
@ -364,7 +354,6 @@ export function useOnboardingFlow(
currentStepId: currentStep.id,
selectedAgent,
theme,
notifications,
featureSetupSelection,
settings,
updateSettings,
@ -396,30 +385,30 @@ export function useOnboardingFlow(
// the first call's setStepIndex has run, advancing twice and skipping a
// step. A ref flips synchronously so re-entries bail immediately.
const nextInFlightRef = useRef(false)
const notificationsStepCompletedTrackedRef = useRef(false)
const featureSetupStepCompletedTrackedRef = useRef(false)
const next = useCallback(
async (advancedVia: 'button' | 'keyboard' = 'button') => {
if (nextInFlightRef.current || busyLabel || currentStep.id === 'repo') {
return
}
if (currentStep.id === 'notifications' && featureSetupTerminalCommand) {
if (currentStep.id === 'agentSetup' && featureSetupTerminalCommand) {
setStepIndex((idx) => Math.min(idx + 1, STEPS.length - 1))
return
}
nextInFlightRef.current = true
if (currentStep.id === 'notifications' && hasSelectedFeatureSetup) {
if (currentStep.id === 'agentSetup' && hasSelectedFeatureSetup) {
setBusyLabel('Setting up features…')
}
try {
const trackCurrentStepCompleted = (): void => {
if (currentStep.id === 'notifications') {
if (notificationsStepCompletedTrackedRef.current) {
if (currentStep.id === 'agentSetup') {
if (featureSetupStepCompletedTrackedRef.current) {
return
}
// Why: feature setup can keep the user on this already-persisted
// step to review a terminal command; later checklist edits must
// not double-count the same step completion.
notificationsStepCompletedTrackedRef.current = true
featureSetupStepCompletedTrackedRef.current = true
}
const durationMs = consumeStepDurationMs()
track('onboarding_step_completed', {
@ -434,7 +423,7 @@ export function useOnboardingFlow(
}
const result = await persistCurrentStep()
const nextCommand = result.featureSetupResult?.skillInstallCommand ?? null
if (currentStep.id === 'notifications' && nextCommand) {
if (currentStep.id === 'agentSetup' && nextCommand) {
trackCurrentStepCompleted()
setFeatureSetupTerminalSelection(featureSetupSelection)
setFeatureSetupTerminalCommand(nextCommand)
@ -445,7 +434,7 @@ export function useOnboardingFlow(
setStepIndex((idx) => Math.min(idx + 1, STEPS.length - 1))
}
} finally {
if (currentStep.id === 'notifications') {
if (currentStep.id === 'agentSetup') {
setBusyLabel(null)
}
nextInFlightRef.current = false
@ -623,14 +612,14 @@ export function useOnboardingFlow(
])
const skipAgentSetup = useCallback(async () => {
if (busyLabel || currentStep.id !== 'notifications') {
if (busyLabel || currentStep.id !== 'agentSetup') {
return
}
setError(null)
const durationMs = consumeStepDurationMs()
try {
// Why: this step's primary action can request notification permission and
// run selected feature setup. Skip is the explicit "not now" path.
// Why: this step's primary action can run selected feature setup. Skip is
// the explicit "not now" path.
const nextState = await persistStep(currentStep.stepNumber)
onOnboardingChange(nextState)
track('onboarding_step_skipped', {
@ -696,8 +685,6 @@ export function useOnboardingFlow(
setSelectedAgent: setSelectedAgentInteractive,
theme,
setTheme: setThemeInteractive,
notifications,
setNotifications,
featureSetupSelection,
setFeatureSetupSelection: setFeatureSetupSelectionInteractive,
featureSetupTerminalCommand,

View File

@ -23,6 +23,7 @@ function createSettings(): GlobalSettings {
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: true,
customSoundId: 'system',
customSoundPath: null,
customSoundVolume: 50
}

View File

@ -96,13 +96,14 @@ export async function sendNotificationSettingsTestNotification(
// Why: the Test button must always play through, even if the user clicks
// it twice in quick succession — the in-flight dedupe is for incidental
// bursts of real notifications, not for an explicit user action.
const soundResult = notificationSettings.customSoundPath
? await window.api.notifications.playSound({
force: true,
volume: volumeDraft
})
: null
if (notificationSettings.customSoundPath && soundResult && !soundResult.played) {
const soundResult =
notificationSettings.customSoundId !== 'system'
? await window.api.notifications.playSound({
force: true,
volume: volumeDraft
})
: null
if (notificationSettings.customSoundId !== 'system' && soundResult && !soundResult.played) {
toast.error('Custom notification sound could not be played')
return
}
@ -193,7 +194,7 @@ export function NotificationsPane({
try {
const soundPath = await window.api.shell.pickAudio()
if (soundPath) {
updateNotificationSettings({ customSoundPath: soundPath })
updateNotificationSettings({ customSoundId: 'custom', customSoundPath: soundPath })
}
} finally {
setIsPickingSound(false)
@ -287,7 +288,7 @@ export function NotificationsPane({
variant="ghost"
size="sm"
disabled={!notificationSettings.enabled}
onClick={() => updateNotificationSettings({ customSoundPath: null })}
onClick={() => updateNotificationSettings({ customSoundId: 'system' })}
className="gap-2"
>
<X className="size-3.5" />
@ -295,7 +296,7 @@ export function NotificationsPane({
</Button>
) : null}
</div>
{selectedSoundPath ? (
{notificationSettings.customSoundId !== 'system' ? (
<div className="flex items-center gap-3 pt-1">
<Volume2 className="size-4 text-muted-foreground" />
<Slider

View File

@ -165,7 +165,7 @@ export function dispatchTerminalNotification(
// itself is the source of truth for its owning repo.
const worktree = getWorktreeMapFromState(state).get(worktreeId)
const repo = worktree ? getRepoMapFromState(state).get(worktree.repoId) : null
const customSoundPath = state.settings?.notifications?.customSoundPath ?? null
const customSoundId = state.settings?.notifications?.customSoundId ?? 'system'
const customSoundVolume = state.settings?.notifications?.customSoundVolume ?? null
const agentStatus =
event.source === 'agent-task-complete' && event.paneKey
@ -201,7 +201,7 @@ export function dispatchTerminalNotification(
})
.then((result) => {
if (result.delivered) {
void playDesktopNotificationSound(customSoundPath, customSoundVolume)
void playDesktopNotificationSound(customSoundId, customSoundVolume)
}
})
.catch((err) => {

View File

@ -0,0 +1,24 @@
'use client'
import * as React from 'react'
import { Collapsible as CollapsiblePrimitive } from 'radix-ui'
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>): React.JSX.Element {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Trigger>): React.JSX.Element {
return <CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Content>): React.JSX.Element {
return <CollapsiblePrimitive.Content data-slot="collapsible-content" {...props} />
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@ -1,8 +1,8 @@
export async function playDesktopNotificationSound(
customSoundPath: string | null | undefined,
customSoundId: string | null | undefined,
customSoundVolume?: number | null
): Promise<boolean> {
if (!customSoundPath) {
if (!customSoundId || customSoundId === 'system') {
return false
}

View File

@ -31,7 +31,7 @@ export const DEFAULT_HIDE_SLEEPING_WORKSPACES = false
// 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 = 5
export const ONBOARDING_FINAL_STEP = 6
export const ORCA_BROWSER_PARTITION = 'persist:orca-browser'
// Why: blank browser tabs must start from an inert guest URL that does not
@ -120,6 +120,7 @@ export function getDefaultNotificationSettings(): NotificationSettings {
agentTaskComplete: true,
terminalBell: false,
suppressWhenFocused: true,
customSoundId: 'system',
customSoundPath: null,
customSoundVolume: 100
}

View File

@ -441,6 +441,7 @@ const onboardingValueKindSchema = z.enum([
'agent',
'theme',
'notifications',
'agent_setup',
'integrations',
'repo'
])

View File

@ -1354,6 +1354,18 @@ export type NotificationSettings = {
agentTaskComplete: boolean
terminalBell: boolean
suppressWhenFocused: boolean
customSoundId:
| 'system'
| 'two-tone'
| 'bong'
| 'thump'
| 'blip'
| 'sonar'
| 'blop'
| 'ding'
| 'clack'
| 'beep'
| 'custom'
customSoundPath: string | null
customSoundVolume: number
}

View File

@ -125,10 +125,10 @@ async function setupOnboardingFeatures(page: Page): Promise<void> {
async function continueFromFeatureSetupToRepo(page: Page): Promise<void> {
await continueOnboarding(page)
await expect(page.getByRole('heading', { name: TASK_SOURCES_HEADING })).toBeVisible()
await expect(page.getByText('4 of 5')).toBeVisible()
await expect(page.getByText('5 of 6')).toBeVisible()
await continueOnboarding(page)
await expect(page.getByRole('heading', { name: REPO_STEP_HEADING })).toBeVisible()
await expect(page.getByText('5 of 5')).toBeVisible()
await expect(page.getByText('6 of 6')).toBeVisible()
}
test.describe('Onboarding flow', () => {
@ -148,7 +148,7 @@ test.describe('Onboarding flow', () => {
await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({
timeout: 15_000
})
await expect(orcaPage.getByText('1 of 5')).toBeVisible()
await expect(orcaPage.getByText('1 of 6')).toBeVisible()
await expect(onboardingFooterButton(orcaPage, /^Continue\b/)).toBeVisible()
await expect(onboardingFooterButton(orcaPage, SKIP_TO_PROJECT_SETUP_BUTTON)).toBeVisible()
// Why: Back is not rendered on the first step (was previously rendered-but-
@ -196,7 +196,7 @@ test.describe('Onboarding flow', () => {
await continueOnboarding(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Make it feel like home/i })).toBeVisible()
await expect(orcaPage.getByText('2 of 5')).toBeVisible()
await expect(orcaPage.getByText('2 of 6')).toBeVisible()
await expect
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
timeout: 5_000,
@ -230,8 +230,8 @@ test.describe('Onboarding flow', () => {
.toBe(oppositeTheme)
await continueOnboarding(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Set up Orca for agents/i })).toBeVisible()
await expect(orcaPage.getByText('3 of 5')).toBeVisible()
await expect(orcaPage.getByRole('heading', { name: /Set up notifications/i })).toBeVisible()
await expect(orcaPage.getByText('3 of 6')).toBeVisible()
await expect
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
timeout: 5_000,
@ -243,14 +243,24 @@ test.describe('Onboarding flow', () => {
.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). Use the default setup action without touching
// the toggles; the assertions prove the wizard wrote its opt-in defaults
// through IPC, including the inverted suppressWhenFocused.
// Why: the feature checklist also defaults ON; inject safe deps so this
// E2E validates persistence without registering the real CLI or opening
// OS permission prompts.
await expect(orcaPage.getByRole('button', { name: /System Default/i })).toHaveAttribute(
'aria-pressed',
'true'
)
await expect(orcaPage.getByRole('button', { name: /Send Test Notification/i })).toBeVisible()
await expect(orcaPage.getByText(/Advanced sound file/i)).toBeVisible()
await continueOnboarding(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Set up Orca for agents/i })).toBeVisible()
await expect(orcaPage.getByText('4 of 6')).toBeVisible()
await expect
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
timeout: 5_000,
message: 'lastCompletedStep did not advance to 3 after notifications Continue'
})
.toBe(3)
// Why: the feature checklist defaults ON; inject safe deps so this E2E
// validates setup without registering the real CLI or opening OS prompts.
await installSafeOnboardingFeatureSetupDeps(orcaPage)
const browserUse = orcaPage.getByRole('checkbox', { name: /Agent Browser Use/i })
const computerUse = orcaPage.getByRole('checkbox', { name: /Computer Use/i })
@ -269,11 +279,10 @@ test.describe('Onboarding flow', () => {
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
timeout: 5_000
})
.toBe(4)
.toBe(5)
// Verify all three notification fields landed in settings, including the
// inverted suppressWhenFocused boundary (UI: notifyWhenFocused=true →
// persisted: suppressWhenFocused=false).
// Verify the source defaults land without asking users to configure each
// source in the onboarding UI.
await expect
.poll(
async () => {
@ -281,8 +290,8 @@ test.describe('Onboarding flow', () => {
return {
agentTaskComplete: s.notifications.agentTaskComplete,
terminalBell: s.notifications.terminalBell,
suppressWhenFocused: s.notifications.suppressWhenFocused,
enabled: s.notifications.enabled
enabled: s.notifications.enabled,
customSoundId: s.notifications.customSoundId
}
},
{ timeout: 5_000 }
@ -290,8 +299,8 @@ test.describe('Onboarding flow', () => {
.toEqual({
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false,
enabled: true
enabled: true,
customSoundId: 'system'
})
await expect
@ -355,7 +364,7 @@ test.describe('Onboarding flow', () => {
await onboardingFooterButton(orcaPage, SKIP_TO_PROJECT_SETUP_BUTTON).click()
await expect(orcaPage.getByRole('heading', { name: REPO_STEP_HEADING })).toBeVisible()
await expect(orcaPage.getByText('5 of 5')).toBeVisible()
await expect(orcaPage.getByText('6 of 6')).toBeVisible()
await expect(onboardingFooterButton(orcaPage, SKIP_TO_PROJECT_SETUP_BUTTON)).toHaveCount(0)
await expect(onboardingFooterButton(orcaPage, /Skip all onboarding/i)).toHaveCount(0)
await expect(orcaPage.getByRole('button', { name: /Open a folder/i })).toBeVisible()
@ -380,7 +389,7 @@ test.describe('Onboarding flow', () => {
closedAt: null,
outcome: null,
dismissed: false,
lastCompletedStep: 4
lastCompletedStep: 5
})
await expect
.poll(async () => (await getSettings(orcaPage)).defaultTuiAgent, { timeout: 5_000 })
@ -389,7 +398,7 @@ test.describe('Onboarding flow', () => {
await orcaPage.reload()
await waitForSessionReady(orcaPage)
await expect(orcaPage.getByRole('heading', { name: REPO_STEP_HEADING })).toBeVisible()
await expect(orcaPage.getByText('5 of 5')).toBeVisible()
await expect(orcaPage.getByText('6 of 6')).toBeVisible()
await expect(onboardingFooterButton(orcaPage, SKIP_TO_PROJECT_SETUP_BUTTON)).toHaveCount(0)
expect((await getOnboardingState(orcaPage)).closedAt).toBeNull()
})
@ -430,12 +439,12 @@ test.describe('Onboarding flow', () => {
closedAt: null,
outcome: null,
dismissed: false,
lastCompletedStep: 4
lastCompletedStep: 5
})
await orcaPage.keyboard.press('Escape')
await expect(orcaPage.getByRole('heading', { name: REPO_STEP_HEADING })).toBeVisible()
await expect(orcaPage.getByText('5 of 5')).toBeVisible()
await expect(orcaPage.getByText('6 of 6')).toBeVisible()
})
test('Skip from theme reverts preview without saving the skipped choice', async ({
@ -502,7 +511,7 @@ test.describe('Onboarding flow', () => {
expect((await getOnboardingState(orcaPage)).closedAt).toBeNull()
})
test('Skip from notifications does not persist notification or feature setup', async ({
test('Skip from notifications does not request permission or run feature setup', async ({
orcaPage
}) => {
await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({
@ -511,9 +520,8 @@ test.describe('Onboarding flow', () => {
await continueOnboarding(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Make it feel like home/i })).toBeVisible()
await continueOnboarding(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Set up Orca for agents/i })).toBeVisible()
await expect(orcaPage.getByRole('heading', { name: /Set up notifications/i })).toBeVisible()
const beforeNotifications = (await getSettings(orcaPage)).notifications
await orcaPage.evaluate(() => {
localStorage.removeItem('orca.e2e.notificationPermissionRequested')
window.api.notifications.requestPermission = async () => {
@ -521,33 +529,14 @@ test.describe('Onboarding flow', () => {
return { supported: true, platform: 'darwin', requested: true }
}
})
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 expect(orcaPage.getByRole('button', { name: /System Default/i })).toHaveAttribute(
'aria-pressed',
'true'
)
await onboardingFooterButton(orcaPage, SKIP_TO_PROJECT_SETUP_BUTTON).click()
await expect(orcaPage.getByRole('heading', { name: REPO_STEP_HEADING })).toBeVisible()
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: beforeNotifications.agentTaskComplete,
terminalBell: beforeNotifications.terminalBell,
suppressWhenFocused: beforeNotifications.suppressWhenFocused,
enabled: beforeNotifications.enabled
})
await expect
.poll(
async () =>
@ -593,23 +582,21 @@ test.describe('Onboarding flow', () => {
await expect(codexButton).toHaveAttribute('aria-pressed', 'true')
})
test('notification toggles flip independently and persist on Continue', async ({ orcaPage }) => {
test('notification sound choice persists on Continue', async ({ orcaPage }) => {
await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({
timeout: 15_000
})
await continueOnboarding(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Make it feel like home/i })).toBeVisible()
await continueOnboarding(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Set up notifications/i })).toBeVisible()
const dingSound = orcaPage.getByRole('button', { name: /^Ding\b/i })
await dingSound.click()
await expect(dingSound).toHaveAttribute('aria-pressed', 'true')
await continueOnboarding(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Set up Orca for agents/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 installSafeOnboardingFeatureSetupDeps(orcaPage)
await setupOnboardingFeatures(orcaPage)
await expect(orcaPage.getByRole('region', { name: /Skill setup command/i })).toBeVisible()
@ -621,12 +608,13 @@ test.describe('Onboarding flow', () => {
const s = await getSettings(orcaPage)
return {
agentTaskComplete: s.notifications.agentTaskComplete,
terminalBell: s.notifications.terminalBell
terminalBell: s.notifications.terminalBell,
customSoundId: s.notifications.customSoundId
}
},
{ timeout: 5_000 }
)
.toEqual({ agentTaskComplete: true, terminalBell: false })
.toEqual({ agentTaskComplete: true, terminalBell: true, customSoundId: 'ding' })
})
test('can opt into orchestration setup without enabling browser or computer use', async ({
@ -638,6 +626,8 @@ test.describe('Onboarding flow', () => {
await continueOnboarding(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Make it feel like home/i })).toBeVisible()
await continueOnboarding(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Set up notifications/i })).toBeVisible()
await continueOnboarding(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Set up Orca for agents/i })).toBeVisible()
// Why: this flow validates the orchestration-only setup path without
@ -696,7 +686,7 @@ test.describe('Onboarding flow', () => {
.poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, {
timeout: 5_000
})
.toBe(4)
.toBe(5)
await expect
.poll(
async () =>
@ -724,6 +714,7 @@ test.describe('Onboarding flow', () => {
// Advance to the repo step.
await continueOnboarding(orcaPage)
await continueOnboarding(orcaPage)
await continueOnboarding(orcaPage)
await installSafeOnboardingFeatureSetupDeps(orcaPage)
await setupOnboardingFeatures(orcaPage)
await expectSkillSetupTerminalReady(orcaPage)
@ -763,7 +754,7 @@ test.describe('Onboarding flow', () => {
// 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 5')).toBeVisible()
await expect(orcaPage.getByText('1 of 6')).toBeVisible()
// Why: "without losing progress" means persisted lastCompletedStep stays
// at 1 — Back rewinds the visible step but must not roll persistence back.
@ -780,11 +771,13 @@ test.describe('Onboarding flow', () => {
timeout: 15_000
})
// Advance through the first four steps. The repo step is required setup,
// Advance through the optional setup steps. The repo step is required setup,
// so the footer must not offer a dismiss action there.
await continueOnboarding(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Make it feel like home/i })).toBeVisible()
await continueOnboarding(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Set up notifications/i })).toBeVisible()
await continueOnboarding(orcaPage)
await expect(orcaPage.getByRole('heading', { name: /Set up Orca for agents/i })).toBeVisible()
await installSafeOnboardingFeatureSetupDeps(orcaPage)
await setupOnboardingFeatures(orcaPage)
@ -797,6 +790,6 @@ test.describe('Onboarding flow', () => {
expect(final.closedAt).toBeNull()
expect(final.outcome).toBeNull()
expect(final.checklist.dismissed).toBe(false)
expect(final.lastCompletedStep).toBe(4)
expect(final.lastCompletedStep).toBe(5)
})
})