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