g1t/apps/web/app/components/ui/index.tsx

266 lines7,557 bytesCodeBlame

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.

Agents as a team: lifecycle, merge queue, billing and a new shell1import { Check, Copy, Sparkles } from "lucide-react";
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)2import { type ComponentProps, type ReactNode, useState } from "react";
Agents as a team: lifecycle, merge queue, billing and a new shell3import { Link, type LinkProps, NavLink, useLocation } from "react-router";
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)4
5export function Field({
6 label,
7 hint,
8 children,
9}: {
10 label: string;
11 hint?: string;
12 children: ReactNode;
13}) {
14 return (
15 <label className="block">
16 <span className="mb-1.5 block text-sm font-medium text-muted">
17 {label}
18 </span>
19 {children}
20 {hint && <span className="mt-1.5 block text-xs text-faint">{hint}</span>}
21 </label>
22 );
23}
24
25const CONTROL =
26 "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim";
27
Agents as a team: lifecycle, merge queue, billing and a new shell28/**
29 * Attributes for a field that is not part of signing in. Password managers
30 * guess from names such as "name", "title" and "username" and offer to
31 * fill a login into them; these tell 1Password, LastPass, Bitwarden and
32 * Dashlane to leave the field alone. A field that states what it takes with
33 * `autoComplete` is left to them.
34 */
35export function notACredential(autoComplete?: string) {
36 if (autoComplete) return {};
37 return {
38 autoComplete: "off",
39 "data-1p-ignore": true,
40 "data-lpignore": "true",
41 "data-bwignore": true,
42 "data-form-type": "other",
43 };
44}
45
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)46export function Input(props: ComponentProps<"input">) {
Agents as a team: lifecycle, merge queue, billing and a new shell47 return <input {...notACredential(props.autoComplete)} {...props} className={CONTROL} />;
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)48}
49
50export function Textarea(props: ComponentProps<"textarea">) {
Agents as a team: lifecycle, merge queue, billing and a new shell51 return (
52 <textarea
53 {...notACredential(props.autoComplete)}
54 {...props}
55 className={`${CONTROL} font-mono`}
56 />
57 );
58}
59
60/** One tab in the row under a repository's or a workspace's header. */
61export function TabLink({
62 to,
63 also,
64 end,
65 icon,
66 count,
67 children,
68}: {
69 to: string;
70 /** Another path prefix under which this tab is the current one. */
71 also?: string;
72 end?: boolean;
73 icon: ReactNode;
74 count?: number;
75 children: ReactNode;
76}) {
77 const { pathname } = useLocation();
78 return (
79 <NavLink
80 to={to}
81 end={end}
82 className={({ isActive }) =>
83 `-mb-px flex items-center gap-2 border-b-2 px-1 pb-3 text-sm whitespace-nowrap transition-colors ${
84 isActive || (also && pathname.startsWith(also + "/"))
85 ? "border-accent font-medium text-fg"
86 : "border-transparent text-muted hover:text-fg"
87 }`
88 }
89 >
90 {icon}
91 {children}
92 {count != null && count > 0 && (
93 <span className="rounded-full bg-raised px-1.5 py-px text-xs text-muted">
94 {count}
95 </span>
96 )}
97 </NavLink>
98 );
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)99}
100
101type Variant = "primary" | "accent" | "quiet";
102
103const BUTTON_BASE =
104 "inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2 text-sm font-medium transition-colors disabled:opacity-50";
105
106const BUTTON_VARIANTS: Record<Variant, string> = {
107 primary: "bg-fg text-bg hover:bg-white",
108 accent: "bg-accent text-bg hover:bg-[#c6ff5c]",
109 quiet:
110 "border border-line text-fg/80 hover:border-line-strong hover:bg-surface hover:text-fg",
111};
112
113export function Button({
114 variant = "primary",
115 ...props
116}: ComponentProps<"button"> & { variant?: Variant }) {
117 return (
118 <button {...props} className={`${BUTTON_BASE} ${BUTTON_VARIANTS[variant]}`} />
119 );
120}
121
122/** A link that looks like a button. */
123export function ButtonLink({
124 variant = "primary",
125 large,
126 ...props
127}: LinkProps & { variant?: Variant; large?: boolean }) {
128 return (
129 <Link
130 {...props}
131 className={`${BUTTON_BASE} ${BUTTON_VARIANTS[variant]} ${
132 large ? "rounded-full px-5 py-2.5" : ""
133 }`}
134 />
135 );
136}
137
138export function ErrorText({ children }: { children: ReactNode }) {
139 return children ? <p className="text-sm text-danger">{children}</p> : null;
140}
141
142/** A small outlined label. */
143export function Pill({ children }: { children: ReactNode }) {
144 return (
145 <span className="inline-flex items-center rounded-full border border-line px-2 py-0.5 text-xs text-muted">
146 {children}
147 </span>
148 );
149}
150
151const AVATAR_HUES = [82, 200, 262, 28, 330, 160];
152
Agents as a team: lifecycle, merge queue, billing and a new shell153/** How g1t's agents are named: as reviewers and assignees, and as commit authors. */
154const AGENT_NAMES = new Set(["g1t-agent", "g1t agent", "g1t"]);
155
156/**
157 * A letter avatar whose colour is stable for a given name. People are
158 * round; a workspace is `square`.
159 */
160export function Avatar({
161 name,
162 size = 20,
163 square,
164}: {
165 name: string;
166 size?: number;
167 square?: boolean;
168}) {
169 // g1t's own agents wear the agent colour and mark everywhere they appear.
170 if (AGENT_NAMES.has(name)) {
171 return (
172 <span
173 aria-hidden="true"
174 className="inline-flex shrink-0 items-center justify-center bg-merged/20 text-merged ring-1 ring-merged/40 ring-inset"
175 style={{ width: size, height: size, borderRadius: size * 0.3 }}
176 >
177 <Sparkles size={Math.round(size * 0.58)} />
178 </span>
179 );
180 }
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)181 let hash = 0;
182 for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
183 const hue = AVATAR_HUES[Math.abs(hash) % AVATAR_HUES.length];
184 return (
185 <span
186 aria-hidden="true"
Agents as a team: lifecycle, merge queue, billing and a new shell187 className="inline-flex shrink-0 items-center justify-center font-mono font-semibold uppercase"
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)188 style={{
189 width: size,
190 height: size,
Agents as a team: lifecycle, merge queue, billing and a new shell191 borderRadius: square ? size * 0.24 : size,
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)192 fontSize: size * 0.5,
193 background: `oklch(0.4 0.09 ${hue})`,
194 color: `oklch(0.93 0.08 ${hue})`,
195 }}
196 >
197 {name[0]}
198 </span>
199 );
200}
201
202/** A line of text (usually a command) with a copy button. */
203export function CopyLine({
204 text,
205 prompt,
206}: {
207 text: string;
208 prompt?: boolean;
209}) {
210 const [copied, setCopied] = useState(false);
211 return (
212 <div className="group flex items-center gap-3 rounded-lg border border-line bg-surface py-2 pr-2 pl-3.5 font-mono text-[0.8125rem]">
213 <code className="min-w-0 grow overflow-x-auto whitespace-nowrap">
214 {prompt && <span className="mr-2 text-faint select-none">$</span>}
215 {text}
216 </code>
217 <button
218 type="button"
219 aria-label="Copy"
220 className="shrink-0 rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-fg"
221 onClick={() => {
222 void navigator.clipboard.writeText(text);
223 setCopied(true);
224 setTimeout(() => setCopied(false), 1500);
225 }}
226 >
227 {copied ? <Check size={14} className="text-accent" /> : <Copy size={14} />}
228 </button>
229 </div>
230 );
231}
232
233const UNITS: [string, number][] = [
234 ["d", 86_400_000],
235 ["h", 3_600_000],
236 ["m", 60_000],
237];
238
239/** Compact relative time such as "5m ago". */
RFC 3339 timestamps in identity and repos240export function TimeAgo({ at }: { at: string | number }) {
241 // Timestamps are RFC 3339 strings; a few older ones are still numbers.
242 const elapsed = Date.now() - new Date(at).getTime();
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)243 const unit = UNITS.find(([, size]) => elapsed >= size);
244 const label = unit ? `${Math.floor(elapsed / unit[1])}${unit[0]} ago` : "just now";
245 return (
246 // The server and the browser render at slightly different times.
247 <time dateTime={new Date(at).toISOString()} suppressHydrationWarning>
248 {label}
249 </time>
250 );
251}
252
253export function EmptyState({
254 title,
255 children,
256}: {
257 title: string;
258 children?: ReactNode;
259}) {
260 return (
261 <div className="rounded-lg border border-dashed border-line px-6 py-12 text-center">
262 <p className="font-medium">{title}</p>
263 {children && <div className="mt-1.5 text-sm text-muted">{children}</div>}
264 </div>
265 );
266}