feat: v0.17.0 — ISS-008 toasts for errors/events + browser notifications
CI / build + unit + e2e (pull_request) Failing after 6m59s

This commit is contained in:
2026-09-19 14:52:36 +00:00
parent eb69359a38
commit d8dc785757
12 changed files with 545 additions and 9 deletions
+43 -2
View File
@@ -16,6 +16,8 @@ interface LeftPaneProps {
onRefresh: () => void;
// ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane.
onHover: (target: HoverTarget | null) => void;
// ISS-008 (v0.17.0): report a failed sim-control POST to the shell as an error toast.
onError?: (message: string) => void;
}
// Discrete ladder slider (v0.9.2, values reworked in v0.14.0): position t ∈ [0,1] → nearest
@@ -46,7 +48,7 @@ export function pendingScaleConfirmed(pendingScale: number | null, serverTimeSca
return pendingScale !== null && serverTimeScale === pendingScale;
}
export default function LeftPane({ state, loading, error, onRefresh, onHover }: LeftPaneProps) {
export default function LeftPane({ state, loading, error, onRefresh, onHover, onError }: LeftPaneProps) {
const [busy, setBusy] = useState(false);
// POST /api/sim, then refetch the full snapshot immediately so the pane reflects the new
@@ -90,6 +92,8 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover }:
}
} catch (err) {
console.error('failed to apply sim controls', err);
// ISS-008: surface the failure as an error toast too — previously console-only.
onError?.(`Failed to update time controls (${err instanceof Error ? err.message : String(err)})`);
queuedRef.current = null; // don't replay a stale intent after a failed round-trip
} finally {
busyRef.current = false;
@@ -97,7 +101,7 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover }:
}
return sentOwn ? 'sent' : 'failed';
},
[onRefresh],
[onRefresh, onError],
);
const paused = state?.simState.paused ?? false;
@@ -166,6 +170,25 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover }:
[],
);
// ISS-008: browser-notification opt-in. Starts null (SSR-safe — the real permission is read
// in an effect after hydration, so server and client render identically) and follows the
// result of Notification.requestPermission(). 'unsupported' = no Notification API at all.
const [notifPermission, setNotifPermission] = useState<string | null>(null);
useEffect(() => {
if ('Notification' in window) setNotifPermission(Notification.permission);
else setNotifPermission('unsupported');
}, []);
// null (pre-effect) and 'default' both read as the opt-in prompt.
const notifLabel: string =
notifPermission === 'granted'
? 'Notifications enabled'
: notifPermission === 'denied'
? 'Notifications blocked'
: notifPermission === 'unsupported'
? 'Notifications unavailable'
: 'Enable notifications';
const displayedScale = pendingScale ?? timeScale;
// ISS-007: the clock ticks client-side between polls, re-syncing to server truth on every
@@ -253,6 +276,24 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover }:
/>
<span data-testid="time-scale-value">{displayedScale}×</span>
</div>
{/* ISS-008: explicit opt-in for browser notifications (fired when the window is hidden). */}
<button
type="button"
data-testid="enable-notifications"
className="notif-toggle"
disabled={notifPermission === 'unsupported'}
title={notifPermission === 'unsupported' ? 'This browser does not support notifications' : undefined}
onClick={() => {
// A rejection (rare — e.g. some headless setups) reads as "blocked".
if ('Notification' in window) {
void Notification.requestPermission()
.then(setNotifPermission)
.catch(() => setNotifPermission('denied'));
}
}}
>
{notifLabel}
</button>
</section>
{error && (