Skip to content
156 linesCodeBlameRaw
1/**
2 * Pieces of the teams pages: a team in a list, and its badges.
3 */
4import { EyeOff, UsersRound } from "lucide-react";
5import { Form, Link, useOutletContext } from "react-router";
6
7import { type Team, teamHandle } from "@g1t/contracts";
8
9import type { AgentTeams, PeopleAgent, TeamChange } from "../lib/people";
10import { teamCounts, teamPath } from "../lib/teams";
11import { ErrorText, SubmitButton } from "./ui";
12import { Badge } from "./ui/badge";
13import { SelectField } from "./ui/select";
14import { TeamKindMark } from "./people";
15
16/**
17 * What each page of a team gets from the team's layout: the team and the
18 * agents on it, members as its people are.
19 */
20export type TeamContext = { team: Team; agents: PeopleAgent[] };
21
22/** The team whose page this is. */
23export function useTeam(): Team {
24 return useOutletContext<TeamContext>().team;
25}
26
27/** The agents on the team whose page this is. */
28export function useTeamAgents(): { agents: PeopleAgent[] } {
29 const { agents } = useOutletContext<TeamContext>();
30 return { agents };
31}
32
33/** Secret, and the viewer's place in the team. */
34export function TeamBadges({ team }: { team: Pick<Team, "visibility" | "viewer_role"> }) {
35 return (
36 <>
37 {team.visibility === "secret" && (
38 <Badge>
39 <EyeOff size={10} />
40 Secret
41 </Badge>
42 )}
43 {team.viewer_role === "maintainer" && <Badge tone="accent">Maintainer</Badge>}
44 {team.viewer_role === "member" && <Badge>Member</Badge>}
45 </>
46 );
47}
48
49/** One team in a list: its name, handle, what it is for, and how big it is. */
50export function TeamRow({ team, agents, children }: { team: Team; agents?: number; children?: React.ReactNode }) {
51 const agentsHere = agents ?? team.agents_count ?? 0;
52 return (
53 <li className="flex flex-wrap items-start gap-3 px-4 py-3 transition-colors hover:bg-surface/60 sm:flex-nowrap">
54 <span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
55 <UsersRound size={14} />
56 </span>
57 <div className="min-w-0 grow basis-48">
58 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
59 <TeamKindMark people={team.members_count} agents={agentsHere} />
60 <Link to={teamPath(team.workspace, team.slug)} prefetch="intent" className="font-medium hover:underline">
61 {team.name}
62 </Link>
63 <span className="font-mono text-xs text-faint">{teamHandle(team)}</span>
64 <TeamBadges team={team} />
65 </div>
66 {team.description && <p className="mt-0.5 line-clamp-2 text-sm text-muted">{team.description}</p>}
67 <p className="mt-1 text-xs text-faint">
68 {teamCounts(team, agentsHere)}
69 {team.parent && (
70 <>
71 {" · in "}
72 <Link to={teamPath(team.workspace, team.parent.slug)} className="hover:text-fg hover:underline">
73 {team.parent.name}
74 </Link>
75 </>
76 )}
77 </p>
78 </div>
79 {children}
80 </li>
81 );
82}
83
84/**
85 * An agent's teams on its profile, as a person's are: each team it is on,
86 * with Remove where the viewer manages the team, and Add to a team. Posts
87 * `join-team` and `leave-team` to the page's action (lib/agent-teams.server.ts).
88 */
89export function AgentTeamsEditor({
90 slug,
91 name,
92 teams,
93 personal = false,
94 change,
95}: {
96 slug: string;
97 /** The agent's name, for the empty line. */
98 name: string;
99 /** Null when its teams couldn't be read. */
100 teams: AgentTeams | null;
101 /** A personal agent is on no team. */
102 personal?: boolean;
103 /** The last change's answer, for its error. */
104 change?: TeamChange | null;
105}) {
106 if (personal) return <p className="text-sm text-faint">A personal agent is on no team. Once an owner promotes it, add it to teams like anyone.</p>;
107 if (!teams) return <p className="text-sm text-faint">Its teams couldn&apos;t be read just now.</p>;
108 const error = (team: string) => (change?.team === team && change.intent === "leave-team" ? change.error : null);
109 return (
110 <div className="space-y-3">
111 {teams.on.length ? (
112 <ul className="space-y-1 text-sm">
113 {teams.on.map((team) => (
114 <li key={team.slug}>
115 <div className="flex min-h-9 flex-wrap items-center gap-2">
116 <TeamKindMark people={team.people} agents={team.agents} />
117 <Link to={teamPath(slug, team.slug)} className="min-w-0 truncate font-medium hover:text-accent">
118 {team.name}
119 </Link>
120 {team.lead && <Badge tone="accent">Lead</Badge>}
121 {team.can_manage && (
122 <Form method="post" className="ml-auto">
123 <input type="hidden" name="intent" value="leave-team" />
124 <input type="hidden" name="team" value={team.slug} />
125 <SubmitButton variant="outline" match={{ intent: "leave-team", team: team.slug }} pending="Removing…">
126 Remove
127 </SubmitButton>
128 </Form>
129 )}
130 </div>
131 <ErrorText>{error(team.slug)}</ErrorText>
132 </li>
133 ))}
134 </ul>
135 ) : (
136 <p className="text-sm text-faint">{name} isn&apos;t on a team yet.</p>
137 )}
138 {teams.addable.length > 0 && (
139 <Form method="post" key={`teams:${teams.on.length}`} className="flex flex-col gap-2 sm:flex-row sm:items-center">
140 <input type="hidden" name="intent" value="join-team" />
141 <SelectField
142 name="team"
143 aria-label="Add to a team"
144 defaultValue={teams.addable[0]!.slug}
145 className="w-full sm:w-56"
146 options={teams.addable.map((team) => ({ value: team.slug, label: team.name }))}
147 />
148 <SubmitButton variant="outline" match={{ intent: "join-team" }} pending="Adding…">
149 Add to team
150 </SubmitButton>
151 </Form>
152 )}
153 {change?.intent === "join-team" && <ErrorText>{change.error}</ErrorText>}
154 </div>
155 );
156}