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
+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