Skip to content
143 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.1/**
2 * Pieces of People's pages: a person's face with their presence, an
3 * agent's with its status, someone's local time, what a team is made of,
4 * and a label for what is coming.
5 */
6import { useEffect, useState } from "react";
7import { Link } from "react-router";
8
9import { type DirectoryPerson, type TeamKind, teamKind } from "@g1t/contracts";
10
11import { AgentFace } from "./agents-mode";
12import { StatusDot } from "./chat/marks";
13import { WithPresence } from "./presence";
14import { Avatar } from "./ui";
15import { Badge } from "./ui/badge";
16import { Hint } from "./ui/hint";
17import { cn } from "../lib/cn";
18import { type PeopleAgent, agentPath, personName, personPath } from "../lib/people";
19import { localTime } from "../lib/time-zone";
20
21/** A person's avatar, with their presence in its corner. */
22export function PersonFace({ person, size = 28, ring }: { person: Pick<DirectoryPerson, "user_id" | "username" | "avatar">; size?: number; ring?: string }) {
23 return (
24 <WithPresence person={{ id: person.user_id, username: person.username }} size={size} ring={ring}>
25 <Avatar name={person.username} image={person.avatar} size={size} />
26 </WithPresence>
27 );
28}
29
30/** An agent's face, with its status in its corner. */
31export function AgentPersonFace({ agent, size = 28, ring = "var(--color-bg)" }: { agent: PeopleAgent; size?: number; ring?: string }) {
32 return (
33 <span className="relative inline-flex shrink-0">
34 <AgentFace agent={agent} size={size} />
35 <span className="absolute -right-0.5 -bottom-0.5 inline-flex rounded-full" style={{ boxShadow: `0 0 0 2px ${ring}` }}>
36 <StatusDot status={agent.status} className="block" />
37 </span>
38 </span>
39 );
40}
41
42/**
43 * Someone's local time, worked out in the browser (the server's clock is
44 * not theirs), and kept current. Nothing when they gave no time zone.
45 */
46export function LocalTime({ zone, className, suffix = "local" }: { zone: string | null; className?: string; suffix?: string }) {
47 const [now, setNow] = useState<number | null>(null);
48 useEffect(() => {
49 setNow(Date.now());
50 const timer = setInterval(() => setNow(Date.now()), 30_000);
51 return () => clearInterval(timer);
52 }, []);
53 if (!zone || now == null) return null;
54 const time = localTime(zone, now);
55 if (!time) return null;
56 return (
57 <span className={className}>
58 {time} {suffix}
59 </span>
60 );
61}
62
63const KIND_MARK: Record<TeamKind, string> = {
64 mixed: "bg-[linear-gradient(135deg,var(--color-success)_50%,var(--color-accent)_50%)]",
65 people: "bg-success",
66 agents: "bg-accent",
67 empty: "bg-line-strong",
68};
69
70const KIND_WORDS: Record<TeamKind, string> = {
71 mixed: "People and agents",
72 people: "People only",
73 agents: "Agents only",
74 empty: "No one yet",
75};
76
77/** A small square that says what a team is made of: people, agents, or both. */
78export function TeamKindMark({ people, agents, className }: { people: number; agents: number; className?: string }) {
79 const kind = teamKind(people, agents);
80 return (
81 <Hint label={KIND_WORDS[kind]}>
82 <span tabIndex={0} className={cn("inline-block size-2.5 shrink-0 rounded-[3px]", KIND_MARK[kind], className)}>
83 <span className="sr-only">{KIND_WORDS[kind]}</span>
84 </span>
85 </Hint>
86 );
87}
88
89/** Something that is planned and not here yet. */
90export function Coming({ children = "Coming" }: { children?: string }) {
91 return <Badge className="border-dashed">{children}</Badge>;
92}
93
94/** A person's name, linked to their profile. */
95export function PersonLink({ workspace, person, className }: { workspace: string; person: Pick<DirectoryPerson, "username" | "name" | "display_username">; className?: string }) {
96 return (
97 <Link to={personPath(workspace, person.username)} prefetch="intent" className={cn("hover:text-accent", className)}>
98 {personName(person)}
99 </Link>
100 );
101}
102
103/** An agent's name, linked to its People profile. */
104export function AgentLink({ workspace, agent, className }: { workspace: string; agent: Pick<PeopleAgent, "handle" | "display_name">; className?: string }) {
105 return (
106 <Link to={agentPath(workspace, agent.handle)} prefetch="intent" className={cn("hover:text-accent", className)}>
107 {agent.display_name}
108 </Link>
109 );
110}
111
112/** The small "agent" label after an agent's name. */
113export function AgentTag() {
114 return <Badge tone="accent">Agent</Badge>;
115}
116
117/** What the agent is told, as it reads: headings, lines and lists. */
118export function ToldText({ text }: { text: string }) {
119 const lines = text.split("\n");
120 return (
121 <div className="space-y-1.5 text-sm text-fg-soft">
122 {lines.map((line, index) => {
123 if (line.startsWith("## ")) return null;
124 if (line.startsWith("### ")) {
125 return (
126 <h3 key={index} className="pt-2 font-medium text-fg first:pt-0">
127 {line.slice(4)}
128 </h3>
129 );
130 }
131 if (line.startsWith("- ")) {
132 return (
133 <p key={index} className="relative pl-4 before:absolute before:left-1 before:text-faint before:content-['•']">
134 {line.slice(2)}
135 </p>
136 );
137 }
138 if (!line.trim()) return null;
139 return <p key={index}>{line}</p>;
140 })}
141 </div>
142 );
143}