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
@@ -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";