diff --git a/drb-frontend/components/live/AttentionControls.tsx b/drb-frontend/components/live/AttentionControls.tsx index 2835c8b..d49d804 100644 --- a/drb-frontend/components/live/AttentionControls.tsx +++ b/drb-frontend/components/live/AttentionControls.tsx @@ -173,6 +173,20 @@ export function AttentionControls({ prefs, onChange, armed }: Props) { 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 }); }; @@ -215,7 +229,7 @@ export function AttentionControls({ prefs, onChange, armed }: Props) { Browser notifications while this tab is hidden {perm === "denied" && Blocked in this browser's site settings — allow notifications there first.} - {perm === "unsupported" && This browser doesn't support page notifications.} + {perm === "unsupported" && This browser doesn't show notifications from a web page (mobile browsers often only allow them from installed apps).}
diff --git a/drb-frontend/components/live/NewLane.tsx b/drb-frontend/components/live/NewLane.tsx index 3b8f958..9af27a5 100644 --- a/drb-frontend/components/live/NewLane.tsx +++ b/drb-frontend/components/live/NewLane.tsx @@ -8,7 +8,10 @@ * * 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 - * 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. */ import { useState } from "react"; @@ -31,7 +34,7 @@ function Row({ e, now, onSelect }: { e: LaneEntry; now: number; onSelect: (id: s {ago(e.at, now)} ); - 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 (
  • {e.incidentId ? ( @@ -53,28 +56,30 @@ export function NewLane({ entries, now, onSelect }: { entries: LaneEntry[]; now: return ( <>
    + 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">

    New · last 15 min ({entries.length})

    -
      +
        {entries.map((e) => )} -
      • {notice}
      +
      {notice}
    -
    +
    {open && ( -
      - {entries.map((e) => ( - { setOpen(false); onSelect(id); }} /> - ))} -
    • {notice}
    • -
    +
    +
      + {entries.map((e) => ( + { setOpen(false); onSelect(id); }} /> + ))} +
    +
    {notice}
    +
    )}
    diff --git a/drb-frontend/lib/attention.ts b/drb-frontend/lib/attention.ts index aed3a34..280e684 100644 --- a/drb-frontend/lib/attention.ts +++ b/drb-frontend/lib/attention.ts @@ -404,7 +404,16 @@ export function useAttention({ ready, resetKey, incidents, calls, keywords, visi // eslint-disable-next-line react-hooks/exhaustive-deps }, [ready, resetKey, incidents, calls, kwKey]); - const lane = useMemo(() => laneEntries(visible, seenAt, log, now, visibleCallIds), [visible, seenAt, log, now, visibleCallIds]); - const pulse = useMemo(() => pulseIds(visibleIds, seenAt, log, now), [visibleIds, seenAt, log, now]); + // A keyword usually fires on the transcript, before correlation links the + // 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(); + 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 }; } diff --git a/drb-frontend/lib/useIncidents.ts b/drb-frontend/lib/useIncidents.ts index 6452898..4c3797f 100644 --- a/drb-frontend/lib/useIncidents.ts +++ b/drb-frontend/lib/useIncidents.ts @@ -157,7 +157,16 @@ export function useActiveIncidentsState(): { incidents: IncidentRecord[]; loaded } 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) => { const data = d.data(); return { @@ -166,7 +175,6 @@ export function useActiveIncidentsState(): { incidents: IncidentRecord[]; loaded updated_at: toISO(data.updated_at), } as IncidentRecord; })); - if (!snap.metadata.fromCache) setLoaded(true); }, (err: FirestoreError) => { console.error("useActiveIncidents:", err); }); });