feat(nexus): add useGateIndicator hook (phase 11)
Introduces a minimal pending-gate indicator hook for Phase 11. Reads
the existing approvals endpoint and exposes {hasPendingGates, count,
loading, error} for the IconRail Assistant dot overlay. The rename
from "Approvals" to "Gates" is display-only — approvalsApi and the
underlying /api/approvals endpoint are untouched.
Ships the hook + tests only; the IconRail wiring is deferred to the
controller post-Wave 2 (IconRail is a Phase 8 file).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d2dcb1c813
commit
6010a105fe
2 changed files with 125 additions and 0 deletions
63
ui/src/hooks/useGateIndicator.test.ts
Normal file
63
ui/src/hooks/useGateIndicator.test.ts
Normal file
|
|
@ -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> = {}): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
62
ui/src/hooks/useGateIndicator.ts
Normal file
62
ui/src/hooks/useGateIndicator.ts
Normal file
|
|
@ -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<Approval["status"]> = 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<GateIndicator>(() => {
|
||||||
|
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]);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue