From 2d954b24cabf0937d0ac3cfe53dc24522db07335 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 13 Jul 2026 17:48:33 +0100 Subject: [PATCH] Badge the Actions tab with the pending-approval count Actions awaiting a decision are time-boxed, but the nav gave no signal unless the approval happened to be Patrol-origin and the user was already on Patrol. Poll the canonical decision queue alongside the existing 30s open-work refresh and surface the count on the Actions tab, matching the Alerts/Patrol badge pattern. Sessions without the action-approve capability stop polling after the first terminal response. --- frontend-modern/src/AppLayout.tsx | 8 ++++- .../__tests__/actionApprovalBadge.test.ts | 31 ++++++++++++++++++ .../features/actions/actionPresentation.ts | 16 ++++++++++ frontend-modern/src/stores/actionInbox.ts | 32 +++++++++++++++++++ frontend-modern/src/useAppRuntimeState.ts | 8 +++-- 5 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 frontend-modern/src/features/actions/__tests__/actionApprovalBadge.test.ts create mode 100644 frontend-modern/src/stores/actionInbox.ts diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index 99d01f1a5..af88deed0 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -50,6 +50,8 @@ import { import { getKioskModePreference, setKioskMode } from '@/utils/url'; import { updateStore } from '@/stores/updates'; import { aiChatStore } from '@/stores/aiChat'; +import { getActionApprovalBadgePresentation } from '@/features/actions/actionPresentation'; +import { actionInboxStore } from '@/stores/actionInbox'; import { aiIntelligenceStore } from '@/stores/aiIntelligence'; import { isPro } from '@/stores/licenseCommercial'; import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy'; @@ -396,6 +398,9 @@ export function AppLayout(props: AppLayoutProps) { const getActiveTabDesktop = () => getActiveTabForPath(location.pathname); const getActiveTabMobile = () => getActiveTabForPath(location.pathname); const assistantPageContext = createMemo(() => getAssistantPageContext(location.pathname)); + const actionApprovalBadge = createMemo(() => + getActionApprovalBadgePresentation(actionInboxStore.pendingActionCount), + ); const patrolOpenWorkCount = createMemo(() => aiIntelligenceStore.patrolOpenWorkCount); const patrolOpenWorkCountLabel = createMemo(() => { const count = patrolOpenWorkCount(); @@ -520,7 +525,8 @@ export function AppLayout(props: AppLayoutProps) { route: '/actions', tooltip: 'Review proposed changes and verified outcomes', badge: null, - count: undefined, + count: actionApprovalBadge()?.count, + countLabel: actionApprovalBadge()?.label, breakdown: undefined, icon: ListChecksIcon, }, diff --git a/frontend-modern/src/features/actions/__tests__/actionApprovalBadge.test.ts b/frontend-modern/src/features/actions/__tests__/actionApprovalBadge.test.ts new file mode 100644 index 000000000..89afebb66 --- /dev/null +++ b/frontend-modern/src/features/actions/__tests__/actionApprovalBadge.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { getActionApprovalBadgePresentation } from '../actionPresentation'; + +describe('getActionApprovalBadgePresentation', () => { + it('returns null when nothing awaits approval', () => { + expect(getActionApprovalBadgePresentation(0)).toBeNull(); + expect(getActionApprovalBadgePresentation(-2)).toBeNull(); + expect(getActionApprovalBadgePresentation(Number.NaN)).toBeNull(); + }); + + it('labels a single pending approval', () => { + expect(getActionApprovalBadgePresentation(1)).toEqual({ + count: 1, + label: '1 action awaits approval', + }); + }); + + it('labels multiple pending approvals', () => { + expect(getActionApprovalBadgePresentation(3)).toEqual({ + count: 3, + label: '3 actions await approval', + }); + }); + + it('floors fractional counts from defensive callers', () => { + expect(getActionApprovalBadgePresentation(2.9)).toEqual({ + count: 2, + label: '2 actions await approval', + }); + }); +}); diff --git a/frontend-modern/src/features/actions/actionPresentation.ts b/frontend-modern/src/features/actions/actionPresentation.ts index f0102606b..ed8993930 100644 --- a/frontend-modern/src/features/actions/actionPresentation.ts +++ b/frontend-modern/src/features/actions/actionPresentation.ts @@ -64,6 +64,22 @@ export const sortOpenActionsForReview = ( return Date.parse(right.updatedAt) - Date.parse(left.updatedAt); }); +export interface ActionApprovalBadgePresentation { + count: number; + label: string; +} + +export const getActionApprovalBadgePresentation = ( + count: number, +): ActionApprovalBadgePresentation | null => { + if (!Number.isFinite(count) || count <= 0) return null; + const normalized = Math.floor(count); + return { + count: normalized, + label: `${normalized} ${normalized === 1 ? 'action awaits' : 'actions await'} approval`, + }; +}; + export interface ActionResourcePresentation { detail: string; label: string; diff --git a/frontend-modern/src/stores/actionInbox.ts b/frontend-modern/src/stores/actionInbox.ts new file mode 100644 index 000000000..cf2f024a0 --- /dev/null +++ b/frontend-modern/src/stores/actionInbox.ts @@ -0,0 +1,32 @@ +import { createSignal } from 'solid-js'; +import { ResourceActionsAPI } from '@/api/resourceActions'; +import { logger } from '@/utils/logger'; + +const [pendingActionCount, setPendingActionCount] = createSignal(0); + +// The decision queue requires the action-approve capability, so a session +// without it gets a terminal 402/403/404 — stop polling for the rest of the +// session instead of re-asking every refresh. +let decisionQueueUnavailable = false; + +export const actionInboxStore = { + get pendingActionCount() { + return pendingActionCount(); + }, + + async loadPendingActionCount() { + if (decisionQueueUnavailable) return; + try { + const response = await ResourceActionsAPI.listPendingActions(); + setPendingActionCount(response.count ?? response.actions.length); + } catch (cause) { + const status = (cause as { status?: number }).status; + if (status === 402 || status === 403 || status === 404) { + decisionQueueUnavailable = true; + setPendingActionCount(0); + return; + } + logger.debug('Failed to load pending action count', cause); + } + }, +}; diff --git a/frontend-modern/src/useAppRuntimeState.ts b/frontend-modern/src/useAppRuntimeState.ts index c83ab7ae7..b26f7fa37 100644 --- a/frontend-modern/src/useAppRuntimeState.ts +++ b/frontend-modern/src/useAppRuntimeState.ts @@ -26,6 +26,7 @@ import { eventBus } from '@/stores/events'; import { showToast } from '@/utils/toast'; import { updateStore } from '@/stores/updates'; import { useAlertsActivation } from '@/stores/alertsActivation'; +import { actionInboxStore } from '@/stores/actionInbox'; import { aiIntelligenceStore } from '@/stores/aiIntelligence'; import { applyThemeClass, @@ -486,15 +487,16 @@ export const useAppRuntimeState = () => { const ready = !isLoading() && !needsAuth(); if (!ready) return; - const refreshPatrolOpenWork = () => { + const refreshOpenWorkBadges = () => { void Promise.allSettled([ aiIntelligenceStore.loadPatrolFindings(), aiIntelligenceStore.loadPendingApprovals(), + actionInboxStore.loadPendingActionCount(), ]); }; - refreshPatrolOpenWork(); - const interval = window.setInterval(refreshPatrolOpenWork, 30000); + refreshOpenWorkBadges(); + const interval = window.setInterval(refreshOpenWorkBadges, 30000); onCleanup(() => { window.clearInterval(interval); });