Revamp onboarding notification setup (#2670)
This commit is contained in:
parent
de96ac5678
commit
58accdf8a8
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.
|
|
@ -80,6 +80,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
|||
agentTaskComplete: true,
|
||||
terminalBell: false,
|
||||
suppressWhenFocused: true,
|
||||
customSoundId: 'system',
|
||||
customSoundPath: null,
|
||||
customSoundVolume: 100
|
||||
},
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
|||
agentTaskComplete: true,
|
||||
terminalBell: false,
|
||||
suppressWhenFocused: true,
|
||||
customSoundId: 'system',
|
||||
customSoundPath: null,
|
||||
customSoundVolume: 100
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"')
|
||||
})
|
||||
})
|
||||
|
|
@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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→`)
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ function createSettings(): GlobalSettings {
|
|||
agentTaskComplete: true,
|
||||
terminalBell: true,
|
||||
suppressWhenFocused: true,
|
||||
customSoundId: 'system',
|
||||
customSoundPath: null,
|
||||
customSoundVolume: 50
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -441,6 +441,7 @@ const onboardingValueKindSchema = z.enum([
|
|||
'agent',
|
||||
'theme',
|
||||
'notifications',
|
||||
'agent_setup',
|
||||
'integrations',
|
||||
'repo'
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue