Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
182 linesCodeBlameRaw
1import { Network, Search, UserPlus } from "lucide-react";
2import { Link, data, useSearchParams, type ShouldRevalidateFunctionArgs } from "react-router";
3
4import type { Route } from "./+types/people";
5import { AgentPersonFace, AgentTag, LocalTime, PersonFace } from "../../components/people";
6import { ButtonLink, notACredential } from "../../components/ui";
7import { Badge } from "../../components/ui/badge";
8import { Button } from "../../components/ui/button";
9import { Card } from "../../components/ui/card";
10import { page } from "../../lib/meta";
11import { type DirectoryEntry, type DirectoryKind, agentPath, directoryEntries, directoryKind, peopleAgent, personName, personPath } from "../../lib/people";
12import { identity, workspaceAgents } from "../../lib/services.server";
13import { getViewer, roleIn, unwrap } from "../../lib/session.server";
14
15export function meta({ params, ...args }: Route.MetaArgs) {
16 return page(args, { title: `People · ${params.owner} · g1t` });
17}
18
19export async function loader({ params, context }: Route.LoaderArgs) {
20 const viewer = getViewer(context);
21 // Who is in a workspace is its members' business.
22 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
23 const [directory, agents] = await Promise.all([
24 identity.peopleDirectory(viewer, params.owner).then(unwrap),
25 workspaceAgents.list(params.owner, viewer!).catch(() => null),
26 ]);
27 return {
28 slug: params.owner.toLowerCase(),
29 directory,
30 agents: agents?.ok ? agents.value.map(peopleAgent) : [],
31 };
32}
33
34/** Searching changes only the address: everyone is already here. */
35export function shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
36 if (!formMethod && currentUrl.pathname === nextUrl.pathname) return false;
37 return defaultShouldRevalidate;
38}
39
40const KINDS: { value: DirectoryKind; label: string }[] = [
41 { value: "everyone", label: "Everyone" },
42 { value: "people", label: "People" },
43 { value: "agents", label: "Agents" },
44];
45
46export default function PeopleDirectoryPage({ loaderData }: Route.ComponentProps) {
47 const { slug, directory, agents } = loaderData;
48 const [params, setParams] = useSearchParams();
49 const query = params.get("q") ?? "";
50 const kind = directoryKind(params.get("kind"));
51 const entries = directoryEntries(directory, agents, query, kind);
52 const change = (key: string, value: string) =>
53 setParams(
54 (current) => {
55 const next = new URLSearchParams(current);
56 if (value) next.set(key, value);
57 else next.delete(key);
58 return next;
59 },
60 { replace: true, preventScrollReset: true },
61 );
62 const counts = { everyone: directory.people.length + agents.length, people: directory.people.length, agents: agents.length };
63
64 return (
65 <div className="space-y-6">
66 <div className="flex flex-col gap-3 md:flex-row md:items-center">
67 <label className="relative block grow">
68 <span className="sr-only">Search people and agents</span>
69 <Search size={15} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
70 <input
71 type="search"
72 value={query}
73 onChange={(event) => change("q", event.target.value)}
74 placeholder="Name, title, team or what they own"
75 {...notACredential()}
76 className="w-full rounded-lg border border-line bg-bg py-2 pr-3 pl-9 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
77 />
78 </label>
79 <div className="flex flex-wrap items-center gap-2">
80 <Card role="group" aria-label="Show" tone="bg" radius="lg" className="inline-flex p-0.5">
81 {KINDS.map((option) => (
82 <button
83 key={option.value}
84 type="button"
85 aria-pressed={kind === option.value}
86 onClick={() => change("kind", option.value === "everyone" ? "" : option.value)}
87 className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
88 kind === option.value ? "bg-raised text-fg" : "text-muted hover:text-fg"
89 }`}
90 >
91 {option.label} <span className="text-faint tabular-nums">{counts[option.value]}</span>
92 </button>
93 ))}
94 </Card>
95 <ButtonLink to={`/${slug}/-/org-chart`} variant="outline">
96 <Network size={15} />
97 Org chart
98 </ButtonLink>
99 {directory.can_manage && (
100 <ButtonLink to={`/${slug}/-/members`}>
101 <UserPlus size={15} />
102 Invite people
103 </ButtonLink>
104 )}
105 </div>
106 </div>
107
108 {entries.length === 0 ? (
109 <Card asChild tone="plain" className="border-dashed px-4 py-10 text-center text-sm text-muted">
110 <p>
111 {query ? (
112 <>
113 Nobody matches “{query}”. Try a team, like “sales”, or a job, like “refunds”.{" "}
114 <Button type="button" variant="link" size="inline" onClick={() => change("q", "")}>
115 Show everyone
116 </Button>
117 </>
118 ) : (
119 "No one here yet."
120 )}
121 </p>
122 </Card>
123 ) : (
124 <ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
125 {entries.map((entry) => (
126 <li key={entry.kind === "person" ? `p:${entry.person.username}` : `a:${entry.agent.id}`}>
127 <EntryCard slug={slug} entry={entry} />
128 </li>
129 ))}
130 </ul>
131 )}
132 </div>
133 );
134}
135
136/** One person or agent: who they are, their title, their teams and, for a person, their local time. */
137function EntryCard({ slug, entry }: { slug: string; entry: DirectoryEntry }) {
138 const teams = entry.teams.map((team) => team.name);
139 if (entry.kind === "person") {
140 const { person } = entry;
141 return (
142 <Link
143 to={personPath(slug, person.username)}
144 prefetch="intent"
145 className="flex h-full items-start gap-3 rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong"
146 >
147 <PersonFace person={person} size={44} ring="var(--color-surface)" />
148 <span className="min-w-0 grow">
149 <span className="flex items-center gap-1.5">
150 <span className="truncate font-medium">{personName(person)}</span>
151 {person.role === "owner" && <Badge>Owner</Badge>}
152 </span>
153 <span className="block truncate text-sm text-muted">{person.title ?? `@${person.display_username ?? person.username}`}</span>
154 <span className="mt-1.5 block truncate text-xs text-faint">
155 {teams.length ? teams.join(", ") : "No team"}
156 <LocalTime zone={person.timezone} className="before:mx-1.5 before:content-['·']" />
157 </span>
158 {person.owns.length > 0 && <span className="mt-1 block truncate text-xs text-fg-soft">Owns {person.owns.join(", ")}</span>}
159 </span>
160 </Link>
161 );
162 }
163 const { agent } = entry;
164 return (
165 <Link
166 to={agentPath(slug, agent.handle)}
167 prefetch="intent"
168 className="flex h-full items-start gap-3 rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong"
169 >
170 <AgentPersonFace agent={agent} size={44} ring="var(--color-surface)" />
171 <span className="min-w-0 grow">
172 <span className="flex items-center gap-1.5">
173 <span className="truncate font-medium">{agent.display_name}</span>
174 <AgentTag />
175 </span>
176 <span className="block truncate text-sm text-muted">{agent.title || agent.role}</span>
177 <span className="mt-1.5 block truncate text-xs text-faint">{teams.length ? teams.join(", ") : "No team"}</span>
178 {agent.responsibilities.length > 0 && <span className="mt-1 block truncate text-xs text-fg-soft">{agent.responsibilities.slice(0, 2).join(" · ")}</span>}
179 </span>
180 </Link>
181 );
182}