mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user