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 | */ | |
| 6 | import { useEffect, useState } from "react"; | |
| 7 | import { Link } from "react-router"; | |
| 8 | ||
| 9 | import { type DirectoryPerson, type TeamKind, teamKind } from "@g1t/contracts"; | |
| 10 | ||
| 11 | import { AgentFace } from "./agents-mode"; | |
| 12 | import { StatusDot } from "./chat/marks"; | |
| 13 | import { WithPresence } from "./presence"; | |
| 14 | import { Avatar } from "./ui"; | |
| 15 | import { Badge } from "./ui/badge"; | |
| 16 | import { Hint } from "./ui/hint"; | |
| 17 | import { cn } from "../lib/cn"; | |
| 18 | import { type PeopleAgent, agentPath, personName, personPath } from "../lib/people"; | |
| 19 | import { localTime } from "../lib/time-zone"; | |
| 20 | ||
| 21 | /** A person's avatar, with their presence in its corner. */ | |
| 22 | export 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. */ | |
| 31 | export 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 | */ | |
| 46 | export 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 | ||
| 63 | const 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 | ||
| 70 | const 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. */ | |
| 78 | export 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. */ | |
| 90 | export 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. */ | |
| 95 | export 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. */ | |
| 104 | export 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. */ | |
| 113 | export function AgentTag() { | |
| 114 | return <Badge tone="accent">Agent</Badge>; | |
| 115 | } | |
| 116 | ||
| 117 | /** What the agent is told, as it reads: headings, lines and lists. */ | |
| 118 | export 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 | } |