Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions script/gen-chime.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// One-off generator for the bundled notification sound.
// Run with `node script/gen-chime.mjs`; writes src/renderer/src/assets/chime.wav.
import { writeFileSync, mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'

const RATE = 44100
const DURATION = 0.55
const frames = Math.round(RATE * DURATION)

// Two plucked partials a fifth apart, the second struck slightly later: a soft
// "ti-ding" that reads as a completion rather than an alert.
const notes = [
{ freq: 880, start: 0, gain: 0.55 },
{ freq: 1318.51, start: 0.09, gain: 0.45 }
]

const samples = new Float32Array(frames)
for (const note of notes) {
const offset = Math.round(note.start * RATE)
for (let i = offset; i < frames; i++) {
const t = (i - offset) / RATE
// Exponential decay for the body, short attack ramp so it never clicks.
const decay = Math.exp(-4.2 * t)
const attack = Math.min(1, t / 0.004)
const partial =
Math.sin(2 * Math.PI * note.freq * t) + 0.22 * Math.sin(2 * Math.PI * note.freq * 2 * t)
samples[i] += note.gain * attack * decay * partial
}
}

// Normalize, then fade the tail to zero so the buffer ends in silence.
let peak = 0
for (const s of samples) peak = Math.max(peak, Math.abs(s))
const fadeFrames = Math.round(0.02 * RATE)
const pcm = Buffer.alloc(frames * 2)
for (let i = 0; i < frames; i++) {
const fade = i > frames - fadeFrames ? (frames - i) / fadeFrames : 1
const v = Math.max(-1, Math.min(1, (samples[i] / peak) * 0.85 * fade))
pcm.writeInt16LE(Math.round(v * 32767), i * 2)
}

const header = Buffer.alloc(44)
header.write('RIFF', 0)
header.writeUInt32LE(36 + pcm.length, 4)
header.write('WAVE', 8)
header.write('fmt ', 12)
header.writeUInt32LE(16, 16)
header.writeUInt16LE(1, 20) // PCM
header.writeUInt16LE(1, 22) // mono
header.writeUInt32LE(RATE, 24)
header.writeUInt32LE(RATE * 2, 28)
header.writeUInt16LE(2, 32)
header.writeUInt16LE(16, 34)
header.write('data', 36)
header.writeUInt32LE(pcm.length, 40)

const out = join(dirname(fileURLToPath(import.meta.url)), '../src/renderer/src/assets/chime.wav')
mkdirSync(dirname(out), { recursive: true })
writeFileSync(out, Buffer.concat([header, pcm]))
console.log(`wrote ${out} (${header.length + pcm.length} bytes)`)
11 changes: 10 additions & 1 deletion src/main/db/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,10 @@ export function getSettings(): AppSettings {
// (or a language later dropped from the app) must degrade to English rather
// than leave the UI rendering raw keys.
language: normalizeLanguage(map.get('language')),
activeThemeId: map.get('active_theme_id') ?? null
activeThemeId: map.get('active_theme_id') ?? null,
// Defaults ON, so the absence of a row means enabled - same convention as
// auto_workstream above, and for the same reason: no migration needed.
notifyOnComplete: map.get('notify_on_complete') !== '0'
}
}

Expand Down Expand Up @@ -232,6 +235,12 @@ export function setAutoWorkstream(enabled: boolean): AppSettings {
return getSettings()
}

export function setNotifyOnComplete(enabled: boolean): AppSettings {
// Store only the OFF state; see getSettings for why.
setSetting('notify_on_complete', enabled ? null : '0')
return getSettings()
}

export function completeOnboarding(): AppSettings {
setSetting('onboarding_completed', '1')
return getSettings()
Expand Down
24 changes: 22 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { app, shell, BrowserWindow } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import macDockIcon from '../../resources/icon-mac.png?asset'
import { registerIpc } from './ipc'
Expand All @@ -10,6 +10,12 @@ import { listModels } from './services/models'
import { backfillUsageFromHistory } from './services/usage'
import { listConnectedProviders } from './db/repo'
import { setAppIcon, closeAll as closeAllBrowsers } from './services/browser'
import {
APP_USER_MODEL_ID,
setToastIcon,
setToastWindow,
setWindowFactory
} from './services/notifications'
import { cleanupToolOutputs } from './services/tool-output-store'
import { cancelAllBackgroundJobs } from './services/background-tasks'
import { shutdownAllLsp } from './services/lsp'
Expand Down Expand Up @@ -77,6 +83,10 @@ function createWindow(): BrowserWindow {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}

// Clicking a completion toast has to raise THIS window, not whichever one
// happens to be first in getAllWindows() (the agent browser opens its own).
setToastWindow(mainWindow)

return mainWindow
}

Expand All @@ -98,10 +108,16 @@ async function warmCatalogThenBackfill(): Promise<void> {
}

app.whenReady().then(() => {
electronApp.setAppUserModelId('com.roxy.app')
// NOT electronApp.setAppUserModelId from @electron-toolkit/utils: in dev it
// substitutes `process.execPath`, and Windows prints the AUMID verbatim as
// the toast's header - which is how a full C:\Users\... path ended up above
// every notification. The id is constant so dev matches what ships.
app.setAppUserModelId(APP_USER_MODEL_ID)
// Give the agent's browser window the Roxy icon too (no asset import in the
// browser service so the smoke's esbuild bundle stays happy).
setAppIcon(icon)
// Same icon on the toast; see the note in services/notifications.ts.
setToastIcon(icon)
// Inject the tuned per-model + per-agent prompt text into the harness (imported
// via `?raw` here in the Vite-built entry, so the esbuild smoke bundle never
// sees it).
Expand Down Expand Up @@ -133,6 +149,10 @@ app.whenReady().then(() => {
// backfilled rows can be priced (else they'd all cost $0). Best-effort + async.
void warmCatalogThenBackfill()

// A toast clicked with every window closed (macOS keeps the app running)
// has to be able to open one.
setWindowFactory(createWindow)

const mainWindow = createWindow()
initAutoUpdater(mainWindow)

Expand Down
11 changes: 11 additions & 0 deletions src/main/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type {
QueueImage,
ReasoningEffort
} from '../../shared/types'
import * as notifications from '../services/notifications'
import * as repo from '../db/repo'
import * as copilot from '../services/copilot'
import * as cliproxy from '../services/cliproxy'
Expand Down Expand Up @@ -210,6 +211,9 @@ export function registerIpc(): void {
ipcMain.handle(CHANNELS.settingsSetBranchPrefix, (_e, prefix: string) =>
repo.setBranchPrefix(prefix)
)
ipcMain.handle(CHANNELS.settingsSetNotifyOnComplete, (_e, enabled: boolean) =>
repo.setNotifyOnComplete(enabled)
)
ipcMain.handle(CHANNELS.settingsCompleteOnboarding, () => repo.completeOnboarding())
ipcMain.handle(CHANNELS.settingsReset, async () => {
// "Wipes all providers" has to include the subscription tokens held by the
Expand All @@ -230,6 +234,13 @@ export function registerIpc(): void {
setTrackingEnabled(enabled)
)

// ---- notifications ----
// Both strings arrive translated - see the note on the api type.
ipcMain.handle(CHANNELS.notifyToast, (_e, title: string, body: string, chatId: string) => {
notifications.showTurnToast(title, body, chatId)
})
ipcMain.handle(CHANNELS.notifyTakePending, () => notifications.takePendingActivation())

// ---- providers ----
ipcMain.handle(CHANNELS.providersList, () => repo.listConnectedProviders())
ipcMain.handle(CHANNELS.providersConnect, (_e, input: ConnectProviderInput) => {
Expand Down
198 changes: 198 additions & 0 deletions src/main/services/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/**
* Turn-completion notifications - the main-process half.
*
* One job, and it has to live here rather than in the renderer: the native OS
* toast. The renderer's Web `Notification` also reaches the OS, but it gives no
* way to focus the window when the toast is clicked, which is the entire point
* of the notification.
*
* The SOUND is not here at all. It is a bundled asset played by the renderer,
* which is the only side with audio output.
*
* Deliberately says nothing about WHEN to notify: that decision is the
* renderer's, because only it knows whether the turn was stopped, whether a
* queue is still draining, whether the window is focused, and what the strings
* say.
*/
import { app, BrowserWindow, Notification, nativeImage } from 'electron'
import { CHANNELS } from '../../shared/ipc'

/**
* The app's identity for Windows toasts. Windows renders this string as the
* toast's header, so it must be the stable id and never a filesystem path.
*/
export const APP_USER_MODEL_ID = 'com.roxy.app'

const isWindows = process.platform === 'win32'
const isMac = process.platform === 'darwin'

/**
* Toasts still awaiting a click, newest last.
*
* A shown `Notification` that nothing references can be garbage collected, and
* with it the `click` handler - while the toast is still sitting in the Windows
* Action Center, clickable. Holding it here is what makes "click the toast,
* land on that session" work minutes later.
*
* Bounded, because `close` cannot be trusted to arrive: a Windows toast that
* times out into the Action Center and is dismissed from there often never
* fires it, and macOS is no better. Left uncapped this would retain one
* Notification per completed turn for the life of the process.
*
* Dropping the OLDEST is the right eviction: Windows itself keeps only about
* 20 toasts per app in the Action Center, so anything past the cap is already
* gone from the UI and can no longer be clicked.
*/
const live = new Set<Notification>()
const MAX_LIVE = 20

/** Roxy's icon, injected from main so this module needs no `?asset` import. */
let iconPath: string | undefined
export function setToastIcon(p: string): void {
iconPath = p
}

/**
* The window a clicked toast should raise.
*
* Not `getAllWindows()[0]`: the agent browser (services/browser.ts) opens its
* own BrowserWindow with the same preload, so the first entry can be that one -
* the click would raise a browser window and send `notifyActivated` to a
* renderer with no handler for it, leaving the toast apparently dead. The rest
* of main broadcasts to every window instead, which is right for a data push
* and wrong here: this one FOCUSES whatever it sends to.
*
* Re-registered by `createWindow`, so the reference survives the window being
* closed and recreated on macOS `activate`.
*/
let toastWindow: BrowserWindow | null = null
export function setToastWindow(win: BrowserWindow): void {
toastWindow = win
win.on('closed', () => {
if (toastWindow === win) toastWindow = null
})
}

/**
* How to get a window back when there is none.
*
* A macOS-only path, and the reason it has to exist: `window-all-closed` does
* not quit on darwin, so the app can sit in the dock with no window while its
* toasts sit in Notification Center. Clicking one then has nothing to raise.
* On Windows the app is already gone in that situation.
*/
let openWindow: (() => BrowserWindow) | null = null
export function setWindowFactory(fn: () => BrowserWindow): void {
openWindow = fn
}

/**
* A click that arrived before there was a renderer to tell, held until one
* asks for it. Pushing at a window that is still loading would be lost: the
* store subscribes to `notifyActivated` partway through bootstrap, well after
* `did-finish-load`, so main cannot know when the listener is up. The renderer
* pulls instead, at the moment it subscribes.
*/
let pendingChatId: string | null = null
export function takePendingActivation(): string | null {
const id = pendingChatId
pendingChatId = null
return id
}

/**
* A `file:///` URI Windows will actually load. `encodeURI` matters: the app can
* sit under a path with spaces (a user folder like "Jair Escamilla" is the
* common case) and an unencoded space silently drops the image, leaving the
* generic placeholder that this template exists to replace.
*/
function fileUri(p: string): string {
return 'file:///' + encodeURI(p.replace(/\\/g, '/'))
}

/** XML-escape text before it goes into a Windows toast template. */
function esc(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}

/**
* Bring the whole app forward, not just the window.
*
* macOS needs this: `BrowserWindow.focus()` raises the window within Roxy but
* cannot take focus from the editor you were actually in, so a clicked toast
* looked like it did nothing. `steal` is the documented way to say "the user
* asked for this", which a toast click is - it is not the app interrupting.
*/
function focusApp(): void {
if (isMac) app.focus({ steal: true })
}

/**
* Post a native OS toast for the session `chatId`.
*
* Clicking it focuses the window AND asks the renderer to open that session -
* which is the whole point of a per-session toast, and the reason this isn't the
* renderer's Web Notification API: only main can raise the window.
*
* `silent: true` always: Roxy plays its own chime, and letting the OS add its
* default alert on top produces two noises for one event.
*/
export function showTurnToast(title: string, body: string, chatId: string): void {
if (!Notification.isSupported()) return
const icon = iconPath ? nativeImage.createFromPath(iconPath) : undefined
const notification = new Notification({
title,
body,
silent: true,
// On macOS, `icon` sets `contentImage` (displayed as an attachment thumbnail
// on the right side of the banner). The application icon on the left is
// provided natively by the OS from the .app bundle (Roxy in packaged builds).
// Suppress `icon` on macOS so we don't display a redundant thumbnail on the right.
...(!isMac && icon && !icon.isEmpty() ? { icon } : {}),
// Windows ignores `icon` for the large circular avatar, so ask for the
// template explicitly. `ToastGeneric` + a `appLogoOverride` crop gives the
// rounded app icon at the top-left instead of the generic placeholder.
...(isWindows && iconPath
? {
toastXml: `<toast activationType="foreground">
<visual>
<binding template="ToastGeneric">
<image placement="appLogoOverride" hint-crop="circle" src="${esc(fileUri(iconPath))}"/>
<text hint-maxLines="1">${esc(title)}</text>
<text>${esc(body)}</text>
</binding>
</visual>
<audio silent="true"/>
</toast>`
}
: {})
})
live.add(notification)
if (live.size > MAX_LIVE) live.delete(live.values().next().value as Notification)
notification.on('close', () => live.delete(notification))
notification.on('click', () => {
live.delete(notification)
const existing = toastWindow && !toastWindow.isDestroyed() ? toastWindow : null
if (!existing) {
// No window (macOS, everything closed): reopen, and leave the session id
// for the new renderer to collect once it is listening.
pendingChatId = chatId
openWindow?.()
focusApp()
return
}
if (existing.isMinimized()) existing.restore()
existing.show()
existing.focus()
focusApp()
// Raising the window is not enough: it comes back on whatever session was
// last open, which is exactly the one you did NOT get notified about.
existing.webContents.send(CHANNELS.notifyActivated, chatId)
})
notification.show()
}
Loading
Loading