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.
This commit is contained in:
rcourtman
2026-07-13 17:48:33 +01:00
parent 7b114f4d5a
commit 2d954b24ca
5 changed files with 91 additions and 4 deletions
+7 -1
View File
@@ -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,
},
@@ -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',
});
});
});
@@ -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;
+32
View File
@@ -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);
}
},
};
+5 -3
View File
@@ -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);
});