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