diff --git a/ui/src/hooks/useGateIndicator.test.ts b/ui/src/hooks/useGateIndicator.test.ts new file mode 100644 index 00000000..4f8aa4ea --- /dev/null +++ b/ui/src/hooks/useGateIndicator.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment jsdom +import { describe, it, expect } from "vitest"; +import type { Approval } from "@paperclipai/shared"; +import { countPendingGates } from "./useGateIndicator"; + +const NOW = new Date("2026-04-11T12:00:00Z"); + +function makeApproval(overrides: Partial = {}): Approval { + return { + id: `gate-${Math.random().toString(36).slice(2, 8)}`, + companyId: "co-1", + type: "generic", + requestedByAgentId: null, + requestedByUserId: null, + status: "pending", + payload: {}, + decisionNote: null, + decidedByUserId: null, + decidedAt: null, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + } as Approval; +} + +describe("countPendingGates", () => { + it("returns 0 for undefined or empty lists", () => { + expect(countPendingGates(undefined)).toBe(0); + expect(countPendingGates([])).toBe(0); + }); + + it("counts approvals with status 'pending'", () => { + const approvals = [ + makeApproval({ status: "pending" }), + makeApproval({ status: "pending" }), + makeApproval({ status: "approved" }), + ]; + expect(countPendingGates(approvals)).toBe(2); + }); + + it("counts approvals with status 'revision_requested' as pending", () => { + const approvals = [ + makeApproval({ status: "revision_requested" }), + makeApproval({ status: "pending" }), + makeApproval({ status: "approved" }), + makeApproval({ status: "rejected" }), + ]; + expect(countPendingGates(approvals)).toBe(2); + }); + + it("does not count approved or rejected approvals", () => { + const approvals = [ + makeApproval({ status: "approved" }), + makeApproval({ status: "rejected" }), + ]; + expect(countPendingGates(approvals)).toBe(0); + }); + + it("derives 'hasPendingGates' correctly — count > 0 → true, count === 0 → false", () => { + expect(countPendingGates([makeApproval({ status: "pending" })]) > 0).toBe(true); + expect(countPendingGates([makeApproval({ status: "approved" })]) > 0).toBe(false); + }); +}); diff --git a/ui/src/hooks/useGateIndicator.ts b/ui/src/hooks/useGateIndicator.ts new file mode 100644 index 00000000..f67b9dfb --- /dev/null +++ b/ui/src/hooks/useGateIndicator.ts @@ -0,0 +1,62 @@ +// [nexus] Phase 11 — pending-gate indicator hook. +// +// Reads the existing approvals API and exposes a minimal "are there any +// gates waiting on the user globally?" summary, intended for the IconRail +// Assistant destination dot overlay. The rename from "approvals" to +// "gates" is display-only — we reuse `approvalsApi` / `queryKeys.approvals` +// without touching the backend. +// +// The IconRail wiring itself lives in a Phase 8 file the controller owns; +// Phase 11 only ships the hook + tests. See the Phase 11 report for the +// exact diff the controller should apply post-Wave. +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { Approval } from "@paperclipai/shared"; +import { approvalsApi } from "../api/approvals"; +import { queryKeys } from "../lib/queryKeys"; + +const PENDING_STATUSES: ReadonlySet = new Set([ + "pending", + "revision_requested", +]); + +export interface GateIndicator { + hasPendingGates: boolean; + count: number; + loading: boolean; + error: Error | null; +} + +/** Count how many approvals in the list are awaiting user decision. */ +export function countPendingGates(approvals: readonly Approval[] | undefined): number { + if (!approvals || approvals.length === 0) return 0; + let count = 0; + for (const approval of approvals) { + if (PENDING_STATUSES.has(approval.status)) count += 1; + } + return count; +} + +/** + * Returns `{ hasPendingGates, count, loading, error }` for the given company. + * When `companyId` is null/undefined the query is disabled and the hook + * returns zeroed values — useful while bootstrapping IconRail before a + * company is selected. + */ +export function useGateIndicator(companyId: string | null | undefined): GateIndicator { + const query = useQuery({ + queryKey: queryKeys.approvals.list(companyId ?? "__none__"), + queryFn: () => approvalsApi.list(companyId!), + enabled: Boolean(companyId), + }); + + return useMemo(() => { + const count = countPendingGates(query.data); + return { + hasPendingGates: count > 0, + count, + loading: query.isLoading, + error: (query.error as Error | null) ?? null, + }; + }, [query.data, query.isLoading, query.error]); +}