diff --git a/apps/web/features/teams/teams-view.test.ts b/apps/web/features/teams/teams-view.test.ts new file mode 100644 index 0000000..e97c526 --- /dev/null +++ b/apps/web/features/teams/teams-view.test.ts @@ -0,0 +1,46 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +async function source() { + return readFile(new URL("./teams-view.tsx", import.meta.url), "utf8"); +} + +describe("Teams directory", () => { + it("only groups by team when the directory actually records one", async () => { + const view = await source(); + expect(view).toContain('groupedBy: "team"'); + expect(view).toContain('groupedBy: "actorType"'); + expect(view).toContain("entries.some((entry) => entry.team?.trim())"); + // The old view bucketed every teamless actor under "Unassigned", which on + // real data was the entire organisation. + expect(view).not.toContain('"Unassigned"'); + }); + + it("falls back to the actor split, which is real server data", async () => { + const view = await source(); + expect(view).toContain("ACTOR_TYPE_LABELS"); + expect(view).toContain('human: "People"'); + expect(view).toContain('agent: "Pack agents"'); + expect(view).toContain('system: "System actors"'); + expect(view).toContain("entry.actorType === actorType"); + // Empty actor types are dropped rather than shown as zero-member groups. + expect(view).toContain("filter((group) => group.members.length > 0)"); + }); + + it("describes what it renders instead of promising team structure", async () => { + const view = await source(); + expect(view).toContain("by actor type where it does not"); + expect(view).toContain("No directory entry carries a team"); + expect(view).toContain("nothing here invents one"); + expect(view).toContain("No directory members visible"); + }); + + it("reads the governed directory and counts actor types exactly", async () => { + const view = await source(); + expect(view).toContain("useDirectory"); + expect(view).not.toContain("FIXTURE_TEAMS"); + // System actors must never be reported as humans by subtraction. + expect(view).not.toContain("total - agents"); + expect(view).toContain('entry.actorType === "human"'); + }); +}); diff --git a/apps/web/features/teams/teams-view.tsx b/apps/web/features/teams/teams-view.tsx index 8d5b795..3ceec0d 100644 --- a/apps/web/features/teams/teams-view.tsx +++ b/apps/web/features/teams/teams-view.tsx @@ -11,34 +11,70 @@ import { Badge } from "@/components/ui/badge"; import { useDirectory, type DirectoryEntry } from "@/lib/queries/hooks"; import { relativeTime } from "@/lib/utils"; -const UNASSIGNED = "Unassigned"; +const UNASSIGNED = "No team recorded"; + +const ACTOR_TYPE_ORDER = ["human", "agent", "system"] as const; + +const ACTOR_TYPE_LABELS: Record = { + human: "People", + agent: "Pack agents", + system: "System actors", +}; + +type DirectoryGroup = { key: string; label: string; members: DirectoryEntry[] }; + +const byDisplayName = (left: DirectoryEntry, right: DirectoryEntry) => + left.displayName.localeCompare(right.displayName); /** - * Real organisation directory only. Actors without a team land in one - * "Unassigned" group — never a synthetic SOC/IR roster. + * Real organisation directory only — never a synthetic SOC/IR roster. + * + * `team` is a nullable column no product surface writes yet, so grouping by it + * normally collapses the whole organisation into one meaningless bucket. Group + * by team only once the directory actually returns one; otherwise fall back to + * actor type, which is always populated and is the distinction that matters + * today. */ -function groupByTeam(entries: DirectoryEntry[]) { - const groups = new Map(); - for (const entry of entries) { - const team = entry.team?.trim() || UNASSIGNED; - const bucket = groups.get(team); - if (bucket) bucket.push(entry); - else groups.set(team, [entry]); +function groupDirectory(entries: DirectoryEntry[]): { + groupedBy: "team" | "actorType"; + groups: DirectoryGroup[]; +} { + if (entries.some((entry) => entry.team?.trim())) { + const teams = new Map(); + for (const entry of entries) { + const team = entry.team?.trim() || UNASSIGNED; + const bucket = teams.get(team); + if (bucket) bucket.push(entry); + else teams.set(team, [entry]); + } + return { + groupedBy: "team", + groups: [...teams.entries()] + .sort(([left], [right]) => + left === UNASSIGNED + ? 1 + : right === UNASSIGNED + ? -1 + : left.localeCompare(right), + ) + .map(([team, members]) => ({ + key: team, + label: team, + members: members.sort(byDisplayName), + })), + }; } - return [...groups.entries()] - .sort(([left], [right]) => - left === UNASSIGNED - ? 1 - : right === UNASSIGNED - ? -1 - : left.localeCompare(right), - ) - .map(([team, members]) => ({ - team, - members: members.sort((left, right) => - left.displayName.localeCompare(right.displayName), - ), - })); + + return { + groupedBy: "actorType", + groups: ACTOR_TYPE_ORDER.map((actorType) => ({ + key: actorType, + label: ACTOR_TYPE_LABELS[actorType], + members: entries + .filter((entry) => entry.actorType === actorType) + .sort(byDisplayName), + })).filter((group) => group.members.length > 0), + }; } function MemberRow({ member }: { member: DirectoryEntry }) { @@ -82,20 +118,27 @@ function MemberRow({ member }: { member: DirectoryEntry }) { export function TeamsView() { const [query, setQuery] = useState(""); const directory = useDirectory(query.trim()); - const groups = useMemo( - () => groupByTeam(directory.data ?? []), + const { groupedBy, groups } = useMemo( + () => groupDirectory(directory.data ?? []), [directory.data], ); const total = directory.data?.length ?? 0; - const agents = - directory.data?.filter((entry) => entry.actorType === "agent").length ?? 0; + // Counted per actor type rather than subtracted, so system actors are never + // reported as humans. + const counts = useMemo(() => { + const entries = directory.data ?? []; + return { + humans: entries.filter((entry) => entry.actorType === "human").length, + agents: entries.filter((entry) => entry.actorType === "agent").length, + }; + }, [directory.data]); return (
@@ -110,7 +153,7 @@ export function TeamsView() { className="h-8 min-w-56 flex-1 rounded-md border border-border bg-background px-2 text-sm" /> - {total} members · {agents} agents · {total - agents} humans + {total} members · {counts.humans} humans · {counts.agents} agents
@@ -134,11 +177,11 @@ export function TeamsView() { {groups.map((group) => (
-

{group.team}

+

{group.label}

{group.members.length}{" "} {group.members.length === 1 ? "member" : "members"} @@ -153,8 +196,11 @@ export function TeamsView() { ))}

- Team names come from the directory record. Capability grants stay - server-enforced — this view never assigns or revokes anything. + {groupedBy === "actorType" && total > 0 + ? "No directory entry carries a team, so this is grouped by actor type. Team names are read straight off the directory record; nothing here invents one." + : "Team names are read straight off the directory record; nothing here invents one."}{" "} + Capability grants stay server-enforced — this view never assigns or + revokes anything.