/** * desktop/src/notify-policy.ts — B2: pure notification policy for one session. * * Decides whether a status transition (prev → next snapshot) is worth a native * OS notification, and what it should say. This is the "core value" of the * desktop shell (DESKTOP_PLAN §4.1): approval gates and status changes surface * even when the window is minimized — but never nag when the user is looking. * * Pure & side-effect free; embedded-server/notifications glue calls it per * snapshot and fires the actual Electron Notification. */ import type { NotifyDecision, SessionStatusSnapshot } from './types.js' /** Runtime context the policy needs beyond the two snapshots. */ export interface NotifyOptions { /** True when the app window is focused — suppresses all notifications. */ readonly windowFocused: boolean /** User pref: notify when a session raises a pending approval. */ readonly notifyOnApproval: boolean /** User pref: notify when a session's Claude status changes. */ readonly notifyOnStatusChange: boolean } /** Length of the session-id prefix used in the fallback label. */ const ID_LABEL_LENGTH = 8 /** A silent decision — reused whenever nothing is worth notifying about. */ const NO_NOTIFY: NotifyDecision = { notify: false, title: '', body: '' } /** * Statuses worth a notification. 'working'/'idle'/'unknown' are steady-state * noise; only 'waiting' (needs you) and 'stuck' (something's wrong) are notable. */ const NOTABLE_STATUSES: ReadonlySet = new Set(['waiting', 'stuck']) /** * Decide whether next warrants a notification, given the previous snapshot and * runtime options. Priority: focus-suppression > approval rising-edge > status * change to a notable status > silence. */ export function shouldNotify( prev: SessionStatusSnapshot | undefined, next: SessionStatusSnapshot, opts: NotifyOptions, ): NotifyDecision { // 1. Don't nag when the user is already looking at the app. if (opts.windowFocused) return NO_NOTIFY // 2. Approval rising edge takes priority — a held gate is the most actionable. if (isApprovalRisingEdge(prev, next) && opts.notifyOnApproval) { return approvalDecision(next) } // 3. Status change into a notable state ('waiting' | 'stuck'). if (opts.notifyOnStatusChange && isNotableStatusChange(prev, next)) { return statusDecision(next) } return NO_NOTIFY } /** True only on the transition into pendingApproval (false/undefined → true). */ function isApprovalRisingEdge( prev: SessionStatusSnapshot | undefined, next: SessionStatusSnapshot, ): boolean { return next.pendingApproval === true && prev?.pendingApproval !== true } /** True when the status actually changed and the new status is notable. */ function isNotableStatusChange( prev: SessionStatusSnapshot | undefined, next: SessionStatusSnapshot, ): boolean { if (prev?.claudeStatus === next.claudeStatus) return false return NOTABLE_STATUSES.has(next.claudeStatus) } /** Notification for a held approval gate, naming the gate kind when known. */ function approvalDecision(next: SessionStatusSnapshot): NotifyDecision { // 'plan' gates read "plan approval"; every other gate (incl. null/tool) reads // "tool approval" — never the ungrammatical "tool tool". const gatePhrase = next.gate === 'plan' ? 'plan approval' : 'tool approval' return { notify: true, title: 'Approval needed', body: `${sessionLabel(next)} is waiting for ${gatePhrase}.`, } } /** Notification for a notable status change ('waiting' | 'stuck'). */ function statusDecision(next: SessionStatusSnapshot): NotifyDecision { const title = next.claudeStatus === 'stuck' ? 'Session may be stuck' : 'Session needs attention' return { notify: true, title, body: `${sessionLabel(next)} is now ${next.claudeStatus}.`, } } /** Human label for a session: its title, else a short id prefix. */ function sessionLabel(snapshot: SessionStatusSnapshot): string { return snapshot.title ?? `session ${snapshot.sessionId.slice(0, ID_LABEL_LENGTH)}` }