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.
| Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016) | 1 | import { Check, Copy, LoaderCircle, User } from "lucide-react"; |
| Merge demo dry run fixes: settled checks, other attempts, queue links, clone box, activity feed, DEMO.md | 2 | import { type ComponentProps, Fragment, type ReactNode, useState } from "react"; |
| Merge g1tusercontent.com: registry answers run nothing in a browser, the site's pages run only their own scripts, repository files and avatars on their own origin, raw files rate limited per address | 3 | import { Link, type LinkProps, NavLink, useLocation, useNavigation, useRouteLoaderData } from "react-router"; |
| Automations: rules in .g1t/automations that act when something happens | 4 | |
| Merge g1tusercontent.com: registry answers run nothing in a browser, the site's pages run only their own scripts, repository files and avatars on their own origin, raw files rate limited per address | 5 | import { usercontentFrom } from "../../lib/addresses"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 6 | import { isWaitingMessage, linkPaths } from "../../lib/compute"; |
| Merge branch 'worktree-agent-a985c01f43538af01' | 7 | import { type Submission, isPending } from "../../lib/pending"; |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 8 | import { Mark } from "../logo"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 9 | |
| Automations: rules in .g1t/automations that act when something happens | 10 | export function Field({ |
| 11 | label, | |
| 12 | hint, | |
| 13 | children, | |
| 14 | }: { | |
| 15 | label: string; | |
| 16 | hint?: string; | |
| 17 | children: ReactNode; | |
| 18 | }) { | |
| 19 | return ( | |
| 20 | <label className="block"> | |
| 21 | <span className="mb-1.5 block text-sm font-medium text-muted"> | |
| 22 | {label} | |
| 23 | </span> | |
| 24 | {children} | |
| 25 | {hint && <span className="mt-1.5 block text-xs text-faint">{hint}</span>} | |
| 26 | </label> | |
| 27 | ); | |
| 28 | } | |
| 29 | ||
| 30 | const CONTROL = | |
| 31 | "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"; | |
| 32 | ||
| 33 | /** | |
| 34 | * Attributes for a field that is not part of signing in. Password managers | |
| 35 | * guess from names such as "name", "title" and "username" and offer to | |
| 36 | * fill a login into them; these tell 1Password, LastPass, Bitwarden and | |
| 37 | * Dashlane to leave the field alone. A field that states what it takes with | |
| 38 | * `autoComplete` is left to them. | |
| 39 | */ | |
| 40 | export function notACredential(autoComplete?: string) { | |
| 41 | if (autoComplete) return {}; | |
| 42 | return { | |
| 43 | autoComplete: "off", | |
| 44 | "data-1p-ignore": true, | |
| 45 | "data-lpignore": "true", | |
| 46 | "data-bwignore": true, | |
| 47 | "data-form-type": "other", | |
| 48 | }; | |
| 49 | } | |
| 50 | ||
| 51 | export function Input(props: ComponentProps<"input">) { | |
| 52 | return <input {...notACredential(props.autoComplete)} {...props} className={CONTROL} />; | |
| 53 | } | |
| 54 | ||
| 55 | export function Textarea(props: ComponentProps<"textarea">) { | |
| 56 | return ( | |
| 57 | <textarea | |
| 58 | {...notACredential(props.autoComplete)} | |
| 59 | {...props} | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 60 | className={`${CONTROL} font-mono placeholder:font-sans`} |
| Automations: rules in .g1t/automations that act when something happens | 61 | /> |
| 62 | ); | |
| 63 | } | |
| 64 | ||
| 65 | /** One tab in the row under a repository's or a workspace's header. */ | |
| 66 | export function TabLink({ | |
| 67 | to, | |
| 68 | also, | |
| 69 | end, | |
| 70 | icon, | |
| 71 | count, | |
| 72 | children, | |
| 73 | }: { | |
| 74 | to: string; | |
| 75 | /** Another path prefix under which this tab is the current one. */ | |
| 76 | also?: string; | |
| 77 | end?: boolean; | |
| 78 | icon: ReactNode; | |
| 79 | count?: number; | |
| 80 | children: ReactNode; | |
| 81 | }) { | |
| 82 | const { pathname } = useLocation(); | |
| 83 | return ( | |
| 84 | <NavLink | |
| 85 | to={to} | |
| 86 | end={end} | |
| 87 | className={({ isActive }) => | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 88 | `-mb-px flex items-center gap-2 border-b-2 px-3 pb-3 text-sm whitespace-nowrap transition-colors ${ |
| Automations: rules in .g1t/automations that act when something happens | 89 | isActive || (also && pathname.startsWith(also + "/")) |
| 90 | ? "border-accent font-medium text-fg" | |
| 91 | : "border-transparent text-muted hover:text-fg" | |
| 92 | }` | |
| 93 | } | |
| 94 | > | |
| 95 | {icon} | |
| 96 | {children} | |
| 97 | {count != null && count > 0 && ( | |
| 98 | <span className="rounded-full bg-raised px-1.5 py-px text-xs text-muted"> | |
| 99 | {count} | |
| 100 | </span> | |
| 101 | )} | |
| 102 | </NavLink> | |
| 103 | ); | |
| 104 | } | |
| 105 | ||
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 106 | /** Soon, beside something that is coming: the same small pill in the sidebar and in tabs. */ |
| 107 | export function SoonPill() { | |
| 108 | return ( | |
| 109 | <span className="shrink-0 rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line"> | |
| 110 | Soon | |
| 111 | </span> | |
| 112 | ); | |
| 113 | } | |
| 114 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 115 | type Variant = "primary" | "accent" | "quiet" | "danger"; |
| Automations: rules in .g1t/automations that act when something happens | 116 | |
| 117 | const BUTTON_BASE = | |
| 118 | "inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2 text-sm font-medium transition-colors disabled:opacity-50"; | |
| 119 | ||
| 120 | const BUTTON_VARIANTS: Record<Variant, string> = { | |
| You choose how g1t looks: Auto follows your system, Light and Dark stay put, set from the account menu, the phone's You and help sheet, Settings → Account or the command palette; the page is drawn in your choice from the first byte, every colour has a light value that reads at 4.5:1 or better, code and diffs are highlighted for both, diagrams and the document editor follow along, the product tour and the g1t mark's avatar stay dark, and Dark is what you see until you choose; the workspaces guide says how. | 121 | primary: "bg-fg text-bg hover:bg-fg-hover", |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 122 | accent: "bg-accent text-bg hover:bg-accent-hover", |
| Automations: rules in .g1t/automations that act when something happens | 123 | quiet: |
| 124 | "border border-line text-fg/80 hover:border-line-strong hover:bg-surface hover:text-fg", | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 125 | // For what cannot be undone: transferring, deleting. |
| 126 | danger: "border border-danger/40 text-danger hover:border-danger hover:bg-danger/10", | |
| Automations: rules in .g1t/automations that act when something happens | 127 | }; |
| 128 | ||
| 129 | export function Button({ | |
| 130 | variant = "primary", | |
| 131 | ...props | |
| 132 | }: ComponentProps<"button"> & { variant?: Variant }) { | |
| 133 | return ( | |
| 134 | <button {...props} className={`${BUTTON_BASE} ${BUTTON_VARIANTS[variant]}`} /> | |
| 135 | ); | |
| 136 | } | |
| 137 | ||
| Merge branch 'worktree-agent-a985c01f43538af01' | 138 | /** |
| 139 | * Whether the submission `fields` names is still working (lib/pending.ts): | |
| 140 | * the page's own navigation, or `fetcher`'s when the form is a fetcher's. | |
| 141 | */ | |
| 142 | export function usePending(fields?: Record<string, string | null | undefined>, fetcher?: Submission): boolean { | |
| 143 | const navigation = useNavigation(); | |
| 144 | return isPending(fetcher ?? navigation, fields); | |
| 145 | } | |
| 146 | ||
| 147 | /** | |
| 148 | * A form's submit button that says it is working: turned off, with a | |
| 149 | * spinner and `pending` ("Saving…") in place of its words, from the moment | |
| 150 | * it is pressed until the page has loaded what it changed. Its own | |
| 151 | * `name`/`value` say which submission is its; `match` names it otherwise, | |
| 152 | * such as the form's hidden `intent`. `fetcher` when the form is a | |
| 153 | * fetcher's. `className` replaces the button look, for icon buttons, and | |
| 154 | * `icon` says the spinner takes the place of everything inside it. Words | |
| 155 | * with a leading icon should pass `pending`, so the spinner replaces both. | |
| 156 | */ | |
| 157 | export function SubmitButton({ | |
| 158 | variant = "primary", | |
| 159 | pending, | |
| 160 | match, | |
| 161 | fetcher, | |
| 162 | busy, | |
| 163 | icon, | |
| 164 | disabled, | |
| 165 | className, | |
| 166 | children, | |
| 167 | ...props | |
| 168 | }: Omit<ComponentProps<"button">, "type"> & { | |
| 169 | variant?: Variant; | |
| 170 | /** The words while it works, such as "Saving…"; its own words when absent. */ | |
| 171 | pending?: ReactNode; | |
| 172 | match?: Record<string, string | null | undefined>; | |
| 173 | fetcher?: Submission; | |
| 174 | /** Working for a reason this button cannot see by itself. */ | |
| 175 | busy?: boolean; | |
| 176 | /** An icon button: while it works, the spinner is all it shows. */ | |
| 177 | icon?: boolean; | |
| 178 | }) { | |
| 179 | const own = | |
| 180 | typeof props.name === "string" && props.value != null ? { [props.name]: String(props.value) } : undefined; | |
| 181 | const working = usePending({ ...own, ...match }, fetcher) || Boolean(busy); | |
| 182 | return ( | |
| 183 | <button | |
| 184 | {...props} | |
| 185 | type="submit" | |
| 186 | disabled={disabled || working} | |
| 187 | aria-busy={working || undefined} | |
| 188 | className={className ?? `${BUTTON_BASE} ${BUTTON_VARIANTS[variant]}`} | |
| 189 | > | |
| 190 | {working ? ( | |
| 191 | <> | |
| 192 | <LoaderCircle size={14} aria-hidden="true" className="shrink-0 animate-spin" /> | |
| 193 | {icon ? null : (pending ?? children)} | |
| 194 | </> | |
| 195 | ) : ( | |
| 196 | children | |
| 197 | )} | |
| 198 | </button> | |
| 199 | ); | |
| 200 | } | |
| 201 | ||
| Automations: rules in .g1t/automations that act when something happens | 202 | /** A link that looks like a button. */ |
| 203 | export function ButtonLink({ | |
| 204 | variant = "primary", | |
| 205 | large, | |
| 206 | ...props | |
| 207 | }: LinkProps & { variant?: Variant; large?: boolean }) { | |
| 208 | return ( | |
| 209 | <Link | |
| 210 | {...props} | |
| 211 | className={`${BUTTON_BASE} ${BUTTON_VARIANTS[variant]} ${ | |
| 212 | large ? "rounded-full px-5 py-2.5" : "" | |
| 213 | }`} | |
| 214 | /> | |
| 215 | ); | |
| 216 | } | |
| 217 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 218 | /** |
| 219 | * A message with the g1t pages it names as links, such as the billing page | |
| 220 | * a refusal to start compute points to. | |
| 221 | */ | |
| 222 | export function Linked({ text }: { text: string }) { | |
| 223 | return ( | |
| 224 | <> | |
| 225 | {linkPaths(text).map((piece, at) => | |
| 226 | piece.href ? ( | |
| 227 | <Link key={at} to={piece.href} className="underline underline-offset-2"> | |
| 228 | {piece.text} | |
| 229 | </Link> | |
| 230 | ) : ( | |
| 231 | piece.text | |
| 232 | ), | |
| 233 | )} | |
| 234 | </> | |
| 235 | ); | |
| 236 | } | |
| 237 | ||
| Automations: rules in .g1t/automations that act when something happens | 238 | export function ErrorText({ children }: { children: ReactNode }) { |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 239 | if (!children) return null; |
| 240 | if (typeof children !== "string") return <p className="text-sm text-danger">{children}</p>; | |
| 241 | // Waiting for a free agent slot is not an error: it starts by itself. | |
| 242 | return ( | |
| 243 | <p className={`text-sm ${isWaitingMessage(children) ? "text-muted" : "text-danger"}`}> | |
| 244 | <Linked text={children} /> | |
| 245 | </p> | |
| 246 | ); | |
| 247 | } | |
| 248 | ||
| 249 | /** | |
| 250 | * What a workspace's plan says before someone tries to start compute it | |
| 251 | * would refuse ("Agents need a paid workspace or the free trial"), with | |
| 252 | * where to fix it. | |
| 253 | */ | |
| 254 | export function ComputeNote({ note }: { note: string | null | undefined }) { | |
| 255 | return note ? ( | |
| 256 | <p className="text-sm text-muted"> | |
| 257 | <Linked text={note} /> | |
| 258 | </p> | |
| 259 | ) : null; | |
| Automations: rules in .g1t/automations that act when something happens | 260 | } |
| 261 | ||
| 262 | /** A small outlined label. */ | |
| 263 | export function Pill({ children }: { children: ReactNode }) { | |
| 264 | return ( | |
| 265 | <span className="inline-flex items-center rounded-full border border-line px-2 py-0.5 text-xs text-muted"> | |
| 266 | {children} | |
| 267 | </span> | |
| 268 | ); | |
| 269 | } | |
| 270 | ||
| 271 | const AVATAR_HUES = [82, 200, 262, 28, 330, 160]; | |
| 272 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 273 | /** |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 274 | * g1t itself: its agent, as reviewer, assignee and commit author, and the |
| 275 | * system, as the author of security updates, the issues it opens and merges | |
| 276 | * from the queue. One name, `g1t`. | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 277 | */ |
| 278 | export function isSystemName(name: string | null | undefined): boolean { | |
| 279 | return name === "g1t"; | |
| 280 | } | |
| 281 | ||
| Merge g1tusercontent.com: registry answers run nothing in a browser, the site's pages run only their own scripts, repository files and avatars on their own origin, raw files rate limited per address | 282 | /** |
| 283 | * Where an uploaded avatar is served, from the hash it is stored by: the | |
| 284 | * usercontent origin, or the site's own address (which redirects there) | |
| 285 | * when it is not known. | |
| 286 | */ | |
| 287 | export function avatarUrl(avatar: string, usercontent = ""): string { | |
| 288 | return `${usercontent}/avatars/${avatar}`; | |
| Workspace names and icons, and a component kit for every control | 289 | } |
| 290 | ||
| Automations: rules in .g1t/automations that act when something happens | 291 | /** |
| Workspace names and icons, and a component kit for every control | 292 | * An uploaded avatar if there is one, else a letter avatar whose colour is |
| 293 | * stable for a given name. People are round; a workspace is `square`. An | |
| 294 | * image that fails to load falls back to the letter. | |
| Automations: rules in .g1t/automations that act when something happens | 295 | */ |
| 296 | export function Avatar({ | |
| 297 | name, | |
| 298 | size = 20, | |
| 299 | square, | |
| Workspace names and icons, and a component kit for every control | 300 | image, |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 301 | system, |
| Automations: rules in .g1t/automations that act when something happens | 302 | }: { |
| 303 | name: string; | |
| 304 | size?: number; | |
| 305 | square?: boolean; | |
| Workspace names and icons, and a component kit for every control | 306 | /** The uploaded avatar's hash, as identity returns it. */ |
| 307 | image?: string | null; | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 308 | /** g1t itself (a user of kind `system`), whatever the name. */ |
| 309 | system?: boolean; | |
| Automations: rules in .g1t/automations that act when something happens | 310 | }) { |
| Workspace names and icons, and a component kit for every control | 311 | const [failed, setFailed] = useState<string | null>(null); |
| Merge g1tusercontent.com: registry answers run nothing in a browser, the site's pages run only their own scripts, repository files and avatars on their own origin, raw files rate limited per address | 312 | const usercontent = usercontentFrom(useRouteLoaderData("root")); |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 313 | // g1t itself wears its own mark: the pixel 1 on a dark square. |
| 314 | if (system || isSystemName(name)) { | |
| 315 | return ( | |
| 316 | <span | |
| 317 | aria-hidden="true" | |
| You choose how g1t looks: Auto follows your system, Light and Dark stay put, set from the account menu, the phone's You and help sheet, Settings → Account or the command palette; the page is drawn in your choice from the first byte, every colour has a light value that reads at 4.5:1 or better, code and diffs are highlighted for both, diagrams and the document editor follow along, the product tour and the g1t mark's avatar stay dark, and Dark is what you see until you choose; the workspaces guide says how. | 318 | className="inline-flex shrink-0 items-center justify-center bg-[#0b0b0d] text-fg ring-1 ring-line-strong ring-inset scheme-dark" |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 319 | style={{ width: size, height: size, borderRadius: size * 0.24 }} |
| 320 | > | |
| 321 | <Mark className="size-full" /> | |
| 322 | </span> | |
| 323 | ); | |
| 324 | } | |
| Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016) | 325 | // ghost stands in for deleted accounts: a plain silhouette, as for |
| 326 | // anyone on a commit who has no account. | |
| 327 | if (name === "ghost" && !image) { | |
| 328 | return ( | |
| 329 | <span | |
| 330 | aria-hidden="true" | |
| 331 | className="inline-flex shrink-0 items-center justify-center rounded-full bg-line text-faint" | |
| 332 | style={{ width: size, height: size }} | |
| 333 | > | |
| 334 | <User size={Math.round(size * 0.62)} strokeWidth={2.25} /> | |
| 335 | </span> | |
| 336 | ); | |
| 337 | } | |
| Workspace names and icons, and a component kit for every control | 338 | if (image && failed !== image) { |
| 339 | return ( | |
| 340 | <img | |
| Merge g1tusercontent.com: registry answers run nothing in a browser, the site's pages run only their own scripts, repository files and avatars on their own origin, raw files rate limited per address | 341 | src={avatarUrl(image, usercontent)} |
| Workspace names and icons, and a component kit for every control | 342 | alt="" |
| 343 | aria-hidden="true" | |
| 344 | width={size} | |
| 345 | height={size} | |
| 346 | loading="lazy" | |
| 347 | decoding="async" | |
| 348 | onError={() => setFailed(image)} | |
| 349 | className="inline-block shrink-0 bg-raised object-cover" | |
| 350 | style={{ width: size, height: size, borderRadius: square ? size * 0.24 : size }} | |
| 351 | /> | |
| Automations: rules in .g1t/automations that act when something happens | 352 | ); |
| 353 | } | |
| 354 | let hash = 0; | |
| 355 | for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0; | |
| 356 | const hue = AVATAR_HUES[Math.abs(hash) % AVATAR_HUES.length]; | |
| 357 | return ( | |
| 358 | <span | |
| 359 | aria-hidden="true" | |
| 360 | className="inline-flex shrink-0 items-center justify-center font-mono font-semibold uppercase" | |
| 361 | style={{ | |
| 362 | width: size, | |
| 363 | height: size, | |
| 364 | borderRadius: square ? size * 0.24 : size, | |
| 365 | fontSize: size * 0.5, | |
| 366 | background: `oklch(0.4 0.09 ${hue})`, | |
| 367 | color: `oklch(0.93 0.08 ${hue})`, | |
| 368 | }} | |
| 369 | > | |
| 370 | {name[0]} | |
| 371 | </span> | |
| 372 | ); | |
| 373 | } | |
| 374 | ||
| 375 | /** A line of text (usually a command) with a copy button. */ | |
| 376 | export function CopyLine({ | |
| 377 | text, | |
| 378 | prompt, | |
| Code button: the SSH tab shows the address it will have, disabled, with why it is not on yet | 379 | disabled, |
| Merge demo dry run fixes: settled checks, other attempts, queue links, clone box, activity feed, DEMO.md | 380 | breakAtSlashes, |
| Automations: rules in .g1t/automations that act when something happens | 381 | }: { |
| 382 | text: string; | |
| 383 | prompt?: boolean; | |
| Code button: the SSH tab shows the address it will have, disabled, with why it is not on yet | 384 | /** Shown, so it is clear what will be there, but not yet usable: dimmed, with no copy. */ |
| 385 | disabled?: boolean; | |
| Merge demo dry run fixes: settled checks, other attempts, queue links, clone box, activity feed, DEMO.md | 386 | /** |
| 387 | * For a narrow box: an address longer than the line may wrap after a | |
| 388 | * `/`, so all of it shows, rather than scroll. | |
| 389 | */ | |
| 390 | breakAtSlashes?: boolean; | |
| Automations: rules in .g1t/automations that act when something happens | 391 | }) { |
| 392 | const [copied, setCopied] = useState(false); | |
| 393 | return ( | |
| Code button: the SSH tab shows the address it will have, disabled, with why it is not on yet | 394 | <div |
| 395 | aria-disabled={disabled || undefined} | |
| 396 | 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] ${disabled ? "cursor-not-allowed text-faint select-none" : ""}`} | |
| 397 | > | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 398 | {/* Wraps between words rather than hides: a command is no use half |
| 399 | seen. A word itself, an address or a flag, is never broken in | |
| 400 | two, which would change what is copied by eye; one longer than | |
| 401 | the line scrolls sideways instead. */} | |
| 402 | <code className="min-w-0 grow overflow-x-auto whitespace-pre-wrap [scrollbar-width:thin]"> | |
| Automations: rules in .g1t/automations that act when something happens | 403 | {prompt && <span className="mr-2 text-faint select-none">$</span>} |
| 404 | {text.split(/(\s+)/).map((part, index) => | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 405 | /\S/.test(part) ? ( |
| Merge demo dry run fixes: settled checks, other attempts, queue links, clone box, activity feed, DEMO.md | 406 | breakAtSlashes ? ( |
| 407 | // Each piece whole, with a chance to wrap after each `/`. | |
| 408 | <span key={index}> | |
| 409 | {part.split(/(?<=\/)(?!\/)/).map((piece, at) => ( | |
| 410 | <Fragment key={at}> | |
| 411 | {at > 0 && <wbr />} | |
| 412 | <span className="whitespace-nowrap">{piece}</span> | |
| 413 | </Fragment> | |
| 414 | ))} | |
| 415 | </span> | |
| 416 | ) : ( | |
| 417 | <span key={index} className="whitespace-nowrap"> | |
| 418 | {part} | |
| 419 | </span> | |
| 420 | ) | |
| Automations: rules in .g1t/automations that act when something happens | 421 | ) : ( |
| 422 | part | |
| 423 | ), | |
| 424 | )} | |
| 425 | </code> | |
| 426 | <button | |
| 427 | type="button" | |
| 428 | aria-label="Copy" | |
| Code button: the SSH tab shows the address it will have, disabled, with why it is not on yet | 429 | disabled={disabled} |
| 430 | className="shrink-0 rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-fg disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-faint" | |
| Automations: rules in .g1t/automations that act when something happens | 431 | onClick={() => { |
| 432 | void navigator.clipboard.writeText(text); | |
| 433 | setCopied(true); | |
| 434 | setTimeout(() => setCopied(false), 1500); | |
| 435 | }} | |
| 436 | > | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 437 | {copied ? <Check size={14} className="text-success" /> : <Copy size={14} />} |
| Automations: rules in .g1t/automations that act when something happens | 438 | </button> |
| 439 | </div> | |
| 440 | ); | |
| 441 | } | |
| 442 | ||
| 443 | const UNITS: [string, number][] = [ | |
| 444 | ["d", 86_400_000], | |
| 445 | ["h", 3_600_000], | |
| 446 | ["m", 60_000], | |
| 447 | ]; | |
| 448 | ||
| 449 | /** Compact relative time such as "5m ago". */ | |
| 450 | export function TimeAgo({ at }: { at: string | number }) { | |
| 451 | // Timestamps are RFC 3339 strings; a few older ones are still numbers. | |
| 452 | const elapsed = Date.now() - new Date(at).getTime(); | |
| 453 | const unit = UNITS.find(([, size]) => elapsed >= size); | |
| 454 | const label = unit ? `${Math.floor(elapsed / unit[1])}${unit[0]} ago` : "just now"; | |
| 455 | return ( | |
| 456 | // The server and the browser render at slightly different times. | |
| 457 | <time dateTime={new Date(at).toISOString()} suppressHydrationWarning> | |
| 458 | {label} | |
| 459 | </time> | |
| 460 | ); | |
| 461 | } | |
| 462 | ||
| 463 | export function EmptyState({ | |
| 464 | title, | |
| 465 | children, | |
| 466 | }: { | |
| 467 | title: string; | |
| 468 | children?: ReactNode; | |
| 469 | }) { | |
| 470 | return ( | |
| 471 | <div className="rounded-lg border border-dashed border-line px-6 py-12 text-center"> | |
| 472 | <p className="font-medium">{title}</p> | |
| 473 | {children && <div className="mt-1.5 text-sm text-muted">{children}</div>} | |
| 474 | </div> | |
| 475 | ); | |
| 476 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.