mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 05:07:40 +02:00
[eric] overlays: one nudge at a time (priority queue) + OS notification when the app is hidden
This commit is contained in:
@@ -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' }}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user