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