Compare commits

...

3 Commits

Author SHA1 Message Date
Brooklyn Nicholson
7521de42f4 refactor(desktop): dock terminal under chat and simplify file rail
Keep the right rail focused on file browsing while moving the persistent terminal into the chat column bottom slot, and make terminal colors follow the active light/dark mode instead of a fixed Solarized palette.
2026-06-08 21:10:24 -05:00
Brooklyn Nicholson
02e56da0fc refactor(desktop): drop done1 byte sample from completion bank
Keep the curated Web Audio presets only; the embedded sample added bulk without shipping as the default cue.
2026-06-08 19:53:58 -05:00
Brooklyn Nicholson
5e3c5baf82 feat(desktop): add curated completion sound bank for turn completion
Replace the prior haptic-only completion cue with a curated Web Audio completion sound flow, defaulting to the minimal two-note comfort preset while keeping alternate presets available for quick iteration. Play the cue on every message completion event (including background sessions) so turn-end feedback is consistent across active and non-active chats.
2026-06-08 19:50:27 -05:00
10 changed files with 370 additions and 157 deletions

View File

@@ -10,6 +10,7 @@ import type * as React from 'react'
import { Suspense, useCallback, useMemo, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { TerminalSlot } from '@/app/right-sidebar/terminal/persistent'
import { Thread } from '@/components/assistant-ui/thread'
import { Backdrop } from '@/components/Backdrop'
import { PromptOverlays } from '@/components/prompt-overlays'
@@ -388,6 +389,9 @@ export function ChatView({
<ChatDropOverlay kind={dragKind} />
<ChatSwapOverlay profile={gatewaySwapTarget} />
</div>
<section className="flex h-56 min-h-[10rem] shrink-0 border-t border-(--ui-stroke-secondary) bg-(--ui-editor-surface-background)">
<TerminalSlot />
</section>
</div>
)
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { setRightSidebarTab } from '@/app/right-sidebar/store'
import { setTerminalTakeover } from '@/app/right-sidebar/store'
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
import { matchesQuery } from '@/hooks/use-media-query'
import { PROFILE_SLOT_COUNT } from '@/lib/keybinds/actions'
@@ -84,9 +84,9 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
}
}
const showRightSidebarTab = (tab: 'files' | 'terminal') => {
const showFiles = () => {
setFileBrowserOpen(true)
setRightSidebarTab(tab)
setTerminalTakeover(false)
}
handlersRef.current = {
@@ -128,8 +128,8 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
toggleFileBrowserOpen()
}
},
'view.showFiles': () => showRightSidebarTab('files'),
'view.showTerminal': () => showRightSidebarTab('terminal'),
'view.showFiles': showFiles,
'view.showTerminal': () => setTerminalTakeover(true),
'view.flipPanes': togglePanesFlipped,
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),

View File

@@ -4,22 +4,20 @@ import type { ReactNode } from 'react'
import { ErrorBoundary } from '@/components/error-boundary'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentBranch, $currentCwd } from '@/store/session'
import { $currentCwd } from '@/store/session'
import { SidebarPanelLabel } from '../shell/sidebar-label'
import { ProjectTree } from './files/tree'
import { useProjectTree } from './files/use-project-tree'
import { $rightSidebarTab, $terminalTakeover, type RightSidebarTabId, setRightSidebarTab } from './store'
import { TerminalSlot } from './terminal/persistent'
interface RightSidebarPaneProps {
onActivateFile: (path: string) => void
@@ -27,24 +25,10 @@ interface RightSidebarPaneProps {
onChangeCwd: (path: string) => Promise<void> | void
}
interface RightSidebarTab {
icon: string
id: RightSidebarTabId
labelKey: 'files' | 'terminal'
}
const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
{ id: 'files', labelKey: 'files', icon: 'list-tree' },
{ id: 'terminal', labelKey: 'terminal', icon: 'terminal' }
]
export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd }: RightSidebarPaneProps) {
const { t } = useI18n()
const r = t.rightSidebar
const activeTab = useStore($rightSidebarTab)
const terminalTakeover = useStore($terminalTakeover)
const panesFlipped = useStore($panesFlipped)
const currentBranch = useStore($currentBranch).trim()
const currentCwd = useStore($currentCwd).trim()
const hasCwd = currentCwd.length > 0
@@ -68,7 +52,6 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
} = useProjectTree(currentCwd)
const canCollapse = Object.values(openState).some(Boolean)
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
const chooseFolder = async () => {
const selected = await window.hermesDesktop?.selectPaths({
@@ -97,8 +80,6 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
}
}
const tabs = terminalTakeover ? RIGHT_SIDEBAR_TABS.filter(tab => tab.id !== 'terminal') : RIGHT_SIDEBAR_TABS
return (
<aside
aria-label={r.aria}
@@ -109,85 +90,29 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
)}
>
<RightSidebarChrome activeTab={effectiveTab} branch={currentBranch} tabs={tabs} />
{effectiveTab === 'terminal' ? (
<TerminalSlot />
) : (
<FilesystemTab
canCollapse={canCollapse}
collapseNonce={collapseNonce}
cwd={currentCwd}
cwdName={cwdName}
data={data}
error={rootError}
hasCwd={hasCwd}
loading={rootLoading}
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onChangeFolder={chooseFolder}
onCollapseAll={collapseAll}
onLoadChildren={loadChildren}
onNodeOpenChange={setNodeOpen}
onPreviewFile={previewFile}
onRefresh={() => void refreshRoot()}
openState={openState}
/>
)}
<FilesystemTab
canCollapse={canCollapse}
collapseNonce={collapseNonce}
cwd={currentCwd}
cwdName={cwdName}
data={data}
error={rootError}
hasCwd={hasCwd}
loading={rootLoading}
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onChangeFolder={chooseFolder}
onCollapseAll={collapseAll}
onLoadChildren={loadChildren}
onNodeOpenChange={setNodeOpen}
onPreviewFile={previewFile}
onRefresh={() => void refreshRoot()}
openState={openState}
/>
</aside>
)
}
function RightSidebarChrome({
activeTab,
branch,
tabs
}: {
activeTab: RightSidebarTabId
branch: string
tabs: readonly RightSidebarTab[]
}) {
const { t } = useI18n()
const r = t.rightSidebar
return (
<header className="shrink-0 bg-transparent text-[0.75rem]">
<div className="flex items-center gap-2 px-2.5 py-1">
<nav aria-label={r.panelsAria} className="flex min-w-0 items-center gap-1">
{tabs.map(tab => {
const label = r[tab.labelKey]
return (
<Tip key={tab.id} label={label}>
<Button
aria-label={label}
aria-pressed={tab.id === activeTab}
className={cn(
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
)}
onClick={() => setRightSidebarTab(tab.id)}
size="icon-xs"
variant="ghost"
>
<Codicon name={tab.icon} size="0.875rem" />
</Button>
</Tip>
)
})}
</nav>
{branch && (
<span className="ml-auto flex min-w-0 items-center gap-1 text-[0.6875rem] text-(--ui-text-tertiary)">
<Codicon className="shrink-0" name="git-branch" size="0.75rem" />
<span className="truncate">{branch}</span>
</span>
)}
</div>
</header>
)
}
interface FilesystemTabProps extends FileTreeBodyProps {
canCollapse: boolean
cwdName: string

View File

@@ -2,14 +2,10 @@ import { atom } from 'nanostores'
import { persistBoolean, storedBoolean } from '@/lib/storage'
export type RightSidebarTabId = 'files' | 'git' | 'terminal' | 'web'
const TAKEOVER_KEY = 'hermes.desktop.terminalTakeover'
export const $rightSidebarTab = atom<RightSidebarTabId>('files')
export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false))
$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active))
export const setRightSidebarTab = (tab: RightSidebarTabId) => $rightSidebarTab.set(tab)
export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active)

View File

@@ -1,17 +1,19 @@
import '@xterm/xterm/css/xterm.css'
import { useStore } from '@nanostores/react'
import type { CSSProperties } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { useTheme } from '@/themes/context'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import { $terminalTakeover, setRightSidebarTab, setTerminalTakeover } from '../store'
import { $terminalTakeover, setTerminalTakeover } from '../store'
import { addSelectionShortcutLabel } from './selection'
import { addSelectionShortcutLabel, terminalTheme } from './selection'
import { useTerminalSession } from './use-terminal-session'
interface TerminalTabProps {
@@ -21,6 +23,9 @@ interface TerminalTabProps {
export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
const { t } = useI18n()
const { resolvedMode } = useTheme()
const theme = terminalTheme(resolvedMode)
const { addSelectionToChat, hostRef, selection, selectionStyle, shellName, status } = useTerminalSession({
cwd,
onAddSelectionToChat
@@ -30,22 +35,17 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
const label = takeover ? t.rightSidebar.terminalSplit : t.rightSidebar.terminalFocus
const toggleTakeover = () => {
// Pre-select the Terminal tab so the slot is ready to host us on return.
if (takeover) {
setRightSidebarTab('terminal')
}
setTerminalTakeover(!takeover)
}
return (
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
<div className="flex h-8 shrink-0 items-center gap-2 px-2.5">
<SidebarPanelLabel className="text-white!">{shellName}</SidebarPanelLabel>
<SidebarPanelLabel className="text-(--ui-text-secondary)!">{shellName}</SidebarPanelLabel>
<Tip label={label}>
<Button
aria-label={label}
className="ml-auto size-6 rounded-md text-white!"
className="ml-auto size-6 rounded-md text-(--ui-text-secondary)!"
onClick={toggleTakeover}
size="icon"
type="button"
@@ -55,7 +55,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
</Button>
</Tip>
</div>
<div className="relative min-h-0 flex-1 bg-[#002b36] p-2">
<div className="relative min-h-0 flex-1 p-2" style={{ backgroundColor: theme.background }}>
{status === 'starting' && (
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
<Loader
@@ -84,13 +84,12 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
</Button>
</div>
)}
{/* Outer div paints the dark inset; inner div is the xterm host so the
canvas sizes to the *content* area and p-2 shows as terminal padding.
Forcing screen/viewport bg avoids xterm's default black peeking
through the unused pixels below the last full row. */}
{/* Outer div paints terminal inset; inner div is the xterm host so the
canvas sizes to the content area and p-2 stays as terminal padding. */}
<div
className="h-full min-h-0 overflow-hidden text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-[#002b36]! [&_.xterm-viewport]:bg-[#002b36]!"
className="h-full min-h-0 overflow-hidden text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-[var(--terminal-bg)]! [&_.xterm-viewport]:bg-[var(--terminal-bg)]!"
ref={hostRef}
style={{ '--terminal-bg': theme.background } as CSSProperties}
/>
</div>
</div>

View File

@@ -2,7 +2,9 @@ import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { TERMINAL_BG } from './selection'
import { useTheme } from '@/themes/context'
import { terminalTheme } from './selection'
import { TerminalTab } from './index'
@@ -56,6 +58,8 @@ const sameRect = (a: Rect | null, b: Rect) =>
export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerminalProps) {
const slot = useStore($slot)
const { resolvedMode } = useTheme()
const theme = terminalTheme(resolvedMode)
const [rect, setRect] = useState<Rect | null>(null)
const [ready, setReady] = useState(false)
@@ -107,7 +111,7 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm
visibility: visible ? 'visible' : 'hidden',
pointerEvents: visible ? 'auto' : 'none',
zIndex: 4,
backgroundColor: TERMINAL_BG,
backgroundColor: theme.background,
contain: 'layout size paint'
}

View File

@@ -1,38 +1,55 @@
import type { ITheme, Terminal } from '@xterm/xterm'
import type { CSSProperties } from 'react'
// Solarized-derived palette, but with bright ANSI 815 promoted to real
// accent variants instead of Schoonover's UI grays. Hermes' TUI skins (gold,
// crimson, ...) emit bright SGR codes that would otherwise wash out to gray.
// We always render the dark canvas — the app's light surfaces can't host the
// default skin without dropping below readable contrast.
export const TERMINAL_BG = '#002b36'
const THEME: ITheme = {
background: TERMINAL_BG,
foreground: '#839496',
cursor: '#93a1a1',
cursorAccent: TERMINAL_BG,
selectionBackground: '#586e7555',
black: '#073642',
red: '#dc322f',
green: '#859900',
yellow: '#b58900',
blue: '#268bd2',
magenta: '#d33682',
cyan: '#2aa198',
white: '#eee8d5',
brightBlack: '#586e75',
brightRed: '#f25c54',
brightGreen: '#b3d437',
brightYellow: '#f7c948',
brightBlue: '#5fb3ff',
brightMagenta: '#ff6ab4',
brightCyan: '#5cd9c8',
brightWhite: '#fdf6e3'
const DARK_THEME: ITheme = {
background: '#0f172a',
foreground: '#dbe4ff',
cursor: '#f8fafc',
cursorAccent: '#0f172a',
selectionBackground: '#93c5fd55',
black: '#1e293b',
red: '#f87171',
green: '#4ade80',
yellow: '#facc15',
blue: '#60a5fa',
magenta: '#c084fc',
cyan: '#22d3ee',
white: '#e2e8f0',
brightBlack: '#64748b',
brightRed: '#fca5a5',
brightGreen: '#86efac',
brightYellow: '#fde047',
brightBlue: '#93c5fd',
brightMagenta: '#d8b4fe',
brightCyan: '#67e8f9',
brightWhite: '#f8fafc'
}
export const terminalTheme = (): ITheme => THEME
const LIGHT_THEME: ITheme = {
background: '#f8fafc',
foreground: '#1f2937',
cursor: '#111827',
cursorAccent: '#f8fafc',
selectionBackground: '#60a5fa44',
black: '#1f2937',
red: '#dc2626',
green: '#15803d',
yellow: '#a16207',
blue: '#1d4ed8',
magenta: '#9333ea',
cyan: '#0e7490',
white: '#d1d5db',
brightBlack: '#4b5563',
brightRed: '#ef4444',
brightGreen: '#22c55e',
brightYellow: '#eab308',
brightBlue: '#3b82f6',
brightMagenta: '#a855f7',
brightCyan: '#06b6d4',
brightWhite: '#111827'
}
export const terminalTheme = (mode: 'light' | 'dark'): ITheme => (mode === 'dark' ? DARK_THEME : LIGHT_THEME)
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')

View File

@@ -3,10 +3,11 @@ import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { WebglAddon } from '@xterm/addon-webgl'
import { Terminal } from '@xterm/xterm'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { triggerHaptic } from '@/lib/haptics'
import { useTheme } from '@/themes/context'
import { isAddSelectionShortcut, terminalSelectionAnchor, terminalSelectionLabel, terminalTheme } from './selection'
@@ -184,6 +185,9 @@ function quotePathForShell(path: string, shellName: string): string {
}
export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSessionOptions) {
const { resolvedMode } = useTheme()
const activeTheme = useMemo(() => terminalTheme(resolvedMode), [resolvedMode])
const initialThemeRef = useRef(activeTheme)
const hostRef = useRef<HTMLDivElement | null>(null)
const termRef = useRef<Terminal | null>(null)
const sessionIdRef = useRef<string | null>(null)
@@ -266,7 +270,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
lineHeight: 1.12,
macOptionIsMeta: true,
scrollback: 1000,
theme: terminalTheme()
theme: initialThemeRef.current
})
const fit = new FitAddon()
@@ -493,6 +497,14 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
}
}, [addSelectionToChat, cwd])
useEffect(() => {
const term = termRef.current
if (term) {
term.options.theme = activeTheme
}
}, [activeTheme])
return {
addSelectionToChat,
hostRef,

View File

@@ -15,6 +15,7 @@ import {
} from '@/lib/chat-messages'
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
import { playCompletionSound } from '@/lib/completion-sound'
import { triggerHaptic } from '@/lib/haptics'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setClarifyRequest } from '@/store/clarify'
@@ -781,9 +782,7 @@ export function useMessageStream({
flushQueuedDeltas(sessionId)
if (isActiveEvent) {
triggerHaptic('streamDone')
}
playCompletionSound()
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
completeAssistantMessage(sessionId, finalText)

View File

@@ -0,0 +1,257 @@
// Completion sound bank for agent turn-end cues.
// Runtime playback is pinned to a curated default (currently variant 8).
import { $hapticsMuted } from '@/store/haptics'
type OscType = OscillatorType
interface ToneSpec {
attack?: number
dur: number
freq: number
gain?: number
start?: number
type?: OscType
}
let ctx: AudioContext | null = null
function getCtx(): AudioContext | null {
if (typeof window === 'undefined') {
return null
}
try {
if (!ctx) {
const Ctor = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (!Ctor) {
return null
}
ctx = new Ctor()
}
// Autoplay policies can leave the context suspended until a gesture; a
// resume() here recovers it once the user has interacted with the window.
if (ctx.state === 'suspended') {
void ctx.resume().catch(() => undefined)
}
return ctx
} catch {
return null
}
}
// One enveloped oscillator voice → master. Linear attack into an exponential
// decay keeps the tail smooth and avoids the click you get ramping to zero.
function voice(ac: AudioContext, master: GainNode, t0: number, spec: ToneSpec) {
const osc = ac.createOscillator()
const env = ac.createGain()
const start = t0 + (spec.start ?? 0)
const peak = spec.gain ?? 0.5
const attack = spec.attack ?? 0.006
const end = start + spec.dur
osc.type = spec.type ?? 'sine'
osc.frequency.setValueAtTime(spec.freq, start)
env.gain.setValueAtTime(0.0001, start)
env.gain.exponentialRampToValueAtTime(Math.max(peak, 0.0002), start + attack)
env.gain.exponentialRampToValueAtTime(0.0001, end)
osc.connect(env)
env.connect(master)
osc.start(start)
osc.stop(end + 0.02)
}
let reverbImpulse: AudioBuffer | null = null
// A short, synthetic reverb tail (decaying noise impulse). Used as a subtle wet
// send so the chimes feel like they sit in a room rather than a tin can — the
// detail that nudges them from "arcade beep" toward "polished app". The impulse
// buffer is generated once and cached; each play gets a fresh, disposable
// convolver so connections never pile up on a shared node.
function makeReverb(ac: AudioContext): ConvolverNode {
if (!reverbImpulse) {
const seconds = 1.1
const length = Math.floor(ac.sampleRate * seconds)
reverbImpulse = ac.createBuffer(2, length, ac.sampleRate)
for (let channel = 0; channel < 2; channel += 1) {
const data = reverbImpulse.getChannelData(channel)
for (let i = 0; i < length; i += 1) {
// White noise with a steep exponential decay → smooth, short tail.
data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 3.2
}
}
}
const convolver = ac.createConvolver()
convolver.buffer = reverbImpulse
return convolver
}
export interface CompletionSoundVariant {
id: number
name: string
// `master` is warm (runs through low-pass + room tail).
play: (ac: AudioContext, master: GainNode, t0: number) => void
}
// Note frequencies (equal temperament). Everything lives in a low-mid register
// (C3C5) so the chimes feel warm and "appy" rather than bright and arcade-y.
const C3 = 130.81
const C4 = 261.63
const E4 = 329.63
const G4 = 392
const C5 = 523.25
const D5 = 587.33
const E5 = 659.25
const G5 = 783.99
const C6 = 1046.5
export const COMPLETION_SOUND_VARIANTS: readonly CompletionSoundVariant[] = [
{
id: 1,
name: 'Tiks success (MIT)',
play: (ac, master, t0) => {
// Ported from rexa-developer/tiks success(): tonic then fifth.
voice(ac, master, t0, { freq: C5, dur: 0.11, gain: 0.12, attack: 0.008, type: 'sine' })
voice(ac, master, t0 + 0.085, { freq: G5, dur: 0.16, gain: 0.12, attack: 0.008, type: 'sine' })
}
},
{
id: 2,
name: 'Seslen message (MIT)',
play: (ac, master, t0) => {
// Ported from productdevbook/seslen message(): soft two-tone bell.
voice(ac, master, t0, { freq: 880, dur: 0.28, gain: 0.1, attack: 0.01, type: 'sine' })
voice(ac, master, t0 + 0.08, { freq: 1320, dur: 0.34, gain: 0.085, attack: 0.01, type: 'sine' })
}
},
{
id: 3,
name: 'Seslen success chirp (MIT)',
play: (ac, master, t0) => {
// Ported from productdevbook/seslen success(): 660→990→1320 triangle.
const osc = ac.createOscillator()
const env = ac.createGain()
osc.type = 'triangle'
osc.frequency.setValueAtTime(660, t0)
osc.frequency.linearRampToValueAtTime(990, t0 + 0.08)
osc.frequency.linearRampToValueAtTime(1320, t0 + 0.18)
env.gain.setValueAtTime(0.0001, t0)
env.gain.linearRampToValueAtTime(0.11, t0 + 0.01)
env.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.32)
osc.connect(env)
env.connect(master)
osc.start(t0)
osc.stop(t0 + 0.34)
}
},
{
id: 4,
name: 'Tiks notify (MIT)',
play: (ac, master, t0) => {
// Ported from rexa-developer/tiks notify(): two-note rising figure.
voice(ac, master, t0, { freq: 880, dur: 0.18, gain: 0.1, attack: 0.008, type: 'sine' })
voice(ac, master, t0 + 0.1, { freq: 1320, dur: 0.3, gain: 0.1, attack: 0.008, type: 'sine' })
}
},
{
id: 5,
name: 'Seslen notify (MIT)',
play: (ac, master, t0) => {
// Ported from productdevbook/seslen notify(): 660-880-1320 sequence.
const notes = [660, 880, 1320]
notes.forEach((frequency, i) => {
const start = t0 + i * 0.1
voice(ac, master, start, { freq: frequency, dur: 0.16, gain: 0.095, attack: 0.01, type: 'sine' })
})
}
},
{
id: 6,
name: 'Seslen victory arpeggio (MIT)',
play: (ac, master, t0) => {
const notes = [C5, E5, G5, C6]
notes.forEach((frequency, i) => {
voice(ac, master, t0 + i * 0.09, { freq: frequency, dur: 0.24, gain: 0.12, attack: 0.008, type: 'triangle' })
})
}
},
{
id: 7,
name: 'Seslen level-up arpeggio (MIT)',
play: (ac, master, t0) => {
const notes = [C5, D5, E5, G5, C6]
notes.forEach((frequency, i) => {
voice(ac, master, t0 + i * 0.09, { freq: frequency, dur: 0.22, gain: 0.11, attack: 0.008, type: 'triangle' })
})
}
},
{
id: 8,
name: 'Two-note comfort (minimal)',
play: (ac, master, t0) => {
voice(ac, master, t0, { freq: E4, dur: 0.22, gain: 0.05, attack: 0.03, type: 'sine' })
voice(ac, master, t0 + 0.08, { freq: C4, dur: 0.52, gain: 0.07, attack: 0.08, type: 'sine' })
voice(ac, master, t0 + 0.08, { freq: C3, dur: 0.46, gain: 0.02, attack: 0.1, type: 'sine' })
}
}
] as const
const DEFAULT_COMPLETION_VARIANT_ID = 8
function playVariant(variantId: number) {
const variant = COMPLETION_SOUND_VARIANTS.find(v => v.id === variantId)
if (!variant) {
return
}
const ac = getCtx()
if (!ac) {
return
}
// Signal path: voices → master → low-pass → (dry + reverb send) → out.
// The low-pass sits low to keep things warm, and a small wet send adds the
// sense of space that makes the chime feel like part of a polished app.
const master = ac.createGain()
const tone = ac.createBiquadFilter()
tone.type = 'lowpass'
tone.frequency.setValueAtTime(3400, ac.currentTime)
tone.Q.setValueAtTime(0.4, ac.currentTime)
master.gain.setValueAtTime(0.7, ac.currentTime)
master.connect(tone)
const dry = ac.createGain()
dry.gain.setValueAtTime(0.92, ac.currentTime)
tone.connect(dry)
dry.connect(ac.destination)
const reverb = makeReverb(ac)
const wet = ac.createGain()
wet.gain.setValueAtTime(0.22, ac.currentTime)
tone.connect(reverb)
reverb.connect(wet)
wet.connect(ac.destination)
variant.play(ac, master, ac.currentTime + 0.01)
}
// Plays the fixed completion cue on any `message.complete`.
export function playCompletionSound() {
if ($hapticsMuted.get()) {
return
}
playVariant(DEFAULT_COMPLETION_VARIANT_ID)
}