Files
server-26/drb-frontend/components/live/NewLane.tsx
T
Logan CusanoandClaude Opus 5.5 eb0faf2aea 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>
2026-10-05 03:45:05 -04:00

83 lines
3.8 KiB
TypeScript

"use client";
/**
* "NEW · last 15 min" (server-26#238). The rail is worst-first, so a fresh
* minor that is escalating sits under stale Majors; this lane is the
* recency view of the same FILTERED incidents, newest first, with why each
* one is here ("New", "↑ Major", "+1 stop", a priority keyword).
*
* 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.
* Hidden when there is nothing new.
*/
import { useState } from "react";
import type { LaneEntry } from "@/lib/attention";
import { SEVERITY_COLORS, isKnownSeverity } from "@/lib/severity";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
function ago(at: number, now: number): string {
const m = Math.max(0, Math.floor((now - at) / 60_000));
return m < 1 ? "now" : `${m}m`;
}
function Row({ e, now, onSelect }: { e: LaneEntry; now: number; onSelect: (id: string) => void }) {
const color = isKnownSeverity(e.severity) ? SEVERITY_COLORS[e.severity] : "var(--ink-muted)";
const body = (
<>
<span className="w-1 self-stretch rounded-full shrink-0" style={{ background: color }} aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-ink">{e.title}</span>
<span className="shrink-0 px-1 rounded bg-accent/15 text-accent font-semibold">{e.reason}</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";
return (
<li>
{e.incidentId ? (
<button type="button" className={`${cls} hover:bg-raised`} onClick={() => onSelect(e.incidentId!)}
title={`${e.title} — ${e.reason}`}>
{body}
</button>
) : (
<div className={cls} title={`${e.title} — ${e.reason} (call not linked to an incident yet)`}>{body}</div>
)}
</li>
);
}
export function NewLane({ entries, now, onSelect }: { entries: LaneEntry[]; now: number; onSelect: (id: string) => void }) {
const [open, setOpen] = useState(false);
if (!entries.length) return null;
const notice = <MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start px-1.5 pt-1" />;
return (
<>
<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">
<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>
</h2>
<ul className="min-h-0 overflow-y-auto px-0.5 pb-1">
{entries.map((e) => <Row key={e.key} e={e} now={now} onSelect={onSelect} />)}
<li>{notice}</li>
</ul>
</section>
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-[1002] md:hidden flex flex-col items-center">
<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">
● {entries.length} new · 15 min {open ? "▴" : "▾"}
</button>
{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">
{entries.map((e) => (
<Row key={e.key} e={e} now={now} onSelect={(id) => { setOpen(false); onSelect(id); }} />
))}
<li>{notice}</li>
</ul>
)}
</div>
</>
);
}