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>
This commit is contained in:
Logan Cusano
2026-10-05 03:52:02 -04:00
co-authored by Claude Opus 5.5
parent eb0faf2aea
commit 292c4fffd9
4 changed files with 53 additions and 17 deletions
@@ -173,6 +173,20 @@ export function AttentionControls({ prefs, onChange, armed }: Props) {
if (p === "default") { if (p === "default") {
try { p = (await Notification.requestPermission()) as NotifyState; } catch { p = notifyState(); } 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); setPerm(p);
if (p === "granted") onChange({ ...prefs, notify: true }); if (p === "granted") onChange({ ...prefs, notify: true });
}; };
@@ -215,7 +229,7 @@ export function AttentionControls({ prefs, onChange, armed }: Props) {
<span> <span>
<span className="text-ink font-medium">Browser notifications while this tab is hidden</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 === "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 support page notifications.</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> </span>
</label> </label>
<div> <div>
+17 -12
View File
@@ -8,7 +8,10 @@
* *
* Desktop: overlay at the map's top-left, right of the zoom stack and above * Desktop: overlay at the map's top-left, right of the zoom stack and above
* MapView's rail (the rail starts at top-[9.5rem], so the lane is capped * MapView's rail (the rail starts at top-[9.5rem], so the lane is capped
* just short of it and scrolls). Mobile: a compact chip that expands. * just short of it and its rows scroll). The Gate A notice is pinned below
* the rows, never inside the scroll area — same rule as the rail (#46).
* Mobile: a compact chip under MapView's centred "Live" chip (same top-3
* centre slot would cover it), right of the fit-all button; it expands.
* Hidden when there is nothing new. * Hidden when there is nothing new.
*/ */
import { useState } from "react"; import { useState } from "react";
@@ -31,7 +34,7 @@ function Row({ e, now, onSelect }: { e: LaneEntry; now: number; onSelect: (id: s
<span className="shrink-0 tabular-nums text-ink-muted">{ago(e.at, now)}</span> <span className="shrink-0 tabular-nums text-ink-muted">{ago(e.at, now)}</span>
</> </>
); );
const cls = "w-full flex items-center gap-1.5 px-1.5 py-1 rounded text-[11px] text-left"; const cls = "w-full flex items-center gap-1.5 px-1.5 py-1 rounded text-xs text-left";
return ( return (
<li> <li>
{e.incidentId ? ( {e.incidentId ? (
@@ -53,28 +56,30 @@ export function NewLane({ entries, now, onSelect }: { entries: LaneEntry[]; now:
return ( return (
<> <>
<section aria-label="New in the last 15 minutes" <section aria-label="New in the last 15 minutes"
className="absolute top-3 left-14 z-[1002] hidden md:flex flex-col w-56 max-h-[8.25rem] bg-surface/90 backdrop-blur-sm border border-accent/60 rounded-lg shadow pointer-events-auto"> className="absolute top-3 left-14 z-[1002] hidden md:flex flex-col w-56 max-h-[8.5rem] bg-surface/90 backdrop-blur-sm border border-accent/60 rounded-lg shadow pointer-events-auto">
<h2 className="shrink-0 px-2 pt-1.5 text-[10px] font-semibold uppercase tracking-wide text-accent"> <h2 className="shrink-0 px-2 pt-1.5 text-[10px] font-semibold uppercase tracking-wide text-accent">
New · last 15 min <span className="text-ink-muted font-normal">({entries.length})</span> New · last 15 min <span className="text-ink-muted font-normal">({entries.length})</span>
</h2> </h2>
<ul className="min-h-0 overflow-y-auto px-0.5 pb-1"> <ul className="min-h-0 overflow-y-auto px-0.5">
{entries.map((e) => <Row key={e.key} e={e} now={now} onSelect={onSelect} />)} {entries.map((e) => <Row key={e.key} e={e} now={now} onSelect={onSelect} />)}
<li>{notice}</li>
</ul> </ul>
<div className="shrink-0 border-t border-line pb-1">{notice}</div>
</section> </section>
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-[1002] md:hidden flex flex-col items-center"> <div className="absolute top-[5.25rem] left-14 z-[1002] md:hidden flex flex-col items-start">
<button type="button" aria-expanded={open} onClick={() => setOpen((v) => !v)} <button type="button" aria-expanded={open} onClick={() => setOpen((v) => !v)}
className="bg-surface/95 border border-accent/60 rounded-full px-3 py-1 text-xs text-accent font-semibold whitespace-nowrap shadow"> className="bg-surface/95 border border-accent/60 rounded-full px-3 py-1 text-xs text-accent font-semibold whitespace-nowrap shadow">
● {entries.length} new · 15 min {open ? "▴" : "▾"} ● {entries.length} new · 15 min {open ? "▴" : "▾"}
</button> </button>
{open && ( {open && (
<ul className="mt-1 w-64 max-w-[calc(100vw-2rem)] max-h-48 overflow-y-auto bg-surface/95 border border-line rounded-lg shadow p-0.5"> <div className="mt-1 w-64 max-w-[calc(100vw-4.5rem)] bg-surface/95 border border-line rounded-lg shadow p-0.5 flex flex-col">
{entries.map((e) => ( <ul className="max-h-40 overflow-y-auto">
<Row key={e.key} e={e} now={now} onSelect={(id) => { setOpen(false); onSelect(id); }} /> {entries.map((e) => (
))} <Row key={e.key} e={e} now={now} onSelect={(id) => { setOpen(false); onSelect(id); }} />
<li>{notice}</li> ))}
</ul> </ul>
<div className="shrink-0 border-t border-line pb-1">{notice}</div>
</div>
)} )}
</div> </div>
</> </>
+11 -2
View File
@@ -404,7 +404,16 @@ export function useAttention({ ready, resetKey, incidents, calls, keywords, visi
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, resetKey, incidents, calls, kwKey]); }, [ready, resetKey, incidents, calls, kwKey]);
const lane = useMemo(() => laneEntries(visible, seenAt, log, now, visibleCallIds), [visible, seenAt, log, now, visibleCallIds]); // A keyword usually fires on the transcript, before correlation links the
const pulse = useMemo(() => pulseIds(visibleIds, seenAt, log, now), [visibleIds, seenAt, log, now]); // call. Re-resolve unlinked events against the calls as they are now, so
// the lane row becomes the (clickable) incident and its pin pulses.
const linkedLog = useMemo(() => {
if (!log.some((e) => !e.incidentIds.length && e.callId)) return log;
const links = new Map<string, string[]>();
for (const c of calls) { const ids = callIncidentIds(c); if (ids.length) links.set(c.call_id, ids); }
return log.map((e) => (e.incidentIds.length || !e.callId || !links.has(e.callId) ? e : { ...e, incidentIds: links.get(e.callId)! }));
}, [log, calls]);
const lane = useMemo(() => laneEntries(visible, seenAt, linkedLog, now, visibleCallIds), [visible, seenAt, linkedLog, now, visibleCallIds]);
const pulse = useMemo(() => pulseIds(visibleIds, seenAt, linkedLog, now), [visibleIds, seenAt, linkedLog, now]);
return { lane, pulseIds: pulse, now }; return { lane, pulseIds: pulse, now };
} }
+10 -2
View File
@@ -157,7 +157,16 @@ export function useActiveIncidentsState(): { incidents: IncidentRecord[]; loaded
} }
const q = query(collection(db, "incidents"), where("org_id", "==", orgId), where("status", "==", "active")); const q = query(collection(db, "incidents"), where("org_id", "==", orgId), where("status", "==", "active"));
unsubFirestore = onSnapshot(q, (snap) => { // includeMetadataChanges: the fromCache → server flip arrives as a
// metadata-only snapshot when the data didn't change (e.g. an org with
// nothing active, or a start while offline). Without it `loaded` would
// stay false until some incident changed, and that change would be
// swallowed into the baseline instead of cued.
let first = true;
unsubFirestore = onSnapshot(q, { includeMetadataChanges: true }, (snap) => {
if (!snap.metadata.fromCache) setLoaded(true);
if (!first && snap.docChanges().length === 0) return; // metadata only — no new array, no re-render storm
first = false;
setIncidents(snap.docs.map((d) => { setIncidents(snap.docs.map((d) => {
const data = d.data(); const data = d.data();
return { return {
@@ -166,7 +175,6 @@ export function useActiveIncidentsState(): { incidents: IncidentRecord[]; loaded
updated_at: toISO(data.updated_at), updated_at: toISO(data.updated_at),
} as IncidentRecord; } as IncidentRecord;
})); }));
if (!snap.metadata.fromCache) setLoaded(true);
}, (err: FirestoreError) => { console.error("useActiveIncidents:", err); }); }, (err: FirestoreError) => { console.error("useActiveIncidents:", err); });
}); });