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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user