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 && (
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Attention cues (server-26#238) — the opt-in half: an audible tone and
|
||||
* browser notifications, both OFF by default.
|
||||
*
|
||||
* Tone: Web Audio beep, no asset. Browsers only let audio start from a user
|
||||
* gesture, so the AudioContext is created by the click that turns the tone
|
||||
* on (and that click plays one confirmation beep). After a reload with the
|
||||
* tone already on, the first click/key anywhere on the page re-arms it; until
|
||||
* then nothing plays and the button says so.
|
||||
*
|
||||
* Notifications: Notification.requestPermission() on click; fired only while
|
||||
* the tab is hidden (a visible wall already shows the lane and pulse). The
|
||||
* body ends with the Gate A machine-output notice — the title it carries is
|
||||
* pipeline output. No transcript text goes into a notification.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { DEFAULT_KEYWORDS, parseKeywords, type AttentionEvent, type AttentionPrefs } from "@/lib/attention";
|
||||
import { MACHINE_OUTPUT_NOTICE_TEXT } from "@/components/ui/MachineOutputNotice";
|
||||
|
||||
const TONE_GAP_MS = 4_000;
|
||||
const MAX_NOTIFICATIONS = 3;
|
||||
|
||||
// ── Audio (one context per page, only ever created inside a user gesture) ────
|
||||
let audioCtx: AudioContext | null = null;
|
||||
|
||||
function createAudio(): AudioContext | null {
|
||||
try {
|
||||
if (!audioCtx) {
|
||||
const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AC) return null;
|
||||
audioCtx = new AC();
|
||||
}
|
||||
if (audioCtx.state === "suspended") void audioCtx.resume().catch(() => undefined);
|
||||
return audioCtx;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** n short beeps, alternating 880/660 Hz. Silent unless a gesture already started the context. */
|
||||
function beep(n: number) {
|
||||
const a = audioCtx;
|
||||
if (!a || a.state !== "running") return;
|
||||
try {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t0 = a.currentTime + i * 0.22;
|
||||
const osc = a.createOscillator();
|
||||
const gain = a.createGain();
|
||||
osc.type = "sine";
|
||||
osc.frequency.value = i % 2 ? 660 : 880;
|
||||
gain.gain.setValueAtTime(0.0001, t0);
|
||||
gain.gain.exponentialRampToValueAtTime(0.25, t0 + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.18);
|
||||
osc.connect(gain).connect(a.destination);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + 0.2);
|
||||
}
|
||||
} catch { /* audio unavailable */ }
|
||||
}
|
||||
|
||||
type NotifyState = "unsupported" | "default" | "granted" | "denied";
|
||||
|
||||
function notifyState(): NotifyState {
|
||||
if (typeof window === "undefined" || !("Notification" in window)) return "unsupported";
|
||||
return Notification.permission as NotifyState;
|
||||
}
|
||||
|
||||
const HEADLINE: Record<AttentionEvent["kind"], string> = {
|
||||
major: "Major incident",
|
||||
escalated: "Severity escalated",
|
||||
stop: "Pursuit path: new stop",
|
||||
keyword: "Priority keyword",
|
||||
};
|
||||
|
||||
/**
|
||||
* Fires the opted-in cues for a batch of in-view events. Returns `fire` (pass
|
||||
* to useAttention's onEvents) and the live audio state for the controls.
|
||||
*/
|
||||
export function useAttentionCues(prefs: AttentionPrefs, onOpenIncident: (id: string) => void) {
|
||||
const [armed, setArmed] = useState(false);
|
||||
const lastTone = useRef(0);
|
||||
const prefsRef = useRef(prefs);
|
||||
prefsRef.current = prefs;
|
||||
const openRef = useRef(onOpenIncident);
|
||||
openRef.current = onOpenIncident;
|
||||
|
||||
// Tone on: track the context's state. Already on from an earlier visit →
|
||||
// the next gesture anywhere on the page creates/resumes the context.
|
||||
useEffect(() => {
|
||||
if (!prefs.tone) { setArmed(false); return; }
|
||||
const sync = () => setArmed(audioCtx?.state === "running");
|
||||
const arm = () => {
|
||||
const a = createAudio();
|
||||
if (a) { a.onstatechange = sync; a.resume().then(sync, () => undefined); }
|
||||
};
|
||||
if (audioCtx) { audioCtx.onstatechange = sync; sync(); }
|
||||
window.addEventListener("pointerdown", arm, { once: true });
|
||||
window.addEventListener("keydown", arm, { once: true });
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", arm);
|
||||
window.removeEventListener("keydown", arm);
|
||||
};
|
||||
}, [prefs.tone]);
|
||||
|
||||
const fire = useCallback((events: AttentionEvent[]) => {
|
||||
if (!events.length) return;
|
||||
const p = prefsRef.current;
|
||||
if (p.tone && Date.now() - lastTone.current > TONE_GAP_MS) {
|
||||
lastTone.current = Date.now();
|
||||
// Three beeps for anything reaching Major or a priority keyword, one otherwise.
|
||||
beep(events.some((e) => e.kind === "major" || e.kind === "keyword" || e.label.endsWith("Major")) ? 3 : 1);
|
||||
}
|
||||
if (!p.notify || typeof document === "undefined" || !document.hidden || notifyState() !== "granted") return;
|
||||
const send = (title: string, body: string, tag: string, incidentId: string | null) => {
|
||||
try {
|
||||
const n = new Notification(title, { body: `${body}\n${MACHINE_OUTPUT_NOTICE_TEXT}`, tag });
|
||||
n.onclick = () => {
|
||||
window.focus();
|
||||
if (incidentId) openRef.current(incidentId);
|
||||
n.close();
|
||||
};
|
||||
} catch { /* e.g. mobile Chrome needs a service worker — silently no notification */ }
|
||||
};
|
||||
for (const e of events.slice(0, MAX_NOTIFICATIONS)) {
|
||||
const head = e.kind === "keyword" ? `${HEADLINE.keyword}: ${e.label}` : e.kind === "escalated" ? `${HEADLINE.escalated}: ${e.label.replace(/^↑\s*/, "")}` : HEADLINE[e.kind];
|
||||
send(`DRB · ${head}`, e.title, `drb-attn:${e.incidentIds[0] ?? e.id}`, e.incidentIds[0] ?? null);
|
||||
}
|
||||
if (events.length > MAX_NOTIFICATIONS) {
|
||||
send("DRB · More activity", `${events.length - MAX_NOTIFICATIONS} more on the live wall.`, "drb-attn:more", null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { fire, armed };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
prefs: AttentionPrefs;
|
||||
onChange: (p: AttentionPrefs) => void;
|
||||
armed: boolean;
|
||||
}
|
||||
|
||||
export function AttentionControls({ prefs, onChange, armed }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [perm, setPerm] = useState<NotifyState>("default");
|
||||
const [kwDraft, setKwDraft] = useState(prefs.keywords.join(", "));
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => { setPerm(notifyState()); }, [open]);
|
||||
useEffect(() => { setKwDraft(prefs.keywords.join(", ")); }, [prefs.keywords]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); };
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); };
|
||||
}, [open]);
|
||||
|
||||
const toggleTone = () => {
|
||||
if (prefs.tone) { onChange({ ...prefs, tone: false }); return; }
|
||||
// This click is the gesture that unlocks audio; confirm with one beep.
|
||||
const a = createAudio();
|
||||
onChange({ ...prefs, tone: true });
|
||||
if (a) a.resume().then(() => beep(1), () => undefined);
|
||||
};
|
||||
|
||||
const toggleNotify = async () => {
|
||||
if (prefs.notify) { onChange({ ...prefs, notify: false }); return; }
|
||||
if (notifyState() === "unsupported") { setPerm("unsupported"); return; }
|
||||
let p: NotifyState = notifyState();
|
||||
if (p === "default") {
|
||||
try { p = (await Notification.requestPermission()) as NotifyState; } catch { p = notifyState(); }
|
||||
}
|
||||
setPerm(p);
|
||||
if (p === "granted") onChange({ ...prefs, notify: true });
|
||||
};
|
||||
|
||||
const commitKeywords = () => {
|
||||
const next = parseKeywords(kwDraft);
|
||||
if (next.join("\u0001") !== prefs.keywords.join("\u0001")) onChange({ ...prefs, keywords: next });
|
||||
else setKwDraft(prefs.keywords.join(", "));
|
||||
};
|
||||
|
||||
const notifyOn = prefs.notify && perm !== "denied" && perm !== "unsupported";
|
||||
const summary = [prefs.tone ? (armed ? "tone" : "tone (click to arm)") : null, notifyOn ? "notify" : null].filter(Boolean).join(" · ") || "off";
|
||||
const on = prefs.tone || notifyOn;
|
||||
|
||||
return (
|
||||
<div ref={boxRef} className="relative">
|
||||
<button type="button" aria-expanded={open} aria-haspopup="dialog" onClick={() => setOpen((v) => !v)}
|
||||
title="Attention cues: an audible tone and browser notifications for new Majors, escalations, pursuit stops and priority keywords"
|
||||
className={`px-2 py-0.5 rounded border whitespace-nowrap ${on ? "border-accent text-accent" : "border-line text-ink-2 hover:text-ink"}`}>
|
||||
Cues: {summary}
|
||||
</button>
|
||||
{open && (
|
||||
<div role="dialog" aria-label="Attention cues"
|
||||
className="absolute right-0 top-full mt-1 z-[1100] w-72 max-w-[calc(100vw-2rem)] bg-surface border border-line rounded-lg shadow-lg p-3 space-y-3 text-xs text-ink-2">
|
||||
<p className="text-ink-muted leading-snug">
|
||||
Cues fire on: a Major opened, severity escalated, a pursuit path gaining a stop, or a priority keyword in a new
|
||||
call. Only for incidents the current filter shows.
|
||||
</p>
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={prefs.tone} onChange={toggleTone} />
|
||||
<span>
|
||||
<span className="text-ink font-medium">Sound a tone</span>
|
||||
{prefs.tone && !armed && (
|
||||
<span className="block text-ink-muted">Click anywhere on the page to arm it — browsers block sound until you do.</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={notifyOn} disabled={perm === "unsupported"} onChange={() => { void toggleNotify(); }} />
|
||||
<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's site settings — allow notifications there first.</span>}
|
||||
{perm === "unsupported" && <span className="block text-ink-muted">This browser doesn't support page notifications.</span>}
|
||||
</span>
|
||||
</label>
|
||||
<div>
|
||||
<label htmlFor="attn-kw" className="block text-ink font-medium mb-1">Priority keywords</label>
|
||||
<input id="attn-kw" type="text" value={kwDraft} onChange={(e) => setKwDraft(e.target.value)} onBlur={commitKeywords}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") commitKeywords(); }}
|
||||
placeholder="comma-separated"
|
||||
className="w-full px-2 py-1 rounded border border-line bg-raised text-ink" />
|
||||
<div className="mt-1 flex items-center justify-between text-ink-muted">
|
||||
<span>Matched against new call transcripts.</span>
|
||||
<button type="button" className="underline hover:text-ink"
|
||||
onClick={() => onChange({ ...prefs, keywords: [...DEFAULT_KEYWORDS] })}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,14 @@ function InfoGlyph({ className }: { className?: string }) {
|
||||
}
|
||||
|
||||
const LEAD = "Machine-generated and unverified";
|
||||
const SHORT_BODY = "automated output, may contain errors.";
|
||||
|
||||
/**
|
||||
* The short notice as plain text, for surfaces that cannot render this
|
||||
* component — e.g. a browser notification body (server-26#238). Same copy
|
||||
* rules as above; it is the "popup"/"inline" wording verbatim.
|
||||
*/
|
||||
export const MACHINE_OUTPUT_NOTICE_TEXT = `${LEAD} — ${SHORT_BODY}`;
|
||||
|
||||
interface Props {
|
||||
variant?: Variant;
|
||||
@@ -62,7 +70,7 @@ export function MachineOutputNotice({ variant = "block", detail, className }: Pr
|
||||
const body =
|
||||
detail ??
|
||||
"transcripts, summaries and extracted details are automated output and may contain errors. Check the recording before acting on them.";
|
||||
const shortBody = detail ?? "automated output, may contain errors.";
|
||||
const shortBody = detail ?? SHORT_BODY;
|
||||
|
||||
if (variant === "popup") {
|
||||
// Leaflet popups render on a white wrapper regardless of theme, so this
|
||||
|
||||
Reference in New Issue
Block a user