[eric] overlays: one nudge at a time (priority queue) + OS notification when the app is hidden

This commit is contained in:
ciregenz
2026-07-28 18:39:06 -07:00
parent 781357b29c
commit 47d1f43dc4
7 changed files with 64 additions and 5 deletions
@@ -18,6 +18,7 @@ import {
hidePatternToast,
} from '@/shared/state/patternsSlice';
import { openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
import { useNudgeTurn } from '@/app/components/overlays/nudgeQueue';
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
@@ -44,6 +45,7 @@ export default function PatternOfferToast() {
const open = useAppSelector((s) => s.patterns.toastOpen);
const accepting = useAppSelector((s) => s.patterns.accepting);
const suggestion = useAppSelector((s) => s.patterns.suggestions[0]);
const myTurn = useNudgeTurn('patterns');
const onCreate = React.useCallback(async () => {
if (!suggestion) return;
@@ -61,7 +63,7 @@ export default function PatternOfferToast() {
return (
<Snackbar
open={open && !!suggestion}
open={open && !!suggestion && myTurn}
autoHideDuration={null}
// Clickaway would kill the offer on the user's first canvas click, before they read it.
onClose={(event, reason) => { if (reason !== 'clickaway') dispatch(hidePatternToast()); }}
@@ -10,12 +10,14 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { hideProviderHealthToast } from '@/shared/state/subscriptionsSlice';
import { openSettingsModal } from '@/shared/state/settingsSlice';
import { useNudgeTurn } from '@/app/components/overlays/nudgeQueue';
export default function ProviderHealthToast() {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const open = useAppSelector((s) => s.subscriptions.healthToastOpen);
const dead = useAppSelector((s) => s.subscriptions.healthDead);
const myTurn = useNudgeTurn('providerHealth');
const onReconnect = React.useCallback(() => {
dispatch(openSettingsModal('models'));
@@ -26,7 +28,7 @@ export default function ProviderHealthToast() {
return (
<Snackbar
open={open && dead.length > 0}
open={open && dead.length > 0 && myTurn}
autoHideDuration={null}
// Clickaway would kill the pill on the user's first canvas click, before they read it; only the X or Reconnect dismisses.
onClose={(event, reason) => { if (reason !== 'clickaway') dispatch(hideProviderHealthToast()); }}
@@ -13,6 +13,7 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { hideTriggersHealthToast } from '@/shared/state/triggersHealthSlice';
import { openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
import { useNudgeTurn } from '@/app/components/overlays/nudgeQueue';
export default function TriggerHealthToast() {
const c = useClaudeTokens();
@@ -20,6 +21,7 @@ export default function TriggerHealthToast() {
const open = useAppSelector((s) => s.triggersHealth.toastOpen);
const items = useAppSelector((s) => s.triggersHealth.items);
const first = items[0];
const myTurn = useNudgeTurn('triggersHealth');
const onReview = React.useCallback(() => {
if (first) dispatch(openWorkflowsApp({ workflowId: first.workflow_id }));
@@ -30,7 +32,7 @@ export default function TriggerHealthToast() {
return (
<Snackbar
open={open && !!first}
open={open && !!first && myTurn}
autoHideDuration={null}
onClose={(event, reason) => { if (reason !== 'clickaway') dispatch(hideTriggersHealthToast()); }}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
@@ -0,0 +1,22 @@
// One bottom-left nudge at a time. Priority: needs-you beats broken-login beats
// missed-runs beats offers; the next pill renders only once the current one is
// acted on or dismissed. Pure selector logic so each toast stays a dumb reader.
import { useAppSelector } from '@/shared/hooks';
export type NudgeKind = 'triggersHealth' | 'providerHealth' | 'missedRuns' | 'patterns';
const PRIORITY: NudgeKind[] = ['triggersHealth', 'providerHealth', 'missedRuns', 'patterns'];
export function useNudgeTurn(kind: NudgeKind): boolean {
const open: Record<NudgeKind, boolean> = {
triggersHealth: useAppSelector((s) => s.triggersHealth.toastOpen && s.triggersHealth.items.length > 0),
providerHealth: useAppSelector((s) => s.subscriptions.healthToastOpen && s.subscriptions.healthDead.length > 0),
missedRuns: useAppSelector((s) => s.missedRuns.toastOpen && s.missedRuns.items.length > 0),
patterns: useAppSelector((s) => s.patterns.toastOpen && s.patterns.suggestions.length > 0),
};
for (const k of PRIORITY) {
if (open[k]) return k === kind;
}
return false;
}
@@ -10,12 +10,14 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { hideMissedRunsToast } from '@/shared/state/missedRunsSlice';
import { openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
import { useNudgeTurn } from '@/app/components/overlays/nudgeQueue';
export default function MissedRunsToast() {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const open = useAppSelector((s) => s.missedRuns.toastOpen);
const count = useAppSelector((s) => s.missedRuns.items.length);
const myTurn = useNudgeTurn('missedRuns');
const onReview = React.useCallback(() => {
dispatch(openWorkflowsApp());
@@ -24,7 +26,7 @@ export default function MissedRunsToast() {
return (
<Snackbar
open={open && count > 0}
open={open && count > 0 && myTurn}
autoHideDuration={null}
onClose={() => dispatch(hideMissedRunsToast())}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
+18
View File
@@ -59,3 +59,21 @@ export function notifyAgentCompletion(p: AgentCompletionPayload): void {
// Notification API can throw if sandboxed or headless; fail silently.
}
}
/** A watcher (or similar background surface) needs the user: same hidden-only + permission + debounce gates as completions, so it can never double-tap a visible app. */
export function notifyNeedsAttention(title: string, body: string, tag: string): void {
if (typeof document === 'undefined' || !document.hidden) return;
if (typeof Notification === 'undefined') return;
if (ensurePermission() !== 'granted') return;
const key = `attention:${tag}`;
if (FIRED_RECENTLY.has(key)) return;
FIRED_RECENTLY.add(key);
setTimeout(() => FIRED_RECENTLY.delete(key), COOLDOWN_MS);
try {
const n = new Notification(title, { body: body.slice(0, 140), tag, silent: false });
n.onclick = () => {
try { window.focus(); } catch { /* focus can throw headless */ }
n.close();
};
} catch { /* sandboxed/headless */ }
}
@@ -1,5 +1,6 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
import { notifyNeedsAttention } from '@/shared/notifications';
export interface TriggerAttentionItem {
workflow_id: string;
@@ -23,7 +24,17 @@ export const fetchTriggersAttention = createAsyncThunk(
'triggersHealth/fetch',
async (): Promise<{ attention: TriggerAttentionItem[] }> => {
const r = await fetch(`${API_BASE}/workflows/triggers/attention`);
return (await r.json()) as { attention: TriggerAttentionItem[] };
const data = (await r.json()) as { attention: TriggerAttentionItem[] };
const first = data.attention?.[0];
if (first) {
// App hidden = the in-app pill can't reach them; the OS notification can (no-op when visible).
notifyNeedsAttention(
`A watcher on "${first.workflow_title}" needs you`,
first.needs || first.last_error || `${first.consecutive_failures} failures in a row`,
`trigger-health:${first.trigger_id}`,
);
}
return data;
},
);