diff --git a/script/gen-chime.mjs b/script/gen-chime.mjs new file mode 100644 index 0000000..c06b995 --- /dev/null +++ b/script/gen-chime.mjs @@ -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)`) diff --git a/src/main/db/repo.ts b/src/main/db/repo.ts index 0b0f9be..b1f8628 100644 --- a/src/main/db/repo.ts +++ b/src/main/db/repo.ts @@ -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' } } @@ -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() diff --git a/src/main/index.ts b/src/main/index.ts index 06d0ee2..d35b177 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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' @@ -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' @@ -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 } @@ -98,10 +108,16 @@ async function warmCatalogThenBackfill(): Promise { } 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). @@ -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) diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 4116a02..40dca7b 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -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' @@ -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 @@ -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) => { diff --git a/src/main/services/notifications.ts b/src/main/services/notifications.ts new file mode 100644 index 0000000..f9d2dfd --- /dev/null +++ b/src/main/services/notifications.ts @@ -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() +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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +/** + * 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: ` + + + + ${esc(title)} + ${esc(body)} + + + ` + } + : {}) + }) + 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() +} diff --git a/src/preload/index.ts b/src/preload/index.ts index c671b0b..9598090 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -30,12 +30,29 @@ const roxy: RoxyApi = { setContextLimit: (limit) => ipcRenderer.invoke(CHANNELS.settingsSetContextLimit, limit), setAutoWorkstream: (enabled) => ipcRenderer.invoke(CHANNELS.settingsSetAutoWorkstream, enabled), setBranchPrefix: (prefix) => ipcRenderer.invoke(CHANNELS.settingsSetBranchPrefix, prefix), + setNotifyOnComplete: (enabled) => + ipcRenderer.invoke(CHANNELS.settingsSetNotifyOnComplete, enabled), setLanguage: (language) => ipcRenderer.invoke(CHANNELS.settingsSetLanguage, language), completeOnboarding: () => ipcRenderer.invoke(CHANNELS.settingsCompleteOnboarding), reset: () => ipcRenderer.invoke(CHANNELS.settingsReset), getTelemetry: () => ipcRenderer.invoke(CHANNELS.settingsGetTelemetry), setTelemetry: (enabled) => ipcRenderer.invoke(CHANNELS.settingsSetTelemetry, enabled) }, + notifications: { + toast: (title, body, chatId) => ipcRenderer.invoke(CHANNELS.notifyToast, title, body, chatId), + onActivated: (callback) => { + const handler = (_event: Electron.IpcRendererEvent, chatId: string): void => callback(chatId) + ipcRenderer.on(CHANNELS.notifyActivated, handler) + // Collect a click that landed before this window existed - on macOS the + // app outlives its windows, so the toast that reopened us has a session + // waiting. Asking here rather than being pushed to is what makes it + // race-free: by definition the listener above is already installed. + void ipcRenderer + .invoke(CHANNELS.notifyTakePending) + .then((chatId: string | null) => chatId && callback(chatId)) + return () => ipcRenderer.removeListener(CHANNELS.notifyActivated, handler) + } + }, providers: { listConnected: () => ipcRenderer.invoke(CHANNELS.providersList), connect: (input) => ipcRenderer.invoke(CHANNELS.providersConnect, input), diff --git a/src/renderer/src/assets/chime.wav b/src/renderer/src/assets/chime.wav new file mode 100644 index 0000000..438e6df Binary files /dev/null and b/src/renderer/src/assets/chime.wav differ diff --git a/src/renderer/src/components/NotificationSettings.tsx b/src/renderer/src/components/NotificationSettings.tsx new file mode 100644 index 0000000..c649d09 --- /dev/null +++ b/src/renderer/src/components/NotificationSettings.tsx @@ -0,0 +1,53 @@ +import { useTranslation } from 'react-i18next' +import { Play } from 'lucide-react' +import { Button, Switch } from './ui' +import { useRoxyStore } from '../lib/store' +import { playNotificationSound } from '../lib/notify' + +/** + * Notification preferences: a single switch, plus a way to hear the chime. + * + * Deliberately one control and not four. Sound, OS toast and a never/unfocused/ + * always picker were separate ways of asking one question -- "tell me when it's + * done" -- and their combinations (toast but no sound, sound only when + * unfocused) are decisions nobody wants to make about a chime. Staying quiet + * while the window is focused is not a preference either: it is what the + * feature should always do, so it lives in `shouldNotify`. + * + * The play button stays: it is the only way to know what you signed up for, and + * the click doubles as the gesture that unlocks Chromium's autoplay policy -- an + * app that has never been clicked in cannot play audio, so without it the first + * chime is swallowed. That is also why the button is never disabled: the person + * who just switched notifications ON and touched nothing else is exactly the one + * who needs to make that gesture. + */ +export function NotificationSettings(): JSX.Element { + const { t } = useTranslation() + const settings = useRoxyStore((s) => s.settings) + const setNotifyOnComplete = useRoxyStore((s) => s.setNotifyOnComplete) + const enabled = settings?.notifyOnComplete ?? true + + return ( +
+
+
+ {t('settings.notifications.completeTitle')} +
+

+ {t('settings.notifications.completeDescription')} +

+
+
+ + void setNotifyOnComplete(v)} /> +
+
+ ) +} diff --git a/src/renderer/src/lib/notify.ts b/src/renderer/src/lib/notify.ts new file mode 100644 index 0000000..d3e5c29 --- /dev/null +++ b/src/renderer/src/lib/notify.ts @@ -0,0 +1,73 @@ +/** + * Turn-completion notifications - the renderer's half. + * + * The sound is played here because the main process has no audio output at all. + * The OS toast is the other way round (see `main/services/notifications.ts`), + * since only main can focus the window when the toast is clicked. + * + * The decision of WHETHER to notify also lives on this side: only the renderer + * knows whether the user stopped the turn, whether a queue is still draining, + * and whether the window is focused. + */ +import chime from '../assets/chime.wav' +import { NOTIFY_VOLUME, type AppSettings } from '@shared/types' +import i18n from '../i18n' +import { api } from './api' + +/** + * One reused element rather than a fresh `Audio` per turn, so rapid completions + * restart the chime instead of stacking copies of it on top of each other. + */ +let element: HTMLAudioElement | null = null + +/** + * Play the notification chime. + * + * Never throws: autoplay may still be locked because the user has not + * interacted with the page yet, and that must not take the turn's cleanup down + * with it. + */ +export async function playNotificationSound(): Promise { + try { + element ??= new Audio(chime) + element.volume = NOTIFY_VOLUME + element.currentTime = 0 + await element.play() + } catch { + // A silent notification is the acceptable failure here. + } +} + +/** + * Whether a finished turn may notify right now. + * + * Being focused suppresses it unconditionally: you are already looking at the + * answer, so a chime is pure noise. `hasFocus()` rather than `document.hidden`, + * because a window sitting visible behind the editor is still one you are not + * watching. + */ +function shouldNotify(settings: AppSettings): boolean { + return settings.notifyOnComplete && !document.hasFocus() +} + +/** + * Announce that a session's turn finished: chime plus an OS toast. A no-op when + * notifications are off, or when you are already watching. + * + * The SESSION NAME is the toast's title: with several sessions running, which + * one finished is the only thing you actually need from the toast, and the + * Windows header above it already says Roxy. `sessionTitle` is user data and + * goes in untranslated; the rest is resolved here because main has no i18next. + */ +export function notifyTurnComplete( + settings: AppSettings, + sessionTitle: string, + chatId: string +): void { + if (!shouldNotify(settings)) return + void playNotificationSound() + // A session can genuinely have no title yet (notify fires on the first turn, + // which is what names it), and an empty toast heading looks broken. + const name = sessionTitle.trim() || i18n.t('notifications.turnCompleteUntitled') + void api.notifications.toast(name, i18n.t('notifications.turnCompleteBody'), chatId) +} diff --git a/src/renderer/src/lib/store.ts b/src/renderer/src/lib/store.ts index fb263ff..42573c2 100644 --- a/src/renderer/src/lib/store.ts +++ b/src/renderer/src/lib/store.ts @@ -41,6 +41,7 @@ import { import { uniqueSlug } from '@shared/slugs' import { shouldAutoWorkstream, statusKeyForSession } from '@shared/workstream' import { api } from './api' +import { notifyTurnComplete } from './notify' import type { ComposerImage } from './images' import type { GitStatusView, @@ -221,6 +222,7 @@ interface RoxyStore { setAutoWorkstream: (enabled: boolean) => Promise setTelemetryEnabled: (enabled: boolean) => Promise setBranchPrefix: (prefix: string) => Promise + setNotifyOnComplete: (enabled: boolean) => Promise setLanguage: (language: Language) => Promise selectChat: (id: string) => Promise clearActive: () => void @@ -366,6 +368,7 @@ const asChatId = (value: unknown): string | undefined => typeof value === 'string' ? value : undefined let loopTickSubscribed = false +let notifyActivatedSubscribed = false let llmDeltaSubscribed = false let taskUpdateSubscribed = false let remoteStateSubscribed = false @@ -1006,6 +1009,17 @@ export const useRoxyStore = create((set, get) => ({ // Warm the usage/cost dashboard for the titlebar pill (best-effort, async). void get().refreshUsage() + if (!notifyActivatedSubscribed) { + notifyActivatedSubscribed = true + api.notifications.onActivated(async (chatId) => { + // The session may have been deleted between the toast and the click, and + // selectChat on a missing id would blank the view for no reason. + if (!get().chats.some((c) => c.id === chatId)) await get().refreshChats() + if (!get().chats.some((c) => c.id === chatId)) return + await get().selectChat(chatId) + }) + } + if (!loopTickSubscribed) { loopTickSubscribed = true api.loops.onTick(async (loopId) => { @@ -1570,6 +1584,10 @@ export const useRoxyStore = create((set, get) => ({ set({ settings }) }, + setNotifyOnComplete: async (enabled) => { + set({ settings: await api.settings.setNotifyOnComplete(enabled) }) + }, + selectChat: async (id) => { // Per-chat send state survives switching — just swap which chat is shown. // Clear messages/queue first so the previous chat's content never flashes. @@ -1910,7 +1928,24 @@ export const useRoxyStore = create((set, get) => ({ await get().selectChat(chatId) } // Don't auto-run the next queued prompt when the user stopped this turn. - if (!wasStopped) await get().drainQueue(chatId) + // + // The notification is decided BEFORE draining: `drainQueue` awaits the + // whole chain it starts, so anything placed after that await runs once + // per queued prompt as the recursion unwinds - a queue of five would fire + // five notifications, all at the end. Checking the queue first means only + // the turn that leaves the session genuinely idle announces itself. + // + // A stopped turn stays silent either way: the user is already here, they + // just pressed the button. + if (!wasStopped) { + const pending = await api.queue.list(chatId) + const notifySettings = get().settings + if (pending.length === 0 && notifySettings) { + const title = get().chats.find((c) => c.id === chatId)?.title + notifyTurnComplete(notifySettings, title ?? '', chatId) + } + await get().drainQueue(chatId) + } } clearStop() diff --git a/src/renderer/src/locales/ar.json b/src/renderer/src/locales/ar.json index 82f350f..5ed4176 100644 --- a/src/renderer/src/locales/ar.json +++ b/src/renderer/src/locales/ar.json @@ -227,6 +227,10 @@ "selectModel": "اختر نموذجًا", "unpin": "إلغاء تثبيت النموذج" }, + "notifications": { + "turnCompleteBody": "جاهز لك.", + "turnCompleteUntitled": "جلسة بلا عنوان" + }, "onboarding": { "apiKey": "مفتاح API", "back": "رجوع", @@ -393,6 +397,12 @@ "someHidden": "{{shown}} of {{total}} shown · {{hidden}} hidden", "unhide": "Unhide" }, + "notifications": { + "completeDescription": "احصل على إشعار عندما ينتهي Roxy من الرد. مفيد للمهام الطويلة. لا يظهر إلا عندما لا يكون Roxy في المقدمة؛ انقر عليه للانتقال إلى تلك الجلسة.", + "completeTitle": "اكتمال الردود", + "heading": "الإشعارات", + "preview": "تشغيل" + }, "privacy": { "description": "العدد والتوقيتات — تشغيل التطبيق، الأدوار المكتملة، عدد الخطوات والأدوات التي استغرقها الدور، عدد الرموز المميزة التي استخدمها وما يقرب من تكلفتها — مرتبطة بمعرف عشوائي تم إنشاؤه على هذا الجهاز. لا يتم تضمين مطالباتك، أو التعليمات البرمجية الخاصة بك، أو مسارات الملفات، أو أسماء المستودعات، أو أي نص خطأ.", "heading": "الخصوصية", diff --git a/src/renderer/src/locales/de.json b/src/renderer/src/locales/de.json index 1184c8a..32a8b44 100644 --- a/src/renderer/src/locales/de.json +++ b/src/renderer/src/locales/de.json @@ -227,6 +227,10 @@ "selectModel": "Modell auswählen", "unpin": "Modell lösen" }, + "notifications": { + "turnCompleteBody": "Bereit für Sie.", + "turnCompleteUntitled": "Sitzung ohne Titel" + }, "onboarding": { "apiKey": "API-Schlüssel", "back": "Zurück", @@ -393,6 +397,12 @@ "someHidden": "{{shown}} of {{total}} shown · {{hidden}} hidden", "unhide": "Unhide" }, + "notifications": { + "completeDescription": "Erhalten Sie eine Benachrichtigung, wenn Roxy eine Antwort abgeschlossen hat. Nützlich für lang laufende Aufgaben. Wird nur ausgelöst, wenn Roxy nicht im Vordergrund ist; klicken Sie darauf, um zu dieser Sitzung zu springen.", + "completeTitle": "Abgeschlossene Antworten", + "heading": "Benachrichtigungen", + "preview": "Abspielen" + }, "privacy": { "description": "Zählungen und Zeitangaben – App-Starts, abgeschlossene Züge, wie viele Schritte und Tools ein Zug benötigte, wie viele Token er verbrauchte und was er ungefähr kostete – verknüpft mit einer zufälligen ID, die auf diesem Gerät generiert wurde. Niemals Ihre Prompts, Ihr Code, Dateipfade, Repo-Namen oder Fehlermeldungen.", "heading": "Datenschutz", diff --git a/src/renderer/src/locales/default.json b/src/renderer/src/locales/default.json index eb3dd1d..4e3e5b9 100644 --- a/src/renderer/src/locales/default.json +++ b/src/renderer/src/locales/default.json @@ -227,6 +227,10 @@ "sectionLatest": "Latest · {{provider}}", "allHidden": "Every model is hidden. Bring some back in Settings → Models." }, + "notifications": { + "turnCompleteBody": "Ready for you.", + "turnCompleteUntitled": "Untitled session" + }, "onboarding": { "welcomeTagline": "This might be a complete disaster. It might also be the best thing you use all year!", "continue": "Continue", @@ -356,6 +360,12 @@ "translatedBy": "Translations are community-maintained and may lag behind English.", "sourceLanguage": "Source language" }, + "notifications": { + "heading": "Notifications", + "completeTitle": "Response completions", + "completeDescription": "Get a notification when Roxy has finished a response. Useful for long-running tasks. Only fires when Roxy isn't focused; click it to jump to that session.", + "preview": "Play" + }, "workstreams": { "heading": "Workstreams", "autoTitle": "New sessions get their own workstream", diff --git a/src/renderer/src/locales/es.json b/src/renderer/src/locales/es.json index 6f58af7..e7b1a53 100644 --- a/src/renderer/src/locales/es.json +++ b/src/renderer/src/locales/es.json @@ -227,6 +227,10 @@ "selectModel": "Elige un modelo", "unpin": "Dejar de fijar el modelo" }, + "notifications": { + "turnCompleteBody": "Listo para ti.", + "turnCompleteUntitled": "Sesión sin título" + }, "onboarding": { "apiKey": "Clave de API", "back": "Atrás", @@ -393,6 +397,12 @@ "someHidden": "{{shown}} of {{total}} shown · {{hidden}} hidden", "unhide": "Unhide" }, + "notifications": { + "completeDescription": "Recibe una notificación cuando Roxy termine una respuesta. Útil para tareas largas. Solo se activa cuando Roxy no está en primer plano; haz clic para ir a esa sesión.", + "completeTitle": "Respuestas completadas", + "heading": "Notificaciones", + "preview": "Reproducir" + }, "privacy": { "description": "Recuentos y tiempos —arranques de la aplicación, turnos completados, cuántos pasos y herramientas necesitó un turno, cuántos tokens consumió y aproximadamente cuánto costó— asociados a un identificador aleatorio generado en este equipo. Nunca tus prompts, tu código, rutas de archivos, nombres de repositorios ni texto de errores.", "heading": "Privacidad", diff --git a/src/renderer/src/locales/fr.json b/src/renderer/src/locales/fr.json index f8b9e3d..37e8458 100644 --- a/src/renderer/src/locales/fr.json +++ b/src/renderer/src/locales/fr.json @@ -227,6 +227,10 @@ "selectModel": "Sélectionner un modèle", "unpin": "Désépingler le modèle" }, + "notifications": { + "turnCompleteBody": "Prêt pour vous.", + "turnCompleteUntitled": "Session sans titre" + }, "onboarding": { "apiKey": "Clé API", "back": "Retour", @@ -393,6 +397,12 @@ "someHidden": "{{shown}} of {{total}} shown · {{hidden}} hidden", "unhide": "Unhide" }, + "notifications": { + "completeDescription": "Recevez une notification lorsque Roxy a terminé une réponse. Utile pour les tâches longues. Ne se déclenche que lorsque Roxy n'est pas au premier plan ; cliquez dessus pour accéder à cette session.", + "completeTitle": "Réponses terminées", + "heading": "Notifications", + "preview": "Lire" + }, "privacy": { "description": "Comptes et chronométrages — lancements d'applications, tours terminés, nombre d'étapes et d'outils qu'un tour a pris, nombre de jetons utilisés et coût approximatif — liés à un identifiant aléatoire généré sur cette machine. Jamais vos invites, votre code, les chemins de fichiers, les noms de dépôts ou tout texte d'erreur.", "heading": "Confidentialité", diff --git a/src/renderer/src/locales/hi.json b/src/renderer/src/locales/hi.json index 4f4b7b8..a633f3e 100644 --- a/src/renderer/src/locales/hi.json +++ b/src/renderer/src/locales/hi.json @@ -227,6 +227,10 @@ "selectModel": "एक मॉडल चुनें", "unpin": "मॉडल अनपिन करें" }, + "notifications": { + "turnCompleteBody": "आपके लिए तैयार है।", + "turnCompleteUntitled": "बिना शीर्षक वाला सत्र" + }, "onboarding": { "apiKey": "API कुंजी", "back": "वापस", @@ -393,6 +397,12 @@ "someHidden": "{{shown}} of {{total}} shown · {{hidden}} hidden", "unhide": "Unhide" }, + "notifications": { + "completeDescription": "जब Roxy कोई प्रतिक्रिया पूरी कर ले तो सूचना पाएं। लंबे समय तक चलने वाले कार्यों के लिए उपयोगी। यह केवल तभी आती है जब Roxy फ़ोकस में न हो; उस सत्र पर जाने के लिए उस पर क्लिक करें।", + "completeTitle": "प्रतिक्रिया पूर्ण होना", + "heading": "सूचनाएं", + "preview": "चलाएं" + }, "privacy": { "description": "गणना और समय — ऐप लॉन्च, समाप्त मोड़, एक मोड़ में कितने कदम और उपकरण लगे, इसने कितने टोकन का उपयोग किया और मोटे तौर पर इसकी लागत कितनी थी — इस मशीन पर उत्पन्न एक यादृच्छिक आईडी से जुड़ा हुआ। कभी भी आपके प्रॉम्प्ट, आपका कोड, फ़ाइल पथ, रेपो नाम, या कोई त्रुटि पाठ नहीं।", "heading": "गोपनीयता", diff --git a/src/renderer/src/locales/ja.json b/src/renderer/src/locales/ja.json index 4566e60..67cb09a 100644 --- a/src/renderer/src/locales/ja.json +++ b/src/renderer/src/locales/ja.json @@ -227,6 +227,10 @@ "selectModel": "モデルを選択", "unpin": "モデルのピン留めを解除" }, + "notifications": { + "turnCompleteBody": "準備ができました。", + "turnCompleteUntitled": "無題のセッション" + }, "onboarding": { "apiKey": "APIキー", "back": "戻る", @@ -393,6 +397,12 @@ "someHidden": "{{shown}} of {{total}} shown · {{hidden}} hidden", "unhide": "Unhide" }, + "notifications": { + "completeDescription": "Roxy が応答を完了したときに通知を受け取ります。時間のかかるタスクに便利です。Roxy がフォーカスされていないときにのみ通知され、クリックするとそのセッションに移動します。", + "completeTitle": "応答の完了", + "heading": "通知", + "preview": "再生" + }, "privacy": { "description": "アプリの起動、完了したターン、ターンにかかったステップとツールの数、使用されたトークンの数、おおよそのコストなど、このマシンで生成されたランダムなIDに関連付けられたカウントとタイミング。プロンプト、コード、ファイルパス、リポジトリ名、エラーテキストは決して含まれません。", "heading": "プライバシー", diff --git a/src/renderer/src/locales/pt.json b/src/renderer/src/locales/pt.json index aa867a7..6161396 100644 --- a/src/renderer/src/locales/pt.json +++ b/src/renderer/src/locales/pt.json @@ -227,6 +227,10 @@ "selectModel": "Selecionar um modelo", "unpin": "Desafixar modelo" }, + "notifications": { + "turnCompleteBody": "Pronto para você.", + "turnCompleteUntitled": "Sessão sem título" + }, "onboarding": { "apiKey": "Chave de API", "back": "Voltar", @@ -393,6 +397,12 @@ "someHidden": "{{shown}} of {{total}} shown · {{hidden}} hidden", "unhide": "Unhide" }, + "notifications": { + "completeDescription": "Receba uma notificação quando o Roxy terminar uma resposta. Útil para tarefas longas. Só é acionada quando o Roxy não está em foco; clique nela para ir para essa sessão.", + "completeTitle": "Respostas concluídas", + "heading": "Notificações", + "preview": "Reproduzir" + }, "privacy": { "description": "Contagens e tempos — inicializações de aplicativos, turnos concluídos, quantos passos e ferramentas um turno levou, quantos tokens usou e aproximadamente quanto custou — vinculados a um ID aleatório gerado nesta máquina. Nunca seus prompts, seu código, caminhos de arquivo, nomes de repositórios ou qualquer texto de erro.", "heading": "Privacidade", diff --git a/src/renderer/src/locales/ru.json b/src/renderer/src/locales/ru.json index 4f9722a..1815787 100644 --- a/src/renderer/src/locales/ru.json +++ b/src/renderer/src/locales/ru.json @@ -227,6 +227,10 @@ "selectModel": "Выберите модель", "unpin": "Открепить модель" }, + "notifications": { + "turnCompleteBody": "Всё готово.", + "turnCompleteUntitled": "Сессия без названия" + }, "onboarding": { "apiKey": "Ключ API", "back": "Назад", @@ -393,6 +397,12 @@ "someHidden": "{{shown}} of {{total}} shown · {{hidden}} hidden", "unhide": "Unhide" }, + "notifications": { + "completeDescription": "Получайте уведомление, когда Roxy завершит ответ. Полезно для длительных задач. Срабатывает, только когда Roxy не в фокусе; нажмите, чтобы перейти к этой сессии.", + "completeTitle": "Завершение ответов", + "heading": "Уведомления", + "preview": "Воспроизвести" + }, "privacy": { "description": "Количество и время — запуски приложений, завершенные ходы, сколько шагов и инструментов занял ход, сколько токенов он использовал и примерно сколько это стоило — привязаны к случайному идентификатору, сгенерированному на этой машине. Никогда не ваши запросы, ваш код, пути к файлам, имена репозиториев или любой текст ошибки.", "heading": "Конфиденциальность", diff --git a/src/renderer/src/locales/zh.json b/src/renderer/src/locales/zh.json index d766805..d078989 100644 --- a/src/renderer/src/locales/zh.json +++ b/src/renderer/src/locales/zh.json @@ -227,6 +227,10 @@ "selectModel": "选择模型", "unpin": "取消固定模型" }, + "notifications": { + "turnCompleteBody": "已准备就绪。", + "turnCompleteUntitled": "未命名会话" + }, "onboarding": { "apiKey": "API 密钥", "back": "返回", @@ -393,6 +397,12 @@ "someHidden": "{{shown}} of {{total}} shown · {{hidden}} hidden", "unhide": "Unhide" }, + "notifications": { + "completeDescription": "当 Roxy 完成一次回复时收到通知。适合耗时较长的任务。仅在 Roxy 未处于焦点时触发;点击即可跳转到该会话。", + "completeTitle": "回复完成", + "heading": "通知", + "preview": "播放" + }, "privacy": { "description": "计数和时间 — 应用程序启动、完成的回合、一个回合所需的步骤和工具数量、使用的令牌数量以及大致成本 — 与此机器上生成的随机 ID 绑定。绝不包含您的提示、代码、文件路径、仓库名称或任何错误文本。", "heading": "隐私", diff --git a/src/renderer/src/routes/Settings.tsx b/src/renderer/src/routes/Settings.tsx index 237cbfc..9279a1b 100644 --- a/src/renderer/src/routes/Settings.tsx +++ b/src/renderer/src/routes/Settings.tsx @@ -25,6 +25,7 @@ import { ActivitySection } from '../components/ActivitySection' import { ProviderLogo } from '../lib/providerLogos' import { SubscriptionAccounts } from '../components/SubscriptionSetup' import { ModelVisibility } from '../components/ModelVisibility' +import { NotificationSettings } from '../components/NotificationSettings' import { useRoxyStore } from '../lib/store' /** The section heading repeated down the page. */ @@ -233,6 +234,11 @@ export default function Settings(): JSX.Element { +
+

{t('settings.notifications.heading')}

+ +
+

{t('settings.workstreams.heading')}

diff --git a/src/shared/api.ts b/src/shared/api.ts index 9a4004e..447372c 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -717,6 +717,8 @@ export interface RoxyApi { setContextLimit(limit: number | null): Promise setAutoWorkstream(enabled: boolean): Promise setBranchPrefix(prefix: string): Promise + /** Notify when a turn finishes: chime + OS toast, or nothing. */ + setNotifyOnComplete(enabled: boolean): Promise /** Set the UI language. An unknown code falls back to English. */ setLanguage(language: Language): Promise completeOnboarding(): Promise @@ -729,6 +731,23 @@ export interface RoxyApi { getTelemetry(): Promise setTelemetry(enabled: boolean): Promise } + notifications: { + /** + * Post a native OS toast for session `chatId`. Both strings arrive already + * translated: main has no i18next instance, so the renderer resolves them + * before calling. + */ + toast(title: string, body: string, chatId: string): Promise + /** + * Subscribe to toast clicks; returns an unsubscribe fn. The payload is the + * session id the toast was posted for, so the UI can open it. + * + * Fires for a click that happened BEFORE this window existed too: on macOS + * the app outlives its windows, so a toast can reopen one, and subscribing + * collects whatever was waiting. + */ + onActivated(callback: (chatId: string) => void): () => void + } providers: { listConnected(): Promise connect(input: ConnectProviderInput): Promise diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 5bec2a4..dae2081 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -8,6 +8,7 @@ export const CHANNELS = { settingsSetAutoWorkstream: 'settings:setAutoWorkstream', settingsSetBranchPrefix: 'settings:setBranchPrefix', settingsSetLanguage: 'settings:setLanguage', + settingsSetNotifyOnComplete: 'settings:setNotifyOnComplete', settingsCompleteOnboarding: 'settings:completeOnboarding', settingsReset: 'settings:reset', // Anonymous usage tracking. Its own pair of channels rather than a field on @@ -16,6 +17,21 @@ export const CHANNELS = { settingsGetTelemetry: 'settings:getTelemetry', settingsSetTelemetry: 'settings:setTelemetry', + /** + * Turn-completion notifications. Only the toast crosses IPC: main posts it + * because main alone can focus the window when it is clicked. The sound is + * played wholly in the renderer, which is where the audio output is. + */ + notifyToast: 'notify:toast', + /** Main -> renderer: the toast for this session id was clicked. */ + notifyActivated: 'notify:activated', + /** + * Renderer -> main: hand me the session id of a toast that was clicked + * before I existed. macOS keeps the app alive with every window closed, so + * a click can land with no renderer to push it to. + */ + notifyTakePending: 'notify:takePending', + providersList: 'providers:listConnected', providersConnect: 'providers:connect', providersDisconnect: 'providers:disconnect', diff --git a/src/shared/types.ts b/src/shared/types.ts index 7937c5d..b72d947 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -435,8 +435,34 @@ export interface AppSettings { * been deleted resolves back to the default rather than failing. */ activeThemeId: string | null + /** + * Notify when a turn finishes: chime and OS toast together, or nothing. + * + * One switch, not three. Separate sound / toast / when controls were three + * ways of asking the same question, and their combinations (toast but no + * sound, sound only when unfocused) are noise next to "tell me when it's + * done". Staying quiet while you are already watching is not a preference + * either - it is what the feature should always do, so it lives in + * `shouldNotify` rather than in settings. + */ + notifyOnComplete: boolean } +/** + * Playback level for the notification chime, 0..1. + * + * Fixed rather than user-adjustable: the chime is mastered at -6 dBFS, so this + * lands it at about -9 dBFS - audible over an editor without being startling. + * 0.7 matches VS Code's `accessibility.signalOptions.volume` default of 70, + * which is the closest comparable (an editor's own completion chime, played + * through the same HTMLAudioElement path). + * + * Per-app volume belongs to the OS on Windows (the Volume Mixer). macOS has no + * such control, so if this ever proves wrong for people the fix is to change + * the number here, not to hand everyone a slider to solve it themselves. + */ +export const NOTIFY_VOLUME = 0.7 + export interface AppVersions { app: string electron: string