Files
server-26/drb-frontend/components/live/AttentionControls.tsx
Logan CusanoandClaude Opus 5.5 292c4fffd9 Attention cues review fixes (#238)
- NEW lane: Gate A notice pinned below the rows (it scrolled off with >3
  entries, same rule as the rail, #46); rows at text-xs for the wall.
- Mobile chip moved off MapView's centred "Live" chip, which it covered.
- Keyword cues re-resolve to the incident once correlation links the call,
  so the lane row is clickable and the pin pulses.
- Enabling notifications sends one confirmation notification; a browser
  that grants permission but throws on construct (mobile Chrome) now shows
  "unsupported" instead of a switched-on control that never delivers.
- useActiveIncidentsState: includeMetadataChanges so `loaded` flips on a
  metadata-only server sync (quiet org / offline start) instead of waiting
  for the first incident change and swallowing it into the baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:52:02 -04:00

254 lines
11 KiB
TypeScript

"use client";
/**
* Attention cues (server-26#238) — the opt-in half: an audible tone and
* browser notifications, both OFF by default.
*
* Tone: Web Audio beep, no asset. Browsers only let audio start from a user
* gesture, so the AudioContext is created by the click that turns the tone
* on (and that click plays one confirmation beep). After a reload with the
* tone already on, the first click/key anywhere on the page re-arms it; until
* then nothing plays and the button says so.
*
* Notifications: Notification.requestPermission() on click; fired only while
* the tab is hidden (a visible wall already shows the lane and pulse). The
* body ends with the Gate A machine-output notice — the title it carries is
* pipeline output. No transcript text goes into a notification.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { DEFAULT_KEYWORDS, parseKeywords, type AttentionEvent, type AttentionPrefs } from "@/lib/attention";
import { MACHINE_OUTPUT_NOTICE_TEXT } from "@/components/ui/MachineOutputNotice";
const TONE_GAP_MS = 4_000;
const MAX_NOTIFICATIONS = 3;
// ── Audio (one context per page, only ever created inside a user gesture) ────
let audioCtx: AudioContext | null = null;
function createAudio(): AudioContext | null {
try {
if (!audioCtx) {
const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AC) return null;
audioCtx = new AC();
}
if (audioCtx.state === "suspended") void audioCtx.resume().catch(() => undefined);
return audioCtx;
} catch {
return null;
}
}
/** n short beeps, alternating 880/660 Hz. Silent unless a gesture already started the context. */
function beep(n: number) {
const a = audioCtx;
if (!a || a.state !== "running") return;
try {
for (let i = 0; i < n; i++) {
const t0 = a.currentTime + i * 0.22;
const osc = a.createOscillator();
const gain = a.createGain();
osc.type = "sine";
osc.frequency.value = i % 2 ? 660 : 880;
gain.gain.setValueAtTime(0.0001, t0);
gain.gain.exponentialRampToValueAtTime(0.25, t0 + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.18);
osc.connect(gain).connect(a.destination);
osc.start(t0);
osc.stop(t0 + 0.2);
}
} catch { /* audio unavailable */ }
}
type NotifyState = "unsupported" | "default" | "granted" | "denied";
function notifyState(): NotifyState {
if (typeof window === "undefined" || !("Notification" in window)) return "unsupported";
return Notification.permission as NotifyState;
}
const HEADLINE: Record<AttentionEvent["kind"], string> = {
major: "Major incident",
escalated: "Severity escalated",
stop: "Pursuit path: new stop",
keyword: "Priority keyword",
};
/**
* Fires the opted-in cues for a batch of in-view events. Returns `fire` (pass
* to useAttention's onEvents) and the live audio state for the controls.
*/
export function useAttentionCues(prefs: AttentionPrefs, onOpenIncident: (id: string) => void) {
const [armed, setArmed] = useState(false);
const lastTone = useRef(0);
const prefsRef = useRef(prefs);
prefsRef.current = prefs;
const openRef = useRef(onOpenIncident);
openRef.current = onOpenIncident;
// Tone on: track the context's state. Already on from an earlier visit →
// the next gesture anywhere on the page creates/resumes the context.
useEffect(() => {
if (!prefs.tone) { setArmed(false); return; }
const sync = () => setArmed(audioCtx?.state === "running");
const arm = () => {
const a = createAudio();
if (a) { a.onstatechange = sync; a.resume().then(sync, () => undefined); }
};
if (audioCtx) { audioCtx.onstatechange = sync; sync(); }
window.addEventListener("pointerdown", arm, { once: true });
window.addEventListener("keydown", arm, { once: true });
return () => {
window.removeEventListener("pointerdown", arm);
window.removeEventListener("keydown", arm);
};
}, [prefs.tone]);
const fire = useCallback((events: AttentionEvent[]) => {
if (!events.length) return;
const p = prefsRef.current;
if (p.tone && Date.now() - lastTone.current > TONE_GAP_MS) {
lastTone.current = Date.now();
// Three beeps for anything reaching Major or a priority keyword, one otherwise.
beep(events.some((e) => e.kind === "major" || e.kind === "keyword" || e.label.endsWith("Major")) ? 3 : 1);
}
if (!p.notify || typeof document === "undefined" || !document.hidden || notifyState() !== "granted") return;
const send = (title: string, body: string, tag: string, incidentId: string | null) => {
try {
const n = new Notification(title, { body: `${body}\n${MACHINE_OUTPUT_NOTICE_TEXT}`, tag });
n.onclick = () => {
window.focus();
if (incidentId) openRef.current(incidentId);
n.close();
};
} catch { /* e.g. mobile Chrome needs a service worker — silently no notification */ }
};
for (const e of events.slice(0, MAX_NOTIFICATIONS)) {
const head = e.kind === "keyword" ? `${HEADLINE.keyword}: ${e.label}` : e.kind === "escalated" ? `${HEADLINE.escalated}: ${e.label.replace(/^↑\s*/, "")}` : HEADLINE[e.kind];
send(`DRB · ${head}`, e.title, `drb-attn:${e.incidentIds[0] ?? e.id}`, e.incidentIds[0] ?? null);
}
if (events.length > MAX_NOTIFICATIONS) {
send("DRB · More activity", `${events.length - MAX_NOTIFICATIONS} more on the live wall.`, "drb-attn:more", null);
}
}, []);
return { fire, armed };
}
interface Props {
prefs: AttentionPrefs;
onChange: (p: AttentionPrefs) => void;
armed: boolean;
}
export function AttentionControls({ prefs, onChange, armed }: Props) {
const [open, setOpen] = useState(false);
const [perm, setPerm] = useState<NotifyState>("default");
const [kwDraft, setKwDraft] = useState(prefs.keywords.join(", "));
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => { setPerm(notifyState()); }, [open]);
useEffect(() => { setKwDraft(prefs.keywords.join(", ")); }, [prefs.keywords]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); };
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); };
}, [open]);
const toggleTone = () => {
if (prefs.tone) { onChange({ ...prefs, tone: false }); return; }
// This click is the gesture that unlocks audio; confirm with one beep.
const a = createAudio();
onChange({ ...prefs, tone: true });
if (a) a.resume().then(() => beep(1), () => undefined);
};
const toggleNotify = async () => {
if (prefs.notify) { onChange({ ...prefs, notify: false }); return; }
if (notifyState() === "unsupported") { setPerm("unsupported"); return; }
let p: NotifyState = notifyState();
if (p === "default") {
try { p = (await Notification.requestPermission()) as NotifyState; } catch { p = notifyState(); }
}
if (p === "granted") {
// Confirm with one notification (as the tone confirms with a beep). It
// doubles as the support check: mobile Chrome grants permission but
// throws on `new Notification()` without a service worker, which would
// otherwise leave a switched-on control that never delivers anything.
try {
new Notification("DRB · Notifications on", {
body: `Cues will appear here while the live wall tab is hidden.\n${MACHINE_OUTPUT_NOTICE_TEXT}`,
tag: "drb-attn:on",
});
} catch {
p = "unsupported";
}
}
setPerm(p);
if (p === "granted") onChange({ ...prefs, notify: true });
};
const commitKeywords = () => {
const next = parseKeywords(kwDraft);
if (next.join("\u0001") !== prefs.keywords.join("\u0001")) onChange({ ...prefs, keywords: next });
else setKwDraft(prefs.keywords.join(", "));
};
const notifyOn = prefs.notify && perm !== "denied" && perm !== "unsupported";
const summary = [prefs.tone ? (armed ? "tone" : "tone (click to arm)") : null, notifyOn ? "notify" : null].filter(Boolean).join(" · ") || "off";
const on = prefs.tone || notifyOn;
return (
<div ref={boxRef} className="relative">
<button type="button" aria-expanded={open} aria-haspopup="dialog" onClick={() => setOpen((v) => !v)}
title="Attention cues: an audible tone and browser notifications for new Majors, escalations, pursuit stops and priority keywords"
className={`px-2 py-0.5 rounded border whitespace-nowrap ${on ? "border-accent text-accent" : "border-line text-ink-2 hover:text-ink"}`}>
Cues: {summary}
</button>
{open && (
<div role="dialog" aria-label="Attention cues"
className="absolute right-0 top-full mt-1 z-[1100] w-72 max-w-[calc(100vw-2rem)] bg-surface border border-line rounded-lg shadow-lg p-3 space-y-3 text-xs text-ink-2">
<p className="text-ink-muted leading-snug">
Cues fire on: a Major opened, severity escalated, a pursuit path gaining a stop, or a priority keyword in a new
call. Only for incidents the current filter shows.
</p>
<label className="flex items-start gap-2 cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={prefs.tone} onChange={toggleTone} />
<span>
<span className="text-ink font-medium">Sound a tone</span>
{prefs.tone && !armed && (
<span className="block text-ink-muted">Click anywhere on the page to arm it — browsers block sound until you do.</span>
)}
</span>
</label>
<label className="flex items-start gap-2 cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={notifyOn} disabled={perm === "unsupported"} onChange={() => { void toggleNotify(); }} />
<span>
<span className="text-ink font-medium">Browser notifications while this tab is hidden</span>
{perm === "denied" && <span className="block text-ink-muted">Blocked in this browser&apos;s site settings — allow notifications there first.</span>}
{perm === "unsupported" && <span className="block text-ink-muted">This browser doesn&apos;t show notifications from a web page (mobile browsers often only allow them from installed apps).</span>}
</span>
</label>
<div>
<label htmlFor="attn-kw" className="block text-ink font-medium mb-1">Priority keywords</label>
<input id="attn-kw" type="text" value={kwDraft} onChange={(e) => setKwDraft(e.target.value)} onBlur={commitKeywords}
onKeyDown={(e) => { if (e.key === "Enter") commitKeywords(); }}
placeholder="comma-separated"
className="w-full px-2 py-1 rounded border border-line bg-raised text-ink" />
<div className="mt-1 flex items-center justify-between text-ink-muted">
<span>Matched against new call transcripts.</span>
<button type="button" className="underline hover:text-ink"
onClick={() => onChange({ ...prefs, keywords: [...DEFAULT_KEYWORDS] })}>
Reset
</button>
</div>
</div>
</div>
)}
</div>
);
}