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:
+2
-2
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ử",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user