Live wall attention cues: NEW lane, pin pulse, opt-in tone and notifications (#238)

- lib/attention.ts: pure snapshot diff (Major opened, severity escalated,
  pursuit path gained a stop, priority keyword in a new transcript) with a
  silent baseline on page open; calls baseline separately on their first
  non-empty snapshot. Diffed over all active incidents so widening a filter
  is never "new"; emitted, laned and pulsed only for the FILTERED view.
- components/live/NewLane.tsx: "NEW · last 15 min" overlay above the rail
  (desktop) / compact chip (mobile), Gate A notice included.
- components/live/AttentionControls.tsx: opt-in Web Audio tone (enabling
  click unlocks audio) and Web Notifications while the tab is hidden, body
  carries the machine-output notice; prefs + keywords in localStorage.
- LiveView wires it and passes pulseIncidentIds to MapView (2 min pulse).
- useIncidents: useActiveIncidentsState exposes a loaded flag (first
  server snapshot) so the baseline never sees an empty pre-load list.
- MachineOutputNotice exports MACHINE_OUTPUT_NOTICE_TEXT (same wording).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-05 03:45:05 -04:00
co-authored by Claude Opus 5.5
parent 1d6e79ba25
commit eb0faf2aea
6 changed files with 798 additions and 5 deletions
+16 -2
View File
@@ -128,15 +128,24 @@ export function useIncident(incidentId: string | null) {
return { incident, loading: loading || (!!incidentId && settledId !== incidentId) };
}
export function useActiveIncidents() {
/**
* Active incidents plus whether the first SERVER snapshot has landed
* (server-26#238). `loaded` is what lets the wall tell "nothing active" from
* "not loaded yet" — attention cues baseline on it, so opening the page never
* fires a cue for every incident already open. Reset on sign-out / org change.
*/
export function useActiveIncidentsState(): { incidents: IncidentRecord[]; loaded: boolean } {
const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
const [loaded, setLoaded] = useState(false);
const { orgId } = useAuth();
useEffect(() => {
let unsubFirestore: (() => void) | undefined;
setLoaded(false);
const unsubAuth = onAuthStateChanged(auth, (user) => {
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
setLoaded(false);
if (!user) {
setIncidents([]);
@@ -157,6 +166,7 @@ export function useActiveIncidents() {
updated_at: toISO(data.updated_at),
} as IncidentRecord;
}));
if (!snap.metadata.fromCache) setLoaded(true);
}, (err: FirestoreError) => { console.error("useActiveIncidents:", err); });
});
@@ -166,5 +176,9 @@ export function useActiveIncidents() {
};
}, [orgId]);
return incidents;
return { incidents, loaded };
}
export function useActiveIncidents() {
return useActiveIncidentsState().incidents;
}