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:
co-authored by
Claude Opus 5.5
parent
1d6e79ba25
commit
eb0faf2aea
@@ -24,13 +24,18 @@
|
||||
* and merges them in, so a long incident's path and spine are complete. An
|
||||
* `#inc=` that is not active is fetched by id (resolved → banner; missing or
|
||||
* another org → "not found"). The scrubber hatches time the window didn't load.
|
||||
*
|
||||
* Attention cues (server-26#238): lib/attention.ts diffs successive snapshots
|
||||
* (Major opened, severity up, pursuit path stop, priority keyword) after a
|
||||
* silent baseline. Cues, the NEW lane and pin pulses only ever cover the
|
||||
* FILTERED incidents; tone and notifications are opt-in (AttentionControls).
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useNodes } from "@/lib/useNodes";
|
||||
import { useSystems } from "@/lib/useSystems";
|
||||
import { useActiveCalls, useCalls, useCallsByIncident } from "@/lib/useCalls";
|
||||
import { useActiveIncidents, useIncident } from "@/lib/useIncidents";
|
||||
import { useActiveIncidentsState, useIncident } from "@/lib/useIncidents";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { useAircraft } from "@/lib/useAircraft";
|
||||
import { useVessels } from "@/lib/useVessels";
|
||||
@@ -54,6 +59,9 @@ import { CameraDrill } from "@/components/live/CameraDrill";
|
||||
import { IncidentPanel, RegionPanel } from "@/components/live/WallPanels";
|
||||
import { TruthStrip } from "@/components/live/TruthStrip";
|
||||
import { usePipelineHealth } from "@/lib/usePipelineHealth";
|
||||
import { DEFAULT_ATTENTION_PREFS, loadAttentionPrefs, saveAttentionPrefs, useAttention, type AttentionPrefs } from "@/lib/attention";
|
||||
import { NewLane } from "@/components/live/NewLane";
|
||||
import { AttentionControls, useAttentionCues } from "@/components/live/AttentionControls";
|
||||
|
||||
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
||||
|
||||
@@ -140,7 +148,7 @@ function Kpi({ value, label }: { value: string | number; label: string }) {
|
||||
export function LiveView() {
|
||||
const { nodes, loading: nodesLoading } = useNodes();
|
||||
const activeCalls = useActiveCalls();
|
||||
const activeIncidents = useActiveIncidents();
|
||||
const { incidents: activeIncidents, loaded: incidentsLoaded } = useActiveIncidentsState();
|
||||
// Wide-ish recent window: feeds incident paths, the call spine, the ticker
|
||||
// and the scrubber's density bars. Real recent calls, not a fixture.
|
||||
const { calls: recentCalls, loading: callsLoading, error: callsError, hasMore: windowFull } = useCalls(RECENT_CALLS);
|
||||
@@ -399,6 +407,32 @@ export function LiveView() {
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────
|
||||
const selectIncident = useCallback((id: string) => patchLink({ inc: id, cam: null, tour: false }), [patchLink]);
|
||||
|
||||
// ── Attention cues (server-26#238) ────────────────────────────────────────
|
||||
// Diffed over everything loaded (so widening the filter is never "new");
|
||||
// emitted, laned and pulsed only for what the filter shows.
|
||||
const [attnPrefs, setAttnPrefs] = useState<AttentionPrefs>(DEFAULT_ATTENTION_PREFS);
|
||||
useEffect(() => { setAttnPrefs(loadAttentionPrefs()); }, []);
|
||||
const changeAttnPrefs = useCallback((p: AttentionPrefs) => { setAttnPrefs(p); saveAttentionPrefs(p); }, []);
|
||||
const cues = useAttentionCues(attnPrefs, selectIncident);
|
||||
const attentionCalls = useMemo(() => {
|
||||
if (!incidentCalls.length) return recentCalls;
|
||||
const have = new Set(recentCalls.map((c) => c.call_id));
|
||||
const extra = incidentCalls.filter((c) => !have.has(c.call_id));
|
||||
return extra.length ? [...recentCalls, ...extra] : recentCalls;
|
||||
}, [recentCalls, incidentCalls]);
|
||||
const visibleCallIds = useMemo(() => new Set(filtered.calls.map((c) => c.call_id)), [filtered.calls]);
|
||||
const attention = useAttention({
|
||||
ready: incidentsLoaded && !!orgId,
|
||||
resetKey: orgId ?? null,
|
||||
incidents: activeIncidents,
|
||||
calls: attentionCalls,
|
||||
keywords: attnPrefs.keywords,
|
||||
visible: filtered.incidents,
|
||||
visibleIds: filtered.ids,
|
||||
visibleCallIds,
|
||||
onEvents: cues.fire,
|
||||
});
|
||||
const openCamera = useCallback((c: Camera) => patchLink({ cam: c.id, tour: false }), [patchLink]);
|
||||
const stepUp = useCallback(() => {
|
||||
if (link.cam) patchLink({ cam: null, tour: false });
|
||||
@@ -502,6 +536,7 @@ export function LiveView() {
|
||||
className="px-2 py-0.5 rounded border border-line text-ink-2 hover:text-ink whitespace-nowrap" aria-live="polite">
|
||||
{copied === "ok" ? "Link copied" : copied === "fail" ? "Copy failed — use the address bar" : "Copy view link"}
|
||||
</button>
|
||||
<AttentionControls prefs={attnPrefs} onChange={changeAttnPrefs} armed={cues.armed} />
|
||||
<KioskControls kiosk={kiosk} state={kioskState} onToggleKiosk={toggleKiosk} />
|
||||
<span className="font-mono text-ink-2 tabular-nums">{clock}</span>
|
||||
</div>
|
||||
@@ -539,6 +574,7 @@ export function LiveView() {
|
||||
onOpenCamera={openCamera}
|
||||
onAir={onAir}
|
||||
camCountByIncident={camCountByIncident}
|
||||
pulseIncidentIds={attention.pulseIds}
|
||||
key={mapGen}
|
||||
initialLayers={mapState.layers}
|
||||
initialBase={mapState.base}
|
||||
@@ -548,6 +584,10 @@ export function LiveView() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!nodesLoading && !callsLoading && mapState && (
|
||||
<NewLane entries={attention.lane} now={attention.now} onSelect={selectIncident} />
|
||||
)}
|
||||
|
||||
{/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's
|
||||
Activation screen handles the zero-node case). */}
|
||||
{configuredButQuiet && (
|
||||
|
||||
Reference in New Issue
Block a user