diff --git a/frontend/src/app/components/overlays/PatternOfferToast.tsx b/frontend/src/app/components/overlays/PatternOfferToast.tsx index 5c6da082..a3b4ab00 100644 --- a/frontend/src/app/components/overlays/PatternOfferToast.tsx +++ b/frontend/src/app/components/overlays/PatternOfferToast.tsx @@ -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 ( { if (reason !== 'clickaway') dispatch(hidePatternToast()); }} diff --git a/frontend/src/app/components/overlays/ProviderHealthToast.tsx b/frontend/src/app/components/overlays/ProviderHealthToast.tsx index 028157e0..59aa7f10 100644 --- a/frontend/src/app/components/overlays/ProviderHealthToast.tsx +++ b/frontend/src/app/components/overlays/ProviderHealthToast.tsx @@ -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 ( 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()); }} diff --git a/frontend/src/app/components/overlays/TriggerHealthToast.tsx b/frontend/src/app/components/overlays/TriggerHealthToast.tsx index 217b92bf..83081e0a 100644 --- a/frontend/src/app/components/overlays/TriggerHealthToast.tsx +++ b/frontend/src/app/components/overlays/TriggerHealthToast.tsx @@ -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 ( { if (reason !== 'clickaway') dispatch(hideTriggersHealthToast()); }} anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} diff --git a/frontend/src/app/components/overlays/nudgeQueue.ts b/frontend/src/app/components/overlays/nudgeQueue.ts new file mode 100644 index 00000000..8459ce68 --- /dev/null +++ b/frontend/src/app/components/overlays/nudgeQueue.ts @@ -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 = { + 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; +} diff --git a/frontend/src/app/pages/Workflows/MissedRunsToast.tsx b/frontend/src/app/pages/Workflows/MissedRunsToast.tsx index 0cd3d086..ce87bf72 100644 --- a/frontend/src/app/pages/Workflows/MissedRunsToast.tsx +++ b/frontend/src/app/pages/Workflows/MissedRunsToast.tsx @@ -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 ( 0} + open={open && count > 0 && myTurn} autoHideDuration={null} onClose={() => dispatch(hideMissedRunsToast())} anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} diff --git a/frontend/src/shared/notifications.ts b/frontend/src/shared/notifications.ts index f7f0beaa..7b2f44aa 100644 --- a/frontend/src/shared/notifications.ts +++ b/frontend/src/shared/notifications.ts @@ -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 */ } +} diff --git a/frontend/src/shared/state/triggersHealthSlice.ts b/frontend/src/shared/state/triggersHealthSlice.ts index 63ed62f3..fe52414b 100644 --- a/frontend/src/shared/state/triggersHealthSlice.ts +++ b/frontend/src/shared/state/triggersHealthSlice.ts @@ -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; }, );