feat(ui): Orca Dark / Orca Light themes and notification sounds

Two optional named themes from Orca's neutral palette join the Appearance
picker (Orca Dark keeps a blue accent so white-on-accent buttons stay
legible; Orca Light's status colours are darkened to clear 4.5:1 on their
chips). A shown notification can play a sound — None (default), Chime, Pop
or Alert — chosen globally and per notification type in Settings →
Notifications; the WAVs are generated by scripts/gen-notification-sounds.js.
This commit is contained in:
2026-10-01 17:10:46 +07:00
parent eb90244182
commit b71024f244
20 changed files with 487 additions and 55 deletions
+2 -2
View File
@@ -52,7 +52,7 @@ The client is a single-page application (SPA) built with modern web technologies
- **React 18.3** - Component-based UI with hooks and concurrent features
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (next to the language switcher in the sidebar, and in Settings → Appearance; `useTheme` hook) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (next to the language switcher in the sidebar, and in Settings → Appearance; `useTheme` hook) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
@@ -221,7 +221,7 @@ client/
│ ├── hooks/
│ │ ├── useWebSocket.ts # Auto-reconnecting WebSocket hook
│ │ ├── useLanes.ts # ONE shared lane list (GET /api/lanes + `lane_update` frames, refetch on reconnect) for the sidebar tree, status bar and Workspace page
│ │ ├── useNotifications.ts # Browser push notification triggers
│ │ ├── useNotifications.ts # Browser push notification triggers; a shown one plays its type's sound (`notifPrefs.sound`/`soundByType`, `playSound` in lib/notify.ts, WAVs in public/sounds/ generated by scripts/gen-notification-sounds.js)
│ │ └── useTheme.ts # Dark/light/system mode + per-mode named theme: `dark` class, `data-mode`, `data-theme` on <html>, persisted to localStorage
│ │
│ ├── i18n/ # Internationalization (en / vi)
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -19,6 +19,8 @@ const THEME_LABELS: Record<string, string> = {
"apple-light": "Apple Light",
"intellij-light": "IntelliJ Light",
canvas: "Canvas",
"orca-dark": "Orca Dark",
"orca-light": "Orca Light",
};
const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
@@ -26,7 +26,15 @@ describe("AppearanceSettings", () => {
.map((b) => b.getAttribute("aria-label") ?? b.textContent);
expect(pressed).toHaveLength(2); // both Default cards
expect(screen.getByRole("radio", { name: /Dark/ })).toHaveAttribute("aria-checked", "true");
for (const name of ["Darcula", "Apple Dark", "Apple Light", "IntelliJ Light", "Canvas"]) {
for (const name of [
"Darcula",
"Apple Dark",
"Orca Dark",
"Apple Light",
"IntelliJ Light",
"Canvas",
"Orca Light",
]) {
expect(screen.getByText(name)).toBeInTheDocument();
}
});
@@ -3,7 +3,8 @@
* browser notification, and which preference gates each one. Covers issue #29:
* "Claude needs confirmation" and "Claude finished a turn" must fire out of the
* box once notifications are enabled, since those are the two moments the user
* is the blocker.
* is the blocker. Also: a shown notification plays its type's sound, else the
* default one, and "none" plays nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -104,6 +105,46 @@ describe("useNotifications", () => {
expect(Notification).not.toHaveBeenCalled();
});
it("plays the type's own sound, else the default, and nothing for none", async () => {
const played: string[] = [];
vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (
this: HTMLMediaElement
) {
played.push(this.src.split("/").pop()!);
return Promise.resolve();
});
// The hook's own lane list must read as a list, or a later event finds none.
fetchMock.mockImplementation(async (url: string) => ({
ok: true,
json: async () =>
url === "/api/push/send"
? { ok: true, native: true, pushed: 1 }
: { lanes: [], counts: {} },
}));
enable({ sound: "chime", soundByType: { onTurnComplete: "pop", onAwaitingInput: "none" } });
renderHook(() => useNotifications());
emit("Stop", "Done");
await vi.waitFor(() => expect(played).toEqual(["pop.wav"]));
emit("Notification", "needs permission");
await vi.waitFor(() => expect(sentTitles()).toHaveLength(2));
await new Promise((r) => setTimeout(r, 20));
expect(played).toEqual(["pop.wav"]);
localStorage.setItem(
NOTIF_KEY,
JSON.stringify({
...defaultNotifPrefs,
enabled: true,
sound: "chime",
onSessionComplete: true,
})
);
emit("SessionEnd", "Closed");
await vi.waitFor(() => expect(played).toEqual(["pop.wav", "chime.wav"]));
vi.restoreAllMocks();
});
it("stays silent while notifications are disabled", async () => {
renderHook(() => useNotifications());
@@ -94,6 +94,15 @@ describe("useTheme", () => {
expect(result.current.themeFor).toEqual({ dark: "darcula", light: "canvas" });
});
it("applies the Orca themes by id", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setThemeFor("dark", "orca-dark"));
expect(document.documentElement.dataset.theme).toBe("orca-dark");
act(() => result.current.setThemeFor("light", "orca-light"));
act(() => result.current.toggleTheme());
expect(document.documentElement.dataset.theme).toBe("orca-light");
});
it("the default theme carries no data-theme attribute", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setThemeFor("dark", "darcula"));
+21 -8
View File
@@ -54,8 +54,8 @@ import i18n from "../i18n";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { subscribeToPush } from "../lib/push";
import { notify } from "../lib/notify";
import { loadNotifPrefs } from "../lib/notifPrefs";
import { notify, playSound } from "../lib/notify";
import { loadNotifPrefs, soundFor, type NotifType } from "../lib/notifPrefs";
import type { WSMessage, Session, Agent, DashboardEvent, Lane } from "../lib/types";
/**
@@ -104,12 +104,20 @@ export function useNotifications() {
const prefs = loadNotifPrefs();
if (!prefs.enabled) return;
// A notification that was shown (pushed, native or local) plays its
// type's sound; one that reached nobody plays nothing.
const show = (type: NotifType, title: string, body: string, url?: string) =>
void notify(title, body, url).then((r) => {
if (r.via === "native" || r.via === "push" || r.via === "local")
playSound(soundFor(prefs, type));
});
switch (msg.type) {
case "session_created": {
if (!prefs.onNewSession) return;
const s = msg.data as Session;
notify(
show(
"onNewSession",
i18n.t("errors:notifications.newSession"),
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`,
laneUrlForSession(s.id)
@@ -119,7 +127,8 @@ export function useNotifications() {
case "session_updated": {
const s = msg.data as Session;
if (s.status === "error" && prefs.onSessionError) {
notify(
show(
"onSessionError",
i18n.t("errors:notifications.sessionError"),
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`,
laneUrlForSession(s.id)
@@ -131,7 +140,8 @@ export function useNotifications() {
if (!prefs.onSubagentSpawn) return;
const a = msg.data as Agent;
if (a.type === "subagent") {
notify(
show(
"onSubagentSpawn",
i18n.t("errors:notifications.subagentSpawned"),
a.name,
laneUrlForSession(a.session_id)
@@ -148,19 +158,22 @@ export function useNotifications() {
// `onSessionComplete` / nothing at all, so the one thing the user
// actually waits for was either off by default or unswitchable.
if (ev.event_type === "Notification" && prefs.onAwaitingInput) {
notify(
show(
"onAwaitingInput",
i18n.t("errors:notifications.awaitingInput"),
ev.summary || i18n.t("errors:notifications.defaultBody"),
url
);
} else if (ev.event_type === "Stop" && prefs.onTurnComplete) {
notify(
show(
"onTurnComplete",
i18n.t("errors:notifications.finishedResponding"),
ev.summary || i18n.t("errors:notifications.readyForInput"),
url
);
} else if (ev.event_type === "SessionEnd" && prefs.onSessionComplete) {
notify(
show(
"onSessionComplete",
i18n.t("errors:notifications.sessionCompleted"),
ev.summary || i18n.t("errors:notifications.sessionClosed"),
url
+2 -2
View File
@@ -18,8 +18,8 @@ export type ThemeMode = Theme | "system";
/** Named themes per mode, in picker order. `default` is the stock palette. */
export const THEMES = {
dark: ["default", "darcula", "apple-dark"],
light: ["default", "apple-light", "intellij-light", "canvas"],
dark: ["default", "darcula", "apple-dark", "orca-dark"],
light: ["default", "apple-light", "intellij-light", "canvas", "orca-light"],
} as const satisfies Record<Theme, readonly string[]>;
const MODE_KEY = "theme";
+12 -1
View File
@@ -107,7 +107,18 @@
"testSent": "Sent via push — check your notification centre.",
"testTitle": "Agent Monitor",
"title": "Notifications",
"turnComplete": "Claude finishes a turn"
"turnComplete": "Claude finishes a turn",
"sound": {
"title": "Sound",
"description": "Played with every notification shown. Each type below can pick its own.",
"preview": "Play",
"useDefault": "Default",
"none": "None",
"chime": "Chime",
"pop": "Pop",
"alert": "Alert",
"forType": "Sound for this type"
}
},
"import": {
"title": "Import History",
+12 -1
View File
@@ -107,7 +107,18 @@
"testSent": "Đã gửi qua push — kiểm tra trung tâm thông báo.",
"testTitle": "Agent Monitor",
"title": "Thông báo",
"turnComplete": "Claude trả lời xong"
"turnComplete": "Claude trả lời xong",
"sound": {
"title": "Âm thanh",
"description": "Phát kèm mỗi thông báo được hiển thị. Mỗi loại bên dưới có thể chọn âm riêng.",
"preview": "Phát thử",
"useDefault": "Mặc định",
"none": "Không",
"chime": "Chuông",
"pop": "Pop",
"alert": "Cảnh báo",
"forType": "Âm thanh cho loại này"
}
},
"import": {
"title": "Nhập lịch sử",
+71
View File
@@ -209,6 +209,42 @@
--syn-plain: 229 229 234; /* #e5e5ea */
}
/* Orca Dark — Orca's neutral palette (src/renderer/src/assets/main.css `.dark`).
* Accent is NOT Orca's #e5e5e5 primary: AgentHub puts white text on `bg-accent`
* (.btn-primary), which would read 1.3:1 there; #3b82f6 keeps text-accent 4.6:1
* on the card. Syntax colours are Apple Dark's. */
.dark[data-theme="orca-dark"] {
--surface-0: 10 10 10; /* #0a0a0a */
--surface-1: 17 17 17; /* #111111 */
--surface-2: 23 23 23; /* #171717 */
--surface-3: 28 28 28; /* #1c1c1c */
--surface-4: 38 38 38; /* #262626 */
--surface-5: 64 64 64; /* #404040 */
--border: 31 31 31; /* #1f1f1f */
--border-light: 46 46 46; /* #2e2e2e */
--accent: 59 130 246; /* #3b82f6 */
--accent-hover: 96 165 250; /* #60a5fa */
--fg-primary: 250 250 250; /* #fafafa */
--fg-secondary: 212 212 212; /* #d4d4d4 */
--fg-muted: 161 161 161; /* #a1a1a1 */
--status-success: 134 239 172; /* #86efac */
--status-danger: 255 101 104; /* #ff6568 */
--status-warning: 234 179 8; /* #eab308 */
--syn-comment: 127 140 152; /* #7f8c98 */
--syn-string: 252 106 93; /* #fc6a5d */
--syn-number: 208 191 105; /* #d0bf69 */
--syn-keyword: 252 95 163; /* #fc5fa3 */
--syn-builtin: 93 216 255; /* #5dd8ff */
--syn-function: 103 183 164; /* #67b7a4 */
--syn-operator: 161 161 166; /* #a1a1a6 */
--syn-punctuation: 161 161 166; /* #a1a1a6 */
--syn-property: 181 140 240; /* #b58cf0 */
--syn-tag: 252 95 163; /* #fc5fa3 */
--syn-add: 48 209 88; /* #30d158 */
--syn-del: 255 105 97; /* #ff6961 */
--syn-plain: 229 229 234; /* #e5e5ea */
}
[data-mode="light"][data-theme="apple-light"] {
--surface-0: 233 233 238; /* #e9e9ee */
--surface-1: 239 239 244; /* #efeff4 */
@@ -304,6 +340,41 @@
--syn-del: 159 47 35; /* #9f2f23 */
}
/* Orca Light — Orca's neutral palette (`:root`). The surfaces rise with their
* index, so the page sits below a #fafafa card instead of Orca's white-on-white;
* status colours are two steps darker than Orca's so each label clears 4.5:1 on
* its own /20 chip. Syntax colours are Apple Light's. */
[data-mode="light"][data-theme="orca-light"] {
--surface-0: 235 235 235; /* #ebebeb */
--surface-1: 240 240 240; /* #f0f0f0 */
--surface-2: 245 245 245; /* #f5f5f5 */
--surface-3: 250 250 250; /* #fafafa */
--surface-4: 253 253 253; /* #fdfdfd */
--surface-5: 255 255 255; /* #ffffff */
--border: 229 229 229; /* #e5e5e5 */
--border-light: 212 212 212; /* #d4d4d4 */
--accent: 23 23 23; /* #171717 */
--accent-hover: 38 38 38; /* #262626 */
--fg-primary: 10 10 10; /* #0a0a0a */
--fg-secondary: 64 64 64; /* #404040 */
--fg-muted: 115 115 115; /* #737373 */
--status-success: 22 101 52; /* #166534 */
--status-danger: 168 0 15; /* #a8000f */
--status-warning: 133 77 14; /* #854d0e */
--syn-comment: 93 108 121; /* #5d6c79 */
--syn-string: 196 26 22; /* #c41a16 */
--syn-number: 28 0 207; /* #1c00cf */
--syn-keyword: 155 35 147; /* #9b2393 */
--syn-builtin: 11 79 121; /* #0b4f79 */
--syn-function: 50 109 116; /* #326d74 */
--syn-operator: 75 75 80; /* #4b4b50 */
--syn-punctuation: 75 75 80; /* #4b4b50 */
--syn-property: 57 0 160; /* #3900a0 */
--syn-tag: 155 35 147; /* #9b2393 */
--syn-add: 31 122 54; /* #1f7a36 */
--syn-del: 197 0 15; /* #c5000f */
}
@layer base {
* {
scrollbar-width: thin;
@@ -0,0 +1,45 @@
/**
* @file notifPrefs.test.ts
* @description Notification prefs: the defaults (sound "none"), a blob stored
* before sounds existed loading with sound "none", `soundByType` surviving a
* save/load round trip, and `soundFor` preferring a type's override.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it } from "vitest";
import {
NOTIF_KEY,
defaultNotifPrefs,
loadNotifPrefs,
saveNotifPrefs,
soundFor,
} from "../notifPrefs";
beforeEach(() => localStorage.clear());
describe("notifPrefs", () => {
it("defaults to no sound", () => {
expect(loadNotifPrefs()).toEqual(defaultNotifPrefs);
expect(defaultNotifPrefs.sound).toBe("none");
});
it("loads a blob written before sounds existed with sound none", () => {
localStorage.setItem(NOTIF_KEY, JSON.stringify({ enabled: true, onNewSession: true }));
const prefs = loadNotifPrefs();
expect(prefs.sound).toBe("none");
expect(prefs.enabled).toBe(true);
expect(prefs.soundByType).toBeUndefined();
});
it("round-trips soundByType and resolves a type's sound", () => {
saveNotifPrefs({
...defaultNotifPrefs,
sound: "chime",
soundByType: { onTurnComplete: "pop" },
});
const prefs = loadNotifPrefs();
expect(prefs.soundByType).toEqual({ onTurnComplete: "pop" });
expect(soundFor(prefs, "onTurnComplete")).toBe("pop");
expect(soundFor(prefs, "onSessionError")).toBe("chime");
});
});
+24 -1
View File
@@ -3,12 +3,26 @@
* the defaults, and the `localStorage` read/write. Settings (which edits them)
* and `useNotifications` (which obeys them) both import from here; they used to
* carry private copies, so adding a notification type meant editing the same
* literal twice and a miss went unnoticed until a toggle did nothing.
* literal twice and a miss went unnoticed until a toggle did nothing. A
* shown notification also plays a sound: `sound` is the default, and
* `soundByType` may override it per notification type (`soundFor`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export const NOTIF_KEY = "agent-monitor-notifications";
export const NOTIF_SOUNDS = ["none", "chime", "pop", "alert"] as const;
export type NotifSound = (typeof NOTIF_SOUNDS)[number];
/** The per-type flags — every key a notification is gated on. */
export type NotifType =
| "onNewSession"
| "onSessionError"
| "onAwaitingInput"
| "onTurnComplete"
| "onSessionComplete"
| "onSubagentSpawn";
export interface NotifPrefs {
/** Master switch; when false no notification fires, whatever the flags say. */
enabled: boolean;
@@ -21,6 +35,9 @@ export interface NotifPrefs {
/** The whole session closed (`SessionEnd`), not just one turn. */
onSessionComplete: boolean;
onSubagentSpawn: boolean;
/** The sound a shown notification plays, unless its type overrides it. */
sound: NotifSound;
soundByType?: Partial<Record<NotifType, NotifSound>>;
}
// The two "Claude is waiting for you" types default ON: they are the reason
@@ -34,8 +51,14 @@ export const defaultNotifPrefs: NotifPrefs = {
onTurnComplete: true,
onSessionComplete: false,
onSubagentSpawn: false,
sound: "none",
};
/** The sound for one notification type: its override, else the default. */
export function soundFor(prefs: NotifPrefs, type: NotifType): NotifSound {
return prefs.soundByType?.[type] ?? prefs.sound;
}
/** Reads prefs, merged over the defaults so a stored blob written by an older
* build (missing the newer flags) still yields a complete object. */
export function loadNotifPrefs(): NotifPrefs {
+20 -1
View File
@@ -2,10 +2,29 @@
* @file Shows a browser notification — the single `notify()` both
* `useNotifications` (live events) and Settings' "Send Test Notification"
* button call, so the test proves the real delivery path and can report
* where it actually landed instead of a bare fire-and-forget.
* where it actually landed instead of a bare fire-and-forget. `playSound`
* plays one of the generated `public/sounds/*.wav` (see
* `scripts/gen-notification-sounds.js`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { NotifSound } from "./notifPrefs";
const audio = new Map<NotifSound, HTMLAudioElement>();
/** Plays a notification sound from the start; "none" plays nothing. A browser
* that refuses autoplay before a user gesture is left to refuse quietly. */
export function playSound(kind: NotifSound): void {
if (kind === "none") return;
let el = audio.get(kind);
if (!el) {
el = new Audio(`/sounds/${kind}.wav`);
audio.set(kind, el);
}
el.currentTime = 0;
void el.play()?.catch(() => {});
}
export type NotifyVia = "native" | "push" | "local" | "blocked" | "none";
export interface NotifyError {
+90 -33
View File
@@ -71,6 +71,7 @@ import {
FileDown,
Eraser,
Zap,
Play,
GitBranch,
ShieldCheck,
ShieldAlert,
@@ -91,16 +92,37 @@ import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { sagiPrefs } from "../components/Sagi/prefs";
import { fmt, fmtCost } from "../lib/format";
import { subscribeToPush, unsubscribeFromPush } from "../lib/push";
import { loadNotifPrefs, saveNotifPrefs, type NotifPrefs } from "../lib/notifPrefs";
import { notify } from "../lib/notify";
import {
loadNotifPrefs,
saveNotifPrefs,
NOTIF_SOUNDS,
type NotifPrefs,
type NotifSound,
type NotifType,
} from "../lib/notifPrefs";
import { notify, playSound } from "../lib/notify";
import { Tip } from "../components/Tip";
import { ImportHistory } from "../components/ImportHistory";
import { RemoteSources } from "../components/RemoteSources";
import { Skeleton } from "../components/Skeleton";
import { AlertsNotifications } from "../components/AlertsNotifications";
import { SettingsSwitchRow } from "../components/settings/SettingsRow";
import {
SettingsRow,
SettingsSegmented,
SettingsSwitchRow,
} from "../components/settings/SettingsRow";
import type { WSMessage } from "../lib/types";
// The per-type notification switches, in the order Settings lists them.
const NOTIF_TYPES: [NotifType, string][] = [
["onAwaitingInput", "awaitingInput"],
["onNewSession", "newSession"],
["onTurnComplete", "turnComplete"],
["onSessionComplete", "sessionComplete"],
["onSessionError", "sessionError"],
["onSubagentSpawn", "subagentSpawned"],
];
// ─── Helpers ───
interface SystemInfo {
@@ -582,40 +604,75 @@ export function Settings({
{notifPrefs.enabled && (
<div className="space-y-3 pt-4 border-t border-border">
<SettingsRow
label={t("notifications.sound.title")}
description={t("notifications.sound.description")}
control={
<div className="flex items-center gap-2">
<select
aria-label={t("notifications.sound.title")}
value={notifPrefs.sound}
onChange={(e) =>
updateNotifPrefs({ sound: e.target.value as NotifSound })
}
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs text-fg-primary"
>
{NOTIF_SOUNDS.map((k) => (
<option key={k} value={k}>
{t(`notifications.sound.${k}`)}
</option>
))}
</select>
<button
type="button"
onClick={() => playSound(notifPrefs.sound)}
disabled={notifPrefs.sound === "none"}
className="btn-ghost text-xs disabled:opacity-50"
>
<Play className="w-3 h-3" />
{t("notifications.sound.preview")}
</button>
</div>
}
/>
<p className="text-xs text-fg-muted uppercase tracking-wider font-semibold">
{t("notifications.notifyWhen")}
</p>
<div>
<SettingsSwitchRow
checked={notifPrefs.onAwaitingInput}
onChange={(v) => updateNotifPrefs({ onAwaitingInput: v })}
label={t("notifications.awaitingInput")}
/>
<SettingsSwitchRow
checked={notifPrefs.onNewSession}
onChange={(v) => updateNotifPrefs({ onNewSession: v })}
label={t("notifications.newSession")}
/>
<SettingsSwitchRow
checked={notifPrefs.onTurnComplete}
onChange={(v) => updateNotifPrefs({ onTurnComplete: v })}
label={t("notifications.turnComplete")}
/>
<SettingsSwitchRow
checked={notifPrefs.onSessionComplete}
onChange={(v) => updateNotifPrefs({ onSessionComplete: v })}
label={t("notifications.sessionComplete")}
/>
<SettingsSwitchRow
checked={notifPrefs.onSessionError}
onChange={(v) => updateNotifPrefs({ onSessionError: v })}
label={t("notifications.sessionError")}
/>
<SettingsSwitchRow
checked={notifPrefs.onSubagentSpawn}
onChange={(v) => updateNotifPrefs({ onSubagentSpawn: v })}
label={t("notifications.subagentSpawned")}
/>
{NOTIF_TYPES.map(([type, labelKey]) => (
<div key={type} className="border-b border-border/60 last:border-b-0">
<SettingsSwitchRow
checked={notifPrefs[type]}
onChange={(v) => updateNotifPrefs({ [type]: v })}
label={t(`notifications.${labelKey}`)}
/>
{notifPrefs[type] && (
<div className="pb-2">
<SettingsSegmented
ariaLabel={`${t(`notifications.${labelKey}`)} — ${t("notifications.sound.forType")}`}
value={notifPrefs.soundByType?.[type] ?? "default"}
onChange={(v) => {
const next = { ...notifPrefs.soundByType };
if (v === "default") delete next[type];
else next[type] = v;
updateNotifPrefs({ soundByType: next });
if (v !== "default") playSound(v);
}}
options={[
{
value: "default" as const,
label: t("notifications.sound.useDefault"),
},
...NOTIF_SOUNDS.map((k) => ({
value: k,
label: t(`notifications.sound.${k}`),
})),
]}
/>
</div>
)}
</div>
))}
</div>
<div className="pt-3 border-t border-border">