[eric] notifications: a finished workflow's native notification called a preload bridge that was never added, so it never fired

This commit is contained in:
ciregenz
2026-07-31 23:36:05 -07:00
parent d756ad9897
commit dac60995e2
7 changed files with 204 additions and 61 deletions
+63
View File
@@ -59,3 +59,66 @@ export function notifyAgentCompletion(p: AgentCompletionPayload): void {
// Notification API can throw if sandboxed or headless; fail silently.
}
}
export type WorkflowNotificationOutcome = 'open' | 'ack' | 'rerun' | 'edit';
export interface WorkflowRunNotification {
workflowId: string;
workflowTitle: string;
runId?: string;
sessionId?: string;
status: string;
tierKind?: string;
fallback?: boolean;
}
const SUCCESS_TITLES = ['{name} is done', '{name} just wrapped up', 'Heads up: {name} finished', '{name} is ready'];
const FAILURE_TITLES = ['{name} hit a snag', "{name} couldn't finish", 'Something went sideways on {name}'];
const LATE_TITLES = ['{name} caught up late', '{name} ran late but made it'];
function workflowTitleFor(p: WorkflowRunNotification): string {
const name = p.workflowTitle || 'Workflow';
const pool = p.status === 'success' ? SUCCESS_TITLES
: p.status === 'failure' ? FAILURE_TITLES
: p.status === 'ran_late' ? LATE_TITLES
: null;
if (!pool) return `${name}: ${p.status}`;
// Seed by workflow id + current minute so two workflows pick different copy while one workflow stays stable across a few minutes.
const seed = Math.abs((p.workflowId.length + Math.floor(Date.now() / 60_000)) | 0);
return pool[seed % pool.length].replace('{name}', name);
}
function workflowBodyFor(p: WorkflowRunNotification): string {
if (p.tierKind && p.fallback) {
return `Would have ${p.tierKind === 'call' ? 'called' : 'texted'} you. (Cloud SMS not wired yet.)`;
}
const verb = typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform) ? 'Tap' : 'Click';
if (p.status === 'success') return `${verb} to see what it did.`;
if (p.status === 'failure') return `${verb} to see what went wrong.`;
return `${verb} to open the run.`;
}
/** Native OS notification for a finished workflow run. Prefers the Electron main process, which reaches Notification Center even with the window hidden or the renderer backgrounded; the renderer's own Notification API is the browser-only fallback and it only fires when the tab is hidden. */
export function notifyWorkflowRun(p: WorkflowRunNotification): void {
const bridge = typeof window !== 'undefined' ? window.openswarm : undefined;
if (!bridge?.notify) {
notifyAgentCompletion({
sessionId: p.sessionId || p.workflowId,
sessionName: p.workflowTitle || 'Workflow',
status: p.status === 'success' ? 'completed' : 'error',
});
return;
}
bridge.notify({
title: workflowTitleFor(p),
body: workflowBodyFor(p),
deepLink: `openswarm://workflow/${p.workflowId}/run/${p.runId || ''}`,
runId: p.runId,
workflowId: p.workflowId,
actions: [
{ text: 'Looks good', outcome: 'ack' },
{ text: 'Re-run', outcome: 'rerun' },
{ text: 'Adjust', outcome: 'edit' },
],
}).catch(() => { /* the OS refused it; the run is still on the canvas */ });
}
+11 -49
View File
@@ -35,7 +35,7 @@ import { displaySessionName } from '../state/sessionDisplay';
import { upsertRun, ackRun, runWorkflowNow, openWorkflowCard, upsertWorkflow, removeWorkflow } from '../state/workflowsSlice';
import { stepsSignature } from '@/app/pages/Workflows/scheduleUtils';
import { getAuthToken } from '../config';
import { notifyAgentCompletion } from '../notifications';
import { notifyAgentCompletion, notifyWorkflowRun } from '../notifications';
// Phase 0 boot instrumentation: one-shot flag so we report the first streamed agent token to Electron main exactly once per app launch. Module scope (not instance) because multiple WebSocketManagers exist (one per session WS).
let firstAgentResponseMarked = false;
@@ -763,55 +763,17 @@ class WebSocketManager {
break;
case 'workflow:notify':
try {
notifyAgentCompletion({
sessionId: data.session_id || data.workflow_id,
sessionName: data.workflow_title || 'Workflow',
status: data.status === 'success' ? 'completed' : 'error',
if (data.workflow_id) {
notifyWorkflowRun({
workflowId: data.workflow_id,
workflowTitle: data.workflow_title || 'Workflow',
runId: data.run_id,
sessionId: data.session_id,
status: data.status,
tierKind: data.tier_kind,
fallback: data.fallback,
});
} catch { /* notifications are best-effort */ }
try {
const w: any = (window as any).openswarm;
if (w?.notify) {
// Seed by workflow id + current minute so multiple workflows pick different copy while a single workflow stays stable within a few minutes.
const seed = ((data.workflow_id || '').length + Math.floor(Date.now() / 60000)) | 0;
const SUCCESS_TITLES = [
`${data.workflow_title || 'Workflow'} — done`,
`${data.workflow_title || 'Workflow'} just wrapped up`,
`Heads up: ${data.workflow_title || 'Workflow'} finished`,
`${data.workflow_title || 'Workflow'} is ready`,
];
const FAILURE_TITLES = [
`${data.workflow_title || 'Workflow'} hit a snag`,
`${data.workflow_title || 'Workflow'} couldn't finish`,
`Something went sideways on ${data.workflow_title || 'Workflow'}`,
];
const LATE_TITLES = [
`${data.workflow_title || 'Workflow'} caught up late`,
`${data.workflow_title || 'Workflow'} ran late but made it`,
];
const pool = data.status === 'success' ? SUCCESS_TITLES
: data.status === 'failure' ? FAILURE_TITLES
: data.status === 'ran_late' ? LATE_TITLES
: [`${data.workflow_title || 'Workflow'} • ${data.status}`];
const title = pool[Math.abs(seed) % pool.length];
const isMac = (typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform));
const body = data.tier_kind && data.fallback
? `Would have ${data.tier_kind === 'call' ? 'called' : 'texted'} you. (Cloud SMS not wired yet.)`
: data.status === 'success'
? (isMac ? 'Tap to see what it did.' : 'Click to see what it did.')
: data.status === 'failure'
? (isMac ? 'Tap to see what went wrong.' : 'Click to see what went wrong.')
: (isMac ? 'Tap to open the run.' : 'Click to open the run.');
const deepLink = data.workflow_id ? `openswarm://workflow/${data.workflow_id}/run/${data.run_id || ''}` : undefined;
const actions = [
{ text: 'Looks good', outcome: 'ack' },
{ text: 'Re-run', outcome: 'rerun' },
{ text: 'Adjust', outcome: 'edit' },
];
w.notify({ title, body, deepLink, runId: data.run_id, workflowId: data.workflow_id, actions });
}
} catch { /* native notif optional */ }
}
break;
case 'dashboard:browser_card_keep':