Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions apps/web/features/teams/teams-view.test.ts
Original file line number Diff line number Diff line change
@@ -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"');
});
});
114 changes: 80 additions & 34 deletions apps/web/features/teams/teams-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<DirectoryEntry["actorType"], string> = {
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<string, DirectoryEntry[]>();
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<string, DirectoryEntry[]>();
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 }) {
Expand Down Expand Up @@ -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 (
<CompanyOsShell>
<PageHeader
eyebrow="Workforce"
title="Teams"
description="Organisation-scoped humans and pack agents from the governed directory. No demo roster is seeded."
description="The governed directory of organisation-scoped humans and pack agents. Grouped by team where the directory records one, by actor type where it does not. No demo roster is seeded."
/>
<PageBody>
<div className="flex flex-wrap items-center gap-2">
Expand All @@ -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"
/>
<span className="text-xs text-muted-foreground">
{total} members · {agents} agents · {total - agents} humans
{total} members · {counts.humans} humans · {counts.agents} agents
</span>
</div>

Expand All @@ -134,11 +177,11 @@ export function TeamsView() {

{groups.map((group) => (
<section
key={group.team}
key={group.key}
className="rounded-md border border-border bg-card"
>
<header className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<h2 className="text-sm font-semibold">{group.team}</h2>
<h2 className="text-sm font-semibold">{group.label}</h2>
<span className="text-xs text-muted-foreground">
{group.members.length}{" "}
{group.members.length === 1 ? "member" : "members"}
Expand All @@ -153,8 +196,11 @@ export function TeamsView() {
))}

<p className="text-xs text-muted-foreground">
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.
</p>
</PageBody>
</CompanyOsShell>
Expand Down
Loading