Skip to content
143 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";
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}