feat(FN-3550): add pending approval badges to agent views and header

Added pending-approval badge indicators across the agent detail view, agents overview, mailbox, header, and mobile nav, wired through new and existing agent/mailbox APIs, with test coverage and documentation updates.

Fusion-Task-Id: FN-3550
This commit is contained in:
Fusion
2026-05-09 06:12:18 -07:00
committed by gsxdsm
parent f3626489ed
commit 7cb13439d3
20 changed files with 306 additions and 12 deletions

View File

@@ -4175,6 +4175,8 @@ export interface Agent {
totalOutputTokens?: number;
/** Last error message */
lastError?: string;
/** Number of currently pending approvals requested by this agent. */
pendingApprovalCount?: number;
/** Path to a markdown file containing custom instructions (resolved relative to project root).
* Must end in `.md`, no `..` traversal. Max 500 chars. */
instructionsPath?: string;

View File

@@ -383,12 +383,14 @@ function AppInner() {
// App-level mailbox/chat unread state (used for header/mobile nav badges)
const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0);
const [mailboxPendingApprovalCount, setMailboxPendingApprovalCount] = useState(0);
const [chatHasUnreadResponse, setChatHasUnreadResponse] = useState(false);
const refreshMailboxUnreadCount = useCallback(() => {
fetchUnreadCount(currentProject?.id)
.then((data: { unreadCount: number }) => {
.then((data: { unreadCount: number; pendingApprovalCount?: number }) => {
setMailboxUnreadCount(data.unreadCount);
setMailboxPendingApprovalCount(data.pendingApprovalCount ?? 0);
})
.catch((err) => {
console.warn("[App] Failed to fetch mailbox unread count:", err);
@@ -411,6 +413,9 @@ function AppInner() {
"message:received": refreshMailboxUnreadCount,
"message:read": refreshMailboxUnreadCount,
"message:deleted": refreshMailboxUnreadCount,
"approval:requested": refreshMailboxUnreadCount,
"approval:updated": refreshMailboxUnreadCount,
"approval:decided": refreshMailboxUnreadCount,
},
});
}, [currentProject?.id, refreshMailboxUnreadCount]);
@@ -1336,6 +1341,7 @@ function AppInner() {
onOpenSystemStats={openSystemStatsWithNav}
onOpenMailbox={() => handleTaskViewChange("mailbox")}
mailboxUnreadCount={mailboxUnreadCount}
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
chatHasUnreadResponse={chatHasUnreadResponse}
onOpenSchedules={openSchedulesWithNav}
onOpenGitManager={openGitManagerWithNav}
@@ -1470,6 +1476,7 @@ function AppInner() {
onOpenMailbox={() => handleTaskViewChange("mailbox")}
onOpenNodes={handleOpenNodesWithNav}
mailboxUnreadCount={mailboxUnreadCount}
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
chatHasUnreadResponse={chatHasUnreadResponse}
onOpenGitManager={openGitManagerWithNav}
onOpenWorkflowSteps={openWorkflowStepsWithNav}

View File

@@ -7571,6 +7571,7 @@ export interface OutboxResponse {
/** Response shape for GET /messages/unread-count */
export interface UnreadCountResponse {
unreadCount: number;
pendingApprovalCount?: number;
}
/** Response shape for POST /messages/read-all */

View File

@@ -304,6 +304,13 @@
color: var(--text-muted);
}
.agent-detail-approval-badge {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
color: var(--color-warning);
}
.dashboard-summary-skills {
display: inline-flex;
align-items: center;

View File

@@ -355,24 +355,37 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const contextVersionAtStart = contextVersionRef.current;
const refreshAgentForApprovalEvent = (event: MessageEvent) => {
if (contextVersionRef.current !== contextVersionAtStart) return;
try {
const payload: unknown = JSON.parse(event.data);
if (!payload || typeof payload !== "object") return;
const approvalAgentId = (payload as { agentId?: unknown }).agentId;
if (approvalAgentId !== agentId) return;
void loadAgent();
} catch {
// Ignore malformed events
}
};
return subscribeSse(`/api/events${query}`, {
events: {
"agent:updated": (event) => {
if (contextVersionRef.current !== contextVersionAtStart) return;
try {
const payload: unknown = JSON.parse(event.data);
if (!payload || typeof payload !== "object") return;
const updatedId = (payload as { id?: unknown }).id;
if (updatedId !== agentId) return;
if (hasConfigChangesRef.current) return;
void loadAgent();
} catch {
// Ignore malformed events
}
},
"approval:requested": refreshAgentForApprovalEvent,
"approval:updated": refreshAgentForApprovalEvent,
"approval:decided": refreshAgentForApprovalEvent,
},
});
}, [agentId, projectId, loadAgent]);
@@ -962,6 +975,12 @@ function DashboardTab({
</div>
<div className="dashboard-summary-hero__meta">
<span className="dashboard-summary-hero__health" title={health.reason ?? health.label}>{health.icon} {health.label}</span>
{(agent.pendingApprovalCount ?? 0) > 0 ? (
<span className="badge agent-detail-approval-badge" title="Pending approvals">
<span className="status-dot status-dot--pending" />
{agent.pendingApprovalCount} pending approvals
</span>
) : null}
<span>Role: {agent.role}</span>
<span>
<span className="dashboard-summary-label">{runtimeHint ? "Runtime" : "Model"}</span>

View File

@@ -574,6 +574,13 @@
justify-content: flex-end;
}
.agent-approval-badge {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
color: var(--color-warning);
}
.agent-badges .badge-skill {
min-width: 0;
max-width: min(100%, calc(var(--space-2xl) * 6));

View File

@@ -1099,6 +1099,12 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
<span className="agent-board-icon"><AgentAvatar agent={agent} size={20} /></span>
<span className="agent-board-badge badge text-secondary">{getRoleLabel(agent.role)}</span>
<span className={`agent-board-badge badge ${stateBadgeClass}`}>{agent.state}</span>
{(agent.pendingApprovalCount ?? 0) > 0 ? (
<span className="agent-board-badge badge agent-approval-badge" title="Pending approvals">
<span className="status-dot status-dot--pending" />
{agent.pendingApprovalCount}
</span>
) : null}
</div>
<div className="agent-board-name">{agent.name}</div>
<div className="agent-board-id">{agent.id}</div>
@@ -1213,6 +1219,12 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
<span className="badge text-secondary">
{getRoleLabel(agent.role)}
</span>
{(agent.pendingApprovalCount ?? 0) > 0 ? (
<span className="badge agent-approval-badge" title="Pending approvals">
<span className="status-dot status-dot--pending" />
{agent.pendingApprovalCount}
</span>
) : null}
{/* List view: up to 2 skill badges */}
{(() => {
const skills = getSkillBadges(agent);

View File

@@ -184,6 +184,8 @@ export interface HeaderProps {
onOpenMailbox?: () => void;
/** Unread message count for badge display */
mailboxUnreadCount?: number;
/** Pending approval count for mailbox indicator */
mailboxPendingApprovalCount?: number;
/** Whether chat has an unread assistant response */
chatHasUnreadResponse?: boolean;
onOpenSchedules?: () => void;
@@ -253,6 +255,7 @@ export function Header({
onOpenSystemStats,
onOpenMailbox,
mailboxUnreadCount = 0,
mailboxPendingApprovalCount = 0,
chatHasUnreadResponse = false,
onOpenSchedules,
onOpenGitManager,
@@ -1148,6 +1151,9 @@ export function Header({
aria-pressed={view === "mailbox"}
>
<Mail size={16} />
{mailboxPendingApprovalCount > 0 && view !== "mailbox" && (
<span className="status-dot status-dot--pending header-chat-unread-dot" aria-label="Pending approvals" />
)}
</button>
{pluginDashboardViews
.filter((entry) => entry.view.placement === "primary")
@@ -1834,6 +1840,9 @@ export function Header({
>
<Mail size={16} />
<span>Mailbox{mailboxUnreadCount > 0 ? ` (${mailboxUnreadCount})` : ""}</span>
{mailboxPendingApprovalCount > 0 && (
<span className="header-badge" data-testid="overflow-mailbox-approval-badge">{mailboxPendingApprovalCount}</span>
)}
</button>
)}
{/* Usage - in overflow on mobile */}

View File

@@ -53,6 +53,7 @@ export interface MobileNavBarProps {
onOpenSystemStats?: () => void;
onOpenMailbox?: () => void;
mailboxUnreadCount?: number;
mailboxPendingApprovalCount?: number;
chatHasUnreadResponse?: boolean;
onOpenGitManager?: () => void;
onOpenWorkflowSteps?: () => void;
@@ -117,6 +118,7 @@ export function MobileNavBar({
onOpenSystemStats,
onOpenMailbox,
mailboxUnreadCount = 0,
mailboxPendingApprovalCount = 0,
chatHasUnreadResponse = false,
onOpenGitManager,
onOpenWorkflowSteps,
@@ -311,7 +313,12 @@ export function MobileNavBar({
aria-selected={view === "mailbox"}
onClick={() => onChangeView("mailbox")}
>
<Mail />
<span className="mobile-nav-tab-icon-wrapper">
<Mail />
{mailboxPendingApprovalCount > 0 && view !== "mailbox" && (
<span className="status-dot status-dot--pending mobile-nav-chat-unread-dot" aria-label="Pending approvals" />
)}
</span>
<span className="mobile-nav-tab-label">Mailbox</span>
{mailboxUnreadCount > 0 && (
<span className="mobile-nav-tab-badge">{formatCount(mailboxUnreadCount)}</span>
@@ -392,6 +399,9 @@ export function MobileNavBar({
{mailboxUnreadCount > 0 && (
<span className="mobile-more-item-badge mobile-more-item-badge--unread">{formatCount(mailboxUnreadCount)}</span>
)}
{mailboxPendingApprovalCount > 0 && (
<span className="mobile-more-item-badge">{formatCount(mailboxPendingApprovalCount)}</span>
)}
</button>
<button

View File

@@ -349,6 +349,22 @@ describe("AgentDetailView", () => {
expect(screen.getByText(/Loading agent/i)).toBeInTheDocument();
});
it("renders pending approval badge when agent has pending approvals", async () => {
mockFetchAgent.mockResolvedValueOnce(createMockAgent({ pendingApprovalCount: 3 }));
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText("3 pending approvals")).toBeInTheDocument();
});
});
it("renders inline mode as a region without overlay or close button", async () => {
render(
<AgentDetailView

View File

@@ -219,6 +219,20 @@ describe("AgentsView", () => {
});
});
it("shows pending approval badge when agent has pending approvals", async () => {
mockFetchAgents.mockResolvedValueOnce([
{ ...mockAgents[0], id: "agent-pending", name: "Pending Agent", pendingApprovalCount: 2 },
]);
mockFetchAgentStats.mockResolvedValueOnce({ total: 1, byState: {}, byRole: {} });
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTitle("Pending approvals")).toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
});
});
it("formats skill badge labels from SKILL.md paths", async () => {
mockFetchAgents.mockResolvedValueOnce([
{

View File

@@ -168,6 +168,16 @@ describe("Header", () => {
expect(screen.getByLabelText("Unread chat response")).toBeInTheDocument();
});
it("shows mailbox pending-approval indicator when mailbox is not active", () => {
renderHeader({ onChangeView: noop, view: "board", mailboxPendingApprovalCount: 2 });
expect(screen.getByLabelText("Pending approvals")).toBeInTheDocument();
});
it("hides mailbox pending-approval indicator when mailbox view is active", () => {
renderHeader({ onChangeView: noop, view: "mailbox", mailboxPendingApprovalCount: 2 });
expect(screen.queryByLabelText("Pending approvals")).toBeNull();
});
it("hides chat unread indicator when chat view is active", () => {
renderHeader({ onChangeView: noop, view: "chat", chatHasUnreadResponse: true });
expect(screen.queryByLabelText("Unread chat response")).toBeNull();

View File

@@ -36,6 +36,7 @@ const createDefaultProps = () => ({
onOpenMailbox: vi.fn(),
onOpenNodes: vi.fn(),
mailboxUnreadCount: 0,
mailboxPendingApprovalCount: 0,
onOpenGitManager: vi.fn(),
onOpenWorkflowSteps: vi.fn(),
onOpenSchedules: vi.fn(),
@@ -120,6 +121,16 @@ describe("MobileNavBar", () => {
expect(onOpenTodos).toHaveBeenCalled();
});
it("shows mailbox pending-approval indicator when mailbox tab is inactive", () => {
render(<MobileNavBar {...createDefaultProps()} mailboxPendingApprovalCount={2} />);
expect(screen.getByLabelText("Pending approvals")).toBeInTheDocument();
});
it("hides mailbox pending-approval indicator when mailbox tab is active", () => {
render(<MobileNavBar {...createDefaultProps()} mailboxPendingApprovalCount={2} view="mailbox" />);
expect(screen.queryByLabelText("Pending approvals")).toBeNull();
});
it("keeps dependency graph in More and routes to canonical graph task view", () => {
const props = createDefaultProps();
render(

View File

@@ -184,15 +184,15 @@ describe("useAgents", () => {
mockFetchAgents.mockClear();
mockFetchAgentStats.mockClear();
for (const event of ["agent:created", "agent:updated", "agent:deleted", "agent:stateChanged"]) {
for (const event of ["agent:created", "agent:updated", "agent:deleted", "agent:stateChanged", "approval:requested", "approval:updated", "approval:decided"]) {
act(() => {
es._emit(event);
});
}
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalledTimes(4);
expect(mockFetchAgentStats).toHaveBeenCalledTimes(4);
expect(mockFetchAgents).toHaveBeenCalledTimes(7);
expect(mockFetchAgentStats).toHaveBeenCalledTimes(7);
});
});

View File

@@ -75,6 +75,9 @@ export function useAgents(projectId?: string, options?: UseAgentsOptions) {
"agent:updated": refresh,
"agent:deleted": refresh,
"agent:stateChanged": refresh,
"approval:requested": refresh,
"approval:updated": refresh,
"approval:decided": refresh,
},
});
}, [projectId, loadAgents, loadStats]);

View File

@@ -3473,6 +3473,7 @@ describe("Messaging Routes", () => {
const unread = await GET(app, "/api/messages/unread-count");
expect(unread.body.unreadCount).toBe(3);
expect(unread.body.pendingApprovalCount).toBe(0);
const readAll = await REQUEST(app, "POST", "/api/messages/read-all");
expect(readAll.status).toBe(200);
@@ -3526,6 +3527,42 @@ describe("Messaging Routes", () => {
expect(unread).toBeDefined();
expect(res.status).toBe(200);
expect(res.body.unreadCount).toBe(1);
expect(res.body.pendingApprovalCount).toBe(0);
});
it("GET /api/messages/unread-count includes pendingApprovalCount and excludes resolved approvals", async () => {
const { ApprovalRequestStore } = await import("@fusion/core");
const approvalStore = new ApprovalRequestStore(store.getDatabase());
const pending = approvalStore.create({
requester: { actorId: "agent-1", actorType: "agent", actorName: "Agent One" },
targetAction: {
category: "command_execution",
action: "npm test",
summary: "Run tests",
resourceType: "command",
resourceId: "npm test",
},
});
const resolved = approvalStore.create({
requester: { actorId: "agent-2", actorType: "agent", actorName: "Agent Two" },
targetAction: {
category: "command_execution",
action: "npm run lint",
summary: "Run lint",
resourceType: "command",
resourceId: "npm run lint",
},
});
approvalStore.decide(resolved.id, "denied", {
actor: { actorId: "user", actorType: "user", actorName: "User" },
});
const res = await GET(app, "/api/messages/unread-count");
expect(pending).toBeDefined();
expect(res.status).toBe(200);
expect(res.body.pendingApprovalCount).toBe(1);
});
it("POST /api/messages validates required fields and creates messages", async () => {
@@ -3761,11 +3798,14 @@ describe("Agent stale task-link sanitization", () => {
let tempDir: string;
let fusionDir: string;
let agentId: string;
let routeDb: Database;
beforeEach(async () => {
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-agent-stale-"));
fusionDir = join(tempDir, ".fusion");
mkdirSync(fusionDir, { recursive: true });
routeDb = new Database(fusionDir, { inMemory: false });
routeDb.init();
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
@@ -3778,12 +3818,14 @@ describe("Agent stale task-link sanitization", () => {
});
afterEach(() => {
routeDb.close();
rmSync(tempDir, { recursive: true, force: true });
});
function buildAgentApp() {
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
getDatabase: vi.fn().mockReturnValue(routeDb),
} as any);
const app = express();
app.use(express.json());
@@ -3791,6 +3833,88 @@ describe("Agent stale task-link sanitization", () => {
return app;
}
async function createPendingApproval(requesterId: string) {
const { ApprovalRequestStore } = await import("@fusion/core");
const approvalStore = new ApprovalRequestStore(routeDb);
approvalStore.create({
requester: { actorId: requesterId, actorType: "agent", actorName: "Executor" },
targetAction: {
category: "command_execution",
action: "npm test",
summary: "Run tests",
resourceType: "command",
resourceId: "npm test",
},
});
}
it("GET /api/agents returns pendingApprovalCount=0 when no pending approvals exist", async () => {
const app = buildAgentApp();
const res = await GET(app, "/api/agents");
expect(res.status).toBe(200);
const agents = Array.isArray(res.body) ? res.body : [res.body];
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
expect(testAgent).toBeDefined();
expect(testAgent.pendingApprovalCount).toBe(0);
});
it("GET /api/agents and /api/agents/:id include pendingApprovalCount for pending approvals", async () => {
const app = buildAgentApp();
await createPendingApproval(agentId);
await createPendingApproval(agentId);
const listRes = await GET(app, "/api/agents");
expect(listRes.status).toBe(200);
const agents = Array.isArray(listRes.body) ? listRes.body : [listRes.body];
const listed = agents.find((a: { id: string }) => a.id === agentId);
expect(listed).toBeDefined();
expect(listed.pendingApprovalCount).toBe(2);
const detailRes = await GET(app, `/api/agents/${agentId}`);
expect(detailRes.status).toBe(200);
expect(detailRes.body.pendingApprovalCount).toBe(2);
});
it("GET /api/agents pendingApprovalCount excludes approvals that are no longer pending", async () => {
const app = buildAgentApp();
const { ApprovalRequestStore } = await import("@fusion/core");
const approvalStore = new ApprovalRequestStore(routeDb);
const request = approvalStore.create({
requester: { actorId: agentId, actorType: "agent", actorName: "Executor" },
targetAction: {
category: "command_execution",
action: "npm test",
summary: "Run tests",
resourceType: "command",
resourceId: "npm test",
},
});
approvalStore.decide(request.id, "approved", {
actor: { actorId: "user", actorType: "user", actorName: "User" },
});
const res = await GET(app, "/api/agents");
expect(res.status).toBe(200);
const agents = Array.isArray(res.body) ? res.body : [res.body];
const listed = agents.find((a: { id: string }) => a.id === agentId);
expect(listed).toBeDefined();
expect(listed.pendingApprovalCount).toBe(0);
});
it("GET /api/agents pendingApprovalCount ignores approvals for missing agents", async () => {
const app = buildAgentApp();
await createPendingApproval("agent-missing");
const res = await GET(app, "/api/agents");
expect(res.status).toBe(200);
const agents = Array.isArray(res.body) ? res.body : [res.body];
const listed = agents.find((a: { id: string }) => a.id === agentId);
expect(listed).toBeDefined();
expect(listed.pendingApprovalCount).toBe(0);
});
it("GET /api/agents omits taskId when linked task is done", async () => {
const doneTaskId = "FN-DONE";
const store = createMockStore({

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import type { Request, Response } from "express";
import type { Agent, AgentCapability, AgentUpdateInput, TaskStore } from "@fusion/core";
import {
ApprovalRequestStore,
getDefaultHeartbeatProcedurePath,
isAgentPermissionPolicyPresetId,
normalizeAgentPermissionPolicyFromPreset,
@@ -40,6 +41,29 @@ function isCompatibleDefaultHeartbeatPath(path: string | undefined, agent: Agent
return new RegExp(`^\\.fusion/agents/[^/]+-${safeId}/HEARTBEAT\\.md$`).test(trimmed);
}
function withPendingApprovalCounts<T extends Agent>(agents: T[], scopedStore: TaskStore): Array<T & { pendingApprovalCount: number }> {
try {
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
const pendingRequests = approvalStore.list({ status: "pending", limit: Number.MAX_SAFE_INTEGER, offset: 0 });
const counts = new Map<string, number>();
for (const request of pendingRequests) {
const agentId = request.requester.actorId;
counts.set(agentId, (counts.get(agentId) ?? 0) + 1);
}
return agents.map((agent) => ({
...agent,
pendingApprovalCount: counts.get(agent.id) ?? 0,
}));
} catch {
return agents.map((agent) => ({
...agent,
pendingApprovalCount: 0,
}));
}
}
export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: AgentCoreRouteDeps): void {
const { router, getProjectContext, rethrowAsApiError } = ctx;
const { sanitizeAgentTaskLinks, validateAgentInstructionsPayload } = deps;
@@ -69,7 +93,7 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
const agents = await agentStore.listAgents(filter as { state?: "idle" | "active" | "running" | "paused" | "error"; role?: AgentCapability; includeEphemeral?: boolean });
const sanitizedAgents = await sanitizeAgentTaskLinks(agents, scopedStore);
res.json(sanitizedAgents);
res.json(withPendingApprovalCounts(sanitizedAgents, scopedStore));
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
@@ -345,7 +369,8 @@ export function registerAgentCoreRoutes(ctx: ApiRoutesContext, deps: AgentCoreRo
}
// Sanitize taskId for single-agent responses (omit if linked task is terminal)
const [sanitizedAgent] = await sanitizeAgentTaskLinks([agent], scopedStore);
res.json(sanitizedAgent);
const [agentWithPendingApprovals] = withPendingApprovalCounts([sanitizedAgent], scopedStore);
res.json(agentWithPendingApprovals);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;

View File

@@ -1,6 +1,6 @@
import type { Request } from "express";
import { resolve } from "node:path";
import { DASHBOARD_USER_ID, MessageStore, type MessageType, type ParticipantType, validateMessageMetadata } from "@fusion/core";
import { ApprovalRequestStore, DASHBOARD_USER_ID, MessageStore, type MessageType, type ParticipantType, validateMessageMetadata } from "@fusion/core";
import { ApiError, badRequest, notFound } from "../api-error.js";
import { getTerminalService } from "../terminal-service.js";
import type { ApiRoutesContext } from "./types.js";
@@ -262,8 +262,16 @@ export function registerMessagingScriptRoutes(ctx: ApiRoutesContext): void {
router.get("/messages/unread-count", async (req, res) => {
try {
const msgStore = await getMessageStore(req);
const { store: scopedStore } = await getProjectContext(req);
const mailbox = await msgStore.getMailbox(DASHBOARD_USER_ID, "user");
res.json({ unreadCount: mailbox.unreadCount });
let pendingApprovalCount = 0;
try {
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
pendingApprovalCount = approvalStore.list({ status: "pending", limit: Number.MAX_SAFE_INTEGER, offset: 0 }).length;
} catch {
pendingApprovalCount = 0;
}
res.json({ unreadCount: mailbox.unreadCount, pendingApprovalCount });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;