From e165058ca21f1602c21485ec7cc6ef2d00681960 Mon Sep 17 00:00:00 2001 From: Justin Middler Date: Thu, 30 Jul 2026 14:29:21 +1000 Subject: [PATCH] fix(web): stop showing a fake team structure on Teams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The directory's `team` value lives on the `users` table and no product surface writes it, so agents can never carry a team and humans only do if a row was edited by hand. In practice every entry returns null, which made the view file the entire organisation under one "Unassigned" heading — a grouping that promised structure the system does not have. Group by team only when at least one entry actually carries one; otherwise fall back to actor type (People / Pack agents / System actors), which is always populated by the server and is the distinction that matters today. Header and footer copy now describe what is on screen, and the summary counts humans and agents per actor type instead of subtracting, so system actors are never reported as humans. No roster is invented and no team-management surface is added — the view still renders exactly what the governed directory returns. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/features/teams/teams-view.test.ts | 46 +++++++++ apps/web/features/teams/teams-view.tsx | 114 +++++++++++++++------ 2 files changed, 126 insertions(+), 34 deletions(-) create mode 100644 apps/web/features/teams/teams-view.test.ts 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.