diff --git a/resources/notification-sounds/beep.mp3 b/resources/notification-sounds/beep.mp3 new file mode 100644 index 000000000..8293e0fc0 Binary files /dev/null and b/resources/notification-sounds/beep.mp3 differ diff --git a/resources/notification-sounds/blip.mp3 b/resources/notification-sounds/blip.mp3 new file mode 100644 index 000000000..855780537 Binary files /dev/null and b/resources/notification-sounds/blip.mp3 differ diff --git a/resources/notification-sounds/blop.mp3 b/resources/notification-sounds/blop.mp3 new file mode 100644 index 000000000..3a617391c Binary files /dev/null and b/resources/notification-sounds/blop.mp3 differ diff --git a/resources/notification-sounds/bong.mp3 b/resources/notification-sounds/bong.mp3 new file mode 100644 index 000000000..8fc2248c8 Binary files /dev/null and b/resources/notification-sounds/bong.mp3 differ diff --git a/resources/notification-sounds/clack.mp3 b/resources/notification-sounds/clack.mp3 new file mode 100644 index 000000000..e64b3b35f Binary files /dev/null and b/resources/notification-sounds/clack.mp3 differ diff --git a/resources/notification-sounds/ding.mp3 b/resources/notification-sounds/ding.mp3 new file mode 100644 index 000000000..14faceb91 Binary files /dev/null and b/resources/notification-sounds/ding.mp3 differ diff --git a/resources/notification-sounds/sonar.mp3 b/resources/notification-sounds/sonar.mp3 new file mode 100644 index 000000000..b7ddf0811 Binary files /dev/null and b/resources/notification-sounds/sonar.mp3 differ diff --git a/resources/notification-sounds/thump.mp3 b/resources/notification-sounds/thump.mp3 new file mode 100644 index 000000000..c203801c6 Binary files /dev/null and b/resources/notification-sounds/thump.mp3 differ diff --git a/resources/notification-sounds/two-tone.mp3 b/resources/notification-sounds/two-tone.mp3 new file mode 100644 index 000000000..84f0f004a Binary files /dev/null and b/resources/notification-sounds/two-tone.mp3 differ diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index cd31d8f65..a4b595864 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -80,6 +80,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings agentTaskComplete: true, terminalBell: false, suppressWhenFocused: true, + customSoundId: 'system', customSoundPath: null, customSoundVolume: 100 }, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index c1c566257..fa13ab326 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -73,6 +73,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings agentTaskComplete: true, terminalBell: false, suppressWhenFocused: true, + customSoundId: 'system', customSoundPath: null, customSoundVolume: 100 }, diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index 5ab0e19a8..0159d0467 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -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 = new Ma ['.aac', 'audio/aac'], ['.flac', 'audio/flac'] ]) +const BUILT_IN_NOTIFICATION_SOUNDS: ReadonlyMap = 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 { 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 => { - 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) { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index cf99006a6..e9fffd8b0 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -197,6 +197,27 @@ function normalizeNotificationSettings(value: unknown): NotificationSettings { const defaults = getDefaultNotificationSettings() const candidate = value && typeof value === 'object' ? (value as Partial) : {} + 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 } } diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 7e41f45ab..3b1551612 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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; diff --git a/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx b/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx new file mode 100644 index 000000000..8a11419d6 --- /dev/null +++ b/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx @@ -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( + + ) + + 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"') + }) +}) diff --git a/src/renderer/src/components/onboarding/AgentFeatureSetupStep.tsx b/src/renderer/src/components/onboarding/AgentFeatureSetupStep.tsx new file mode 100644 index 000000000..e6f4a3e50 --- /dev/null +++ b/src/renderer/src/components/onboarding/AgentFeatureSetupStep.tsx @@ -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 ( + <> + + {featureSetupCommand ? ( + + ) : null} + + ) +} diff --git a/src/renderer/src/components/onboarding/AgentStep.test.tsx b/src/renderer/src/components/onboarding/AgentStep.test.tsx new file mode 100644 index 000000000..5bb6bc57d --- /dev/null +++ b/src/renderer/src/components/onboarding/AgentStep.test.tsx @@ -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( + + ) + + expect(html).toContain(`Show ${AGENT_CATALOG.length - 1} more agents→`) + }) + + it('labels the fallback agents summary as hide when expanded', () => { + const html = renderToStaticMarkup( + + ) + + expect(html).toContain('Hide agents') + expect(html).not.toContain(`Show ${AGENT_CATALOG.length - 1} more agents→`) + }) +}) diff --git a/src/renderer/src/components/onboarding/AgentStep.tsx b/src/renderer/src/components/onboarding/AgentStep.tsx index 5db0506ad..7fd3bd870 100644 --- a/src/renderer/src/components/onboarding/AgentStep.tsx +++ b/src/renderer/src/components/onboarding/AgentStep.tsx @@ -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 }: {fallbackRest.length > 0 && ( -
setOpenState(e.currentTarget.open)} - > - - Show {fallbackRest.length} more {hasDetected ? 'agents' : ''}→ - -
- {fallbackRest.map((agent) => ( - onSelect(agent.id, true)} - /> - ))} -
-
+ + + {fallbackRestLabel} + + +
+ {fallbackRest.map((agent) => ( + onSelect(agent.id, true)} + /> + ))} +
+
+
)} ) @@ -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} > -
+ {selected ? ( +
+ +
+ ) : null} +
diff --git a/src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx b/src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx index cd19cb277..1611979fc 100644 --- a/src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx +++ b/src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx @@ -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 ? : null} + {selected ? : null} {row.title} diff --git a/src/renderer/src/components/onboarding/NotificationStep.test.tsx b/src/renderer/src/components/onboarding/NotificationStep.test.tsx index df2763bb6..044951cd0 100644 --- a/src/renderer/src/components/onboarding/NotificationStep.test.tsx +++ b/src/renderer/src/components/onboarding/NotificationStep.test.tsx @@ -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( - + ) - 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') }) }) diff --git a/src/renderer/src/components/onboarding/NotificationStep.tsx b/src/renderer/src/components/onboarding/NotificationStep.tsx index 1d21e56e2..93c9b8a64 100644 --- a/src/renderer/src/components/onboarding/NotificationStep.tsx +++ b/src/renderer/src/components/onboarding/NotificationStep.tsx @@ -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) => Promise | 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(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 ( - <> -
- {rows.map((row, idx) => ( - - ))} + }, []) + + const updateNotificationSettings = async ( + updates: Partial + ): Promise => { + const current = notificationSettingsRef.current + if (!current) { + return + } + const nextNotifications = { + ...current, + ...updates + } + notificationSettingsRef.current = nextNotifications + await updateSettings({ + notifications: nextNotifications + }) + } + + const handleMacPermission = async (): Promise => { + setShowMacSettingsPreview(true) + const status = await window.api.notifications.requestPermission() + setPermissionStatus(status) + await window.api.notifications.openSystemSettings() + } + + const previewSound = async ( + customSoundId: GlobalSettings['notifications']['customSoundId'] + ): Promise => { + 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 => { + await updateNotificationSettings({ customSoundId }) + await previewSound(customSoundId) + } + + const handleChooseCustomSound = async (): Promise => { + 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 => { + if (!notificationSettings) { + toast.error('Notification settings are still loading') + return + } + await sendNotificationSettingsTestNotification(notificationSettings, volumeDraft) + } + + if (!notificationSettings) { + return ( +
+ Loading notification settings…
-

- Configure other agent status personalization, like custom sounds, under{' '} - Settings → Notifications. -

- - {featureSetupCommand ? ( - + ) + } + + 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 ( +
+ {isMac ? ( +
+
+
+
+ + Allow Orca in macOS +
+

+ macOS controls notifications per app. Open System Settings and make sure Orca is + allowed to show alerts and play sounds. +

+
+ +
+ {showMacSettingsPreview ? ( +
+
+
+
+ +
+
+
Allow notifications
+
Orca
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 9:41 +
+
+
+
+ +
+
+ ) : null} +
) : null} - + +
+
+
+

Choose a sound

+

+ Pick the alert Orca plays after a desktop notification is delivered. +

+
+ +
+ +
+ {soundOptions.map((option) => { + const selected = selectedSoundId === option.id + const OptionIcon = option.icon + return ( + + ) + })} +
+ + {canAdjustVolume ? ( +
+
+ + setVolumeDraft(value)} + onValueCommit={([value]) => handleVolumeCommit(value)} + className="flex-1" + aria-label="Notification sound volume" + /> + + {volumeDraft}% + +
+
+ ) : null} +
+ + + + + + +
+
+
+
+ + Upload a sound +
+

+ MP3, WAV, OGG, M4A, AAC, or FLAC. Orca stores only the local file path. +

+ {customPath ? ( +

+ {customPath} +

+ ) : null} +
+ +
+
+
+
+
) } diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.tsx index db72c2d30..b92fa0a7e 100644 --- a/src/renderer/src/components/onboarding/OnboardingFlow.tsx +++ b/src/renderer/src/components/onboarding/OnboardingFlow.tsx @@ -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 + 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
)} -

- {copy.title} -

-

- {copy.subtitle} -

+
+
+

+ {copy.title} +

+

+ {copy.subtitle} +

+
+ +
@@ -188,9 +219,10 @@ export default function OnboardingFlow({ /> )} {currentStep.id === 'notifications' && ( - + )} + {currentStep.id === 'agentSetup' && ( + ) => Promise | 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, diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow-types.ts b/src/renderer/src/components/onboarding/use-onboarding-flow-types.ts index 0db737aa4..832511e97 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow-types.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow-types.ts @@ -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' } ] diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.ts index 7e0d9593b..1340098bf 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow.ts @@ -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(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({ - agentTaskComplete: true, - terminalBell: true, - notifyWhenFocused: true - }) const [featureSetupSelection, setFeatureSetupSelection] = useState(DEFAULT_ONBOARDING_FEATURE_SETUP_SELECTION) const [featureSetupTerminalCommand, setFeatureSetupTerminalCommand] = useState( @@ -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, diff --git a/src/renderer/src/components/settings/NotificationsPane.test.tsx b/src/renderer/src/components/settings/NotificationsPane.test.tsx index 0cc38b18a..68eca3763 100644 --- a/src/renderer/src/components/settings/NotificationsPane.test.tsx +++ b/src/renderer/src/components/settings/NotificationsPane.test.tsx @@ -23,6 +23,7 @@ function createSettings(): GlobalSettings { agentTaskComplete: true, terminalBell: true, suppressWhenFocused: true, + customSoundId: 'system', customSoundPath: null, customSoundVolume: 50 } diff --git a/src/renderer/src/components/settings/NotificationsPane.tsx b/src/renderer/src/components/settings/NotificationsPane.tsx index 2d419ccff..4855927fd 100644 --- a/src/renderer/src/components/settings/NotificationsPane.tsx +++ b/src/renderer/src/components/settings/NotificationsPane.tsx @@ -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" > @@ -295,7 +296,7 @@ export function NotificationsPane({ ) : null}
- {selectedSoundPath ? ( + {notificationSettings.customSoundId !== 'system' ? (
{ if (result.delivered) { - void playDesktopNotificationSound(customSoundPath, customSoundVolume) + void playDesktopNotificationSound(customSoundId, customSoundVolume) } }) .catch((err) => { diff --git a/src/renderer/src/components/ui/collapsible.tsx b/src/renderer/src/components/ui/collapsible.tsx new file mode 100644 index 000000000..626887752 --- /dev/null +++ b/src/renderer/src/components/ui/collapsible.tsx @@ -0,0 +1,24 @@ +'use client' + +import * as React from 'react' +import { Collapsible as CollapsiblePrimitive } from 'radix-ui' + +function Collapsible({ + ...props +}: React.ComponentProps): React.JSX.Element { + return +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps): React.JSX.Element { + return +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps): React.JSX.Element { + return +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/src/renderer/src/lib/desktop-notification-sound.ts b/src/renderer/src/lib/desktop-notification-sound.ts index b96e46ecf..0cb386820 100644 --- a/src/renderer/src/lib/desktop-notification-sound.ts +++ b/src/renderer/src/lib/desktop-notification-sound.ts @@ -1,8 +1,8 @@ export async function playDesktopNotificationSound( - customSoundPath: string | null | undefined, + customSoundId: string | null | undefined, customSoundVolume?: number | null ): Promise { - if (!customSoundPath) { + if (!customSoundId || customSoundId === 'system') { return false } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 83cbd6eeb..6ac7f54c9 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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 } diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 9aaf258ae..af051f2ba 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -441,6 +441,7 @@ const onboardingValueKindSchema = z.enum([ 'agent', 'theme', 'notifications', + 'agent_setup', 'integrations', 'repo' ]) diff --git a/src/shared/types.ts b/src/shared/types.ts index 2d42c7fe8..7d423a5b4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 } diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index 8f6113988..c2a46ca5c 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -125,10 +125,10 @@ async function setupOnboardingFeatures(page: Page): Promise { async function continueFromFeatureSetupToRepo(page: Page): Promise { 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) }) })