flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/web/app/routes/home.tsx

1,199 lines47,880 bytesCodeBlame
1import {
2 Activity as ActivityIcon,
3 ArrowRight,
4 ArrowUpRight,
5 Bot,
6 Box,
7 CircleDot,
8 Coins,
9 GitPullRequest,
10 Hand,
11 Lock,
12 Plus,
13 Radio,
14 Settings,
15 Sparkles,
16 Users,
17} from "lucide-react";
18import { env } from "cloudflare:workers";
19import { type ReactNode, useEffect, useState } from "react";
20import { Link, data, useRouteLoaderData } from "react-router";
21
22import {
23 type AgentRun,
24 type AuthoredItem,
25 type CheckStatus,
26 type Deployment,
27 type G1tEvent,
28 type Issue,
29 type Lifecycle,
30 type LiveApp,
31 type Pull,
32 type PullStatus,
33 type Repo,
34 type RepoPath,
35 type Stage,
36 isActiveRun,
37} from "@g1t/contracts";
38
39import type { Route } from "./+types/home";
40import { page } from "../lib/meta";
41import { CHANGELOG, changelogHref } from "../lib/changelog";
42import {
43 type ActivityItem,
44 type Need,
45 SEEN_COOKIE,
46 TIME,
47 agentHours,
48 dailyBuckets,
49 digestParts,
50 eventItem,
51 firstPassRate,
52 formatSpan,
53 greetingFor,
54 groupActivity,
55 hourIn,
56 issueToMerge,
57 median,
58 nextSeen,
59 rankNeeds,
60 readCookie,
61 stuckMinutes,
62} from "../lib/mission";
63import { Landing } from "../components/landing";
64import type { ShellData } from "../components/shell";
65import { STAGE_LABEL, StageDots } from "../components/lifecycle";
66import { RunCard, formatCost, useLiveRefresh } from "../components/agents";
67import { CheckBadge } from "../components/checks";
68import { DEPLOY_STATUS, StatusDot, host } from "../components/deploy";
69import { ActivityFeed, Meter, NeedsList, Panel, PulseTile, Quiet, Unavailable, percent } from "../components/mission";
70import { Avatar, CopyLine, TimeAgo } from "../components/ui";
71import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
72import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
73import { IssueIcon, PullIcon } from "../components/work";
74import {
75 agents,
76 billing,
77 deployments,
78 events as eventLog,
79 identity,
80 projects as projectsApi,
81 repos as reposApi,
82 work,
83} from "../lib/services.server";
84import { getViewer } from "../lib/session.server";
85
86/** The viewer's time zone, set by the page itself, so the greeting fits their day. */
87const TZ_COOKIE = "g1t_tz";
88/** The most projects whose pull requests and events are read for the page. */
89const MAX_PROJECTS = 10;
90const EVENTS_PER_PROJECT = 80;
91
92const dollars = (micros: number) => `$${(Math.max(0, micros) / 1_000_000).toFixed(2)}`;
93
94export function meta(args: Route.MetaArgs) {
95 return page(args, {
96 title: "g1t — Git for AI scale",
97 description:
98 "A git forge for thousands of agents working on the same code at once: every change isolated, every decision recorded, every change landed in order. Open source, built on Cloudflare.",
99 });
100}
101
102export function headers({ loaderHeaders }: Route.HeadersArgs) {
103 const out = new Headers();
104 const timing = loaderHeaders.get("Server-Timing");
105 if (timing) out.set("Server-Timing", timing);
106 const cookie = loaderHeaders.get("Set-Cookie");
107 if (cookie) out.set("Set-Cookie", cookie);
108 return out;
109}
110
111/** A row of the viewer's pull requests, whichever list it comes from. */
112type PullRow = {
113 key: string;
114 repo: RepoPath;
115 number: number;
116 title: string;
117 status: PullStatus;
118 agent: string | null;
119 stage: Stage | null;
120 detail: string | null;
121 checkStatus: CheckStatus | null;
122 updatedAt: string;
123};
124
125/** A row of the viewer's issues. */
126type IssueRow = {
127 key: string;
128 repo: RepoPath;
129 number: number;
130 title: string;
131 agent: string | null;
132 queued: boolean;
133 updatedAt: string;
134};
135
136type ProjectHealth = {
137 slug: string;
138 name: string;
139 private: boolean;
140 repo: RepoPath | null;
141 deploys: boolean;
142 production: LiveApp | null;
143 latest: Deployment | null;
144 openPulls: number | null;
145 agents: number;
146 passRate: number | null;
147 checks: number;
148};
149
150const pullRow = (pull: Pull, repo: RepoPath, lifecycle: Lifecycle | null): PullRow => ({
151 key: pull.id,
152 repo,
153 number: pull.number,
154 title: pull.title,
155 status: pull.status,
156 agent: pull.agent,
157 stage: lifecycle?.stage ?? null,
158 detail: lifecycle?.detail ?? null,
159 checkStatus: pull.checkStatus,
160 updatedAt: pull.updatedAt,
161});
162
163const authoredPull = (item: AuthoredItem): PullRow => ({
164 key: `${item.repo.namespace}/${item.repo.name}#${item.number}`,
165 repo: item.repo,
166 number: item.number,
167 title: item.title,
168 status: item.status ?? (item.draft ? "draft" : "open"),
169 agent: null,
170 stage: null,
171 detail: null,
172 checkStatus: null,
173 updatedAt: item.updatedAt,
174});
175
176export async function loader({ context, request }: Route.LoaderArgs) {
177 const viewer = getViewer(context);
178 if (!viewer) {
179 const repos = await reposApi.list(viewer, { memberOnly: false });
180 return data({ signedIn: false as const, repos });
181 }
182
183 const now = Date.now();
184 const weekAgo = now - 7 * TIME.DAY;
185 const started = Date.now();
186 const times: Record<string, number> = {};
187 // Each call is timed, and one that fails leaves its section empty rather
188 // than taking the page down.
189 const soft = <T,>(name: string, promise: Promise<T>): Promise<T | null> =>
190 promise.then(
191 (value) => ((times[name] = Date.now() - started), value),
192 (error) => {
193 console.warn(`home: ${name} failed`, error);
194 times[name] = Date.now() - started;
195 return null;
196 },
197 );
198 const okOr = <T,>(result: { ok: true; value: T } | { ok: false } | null): T | null => (result?.ok ? result.value : null);
199
200 const cookies = request.headers.get("cookie");
201 const seen = nextSeen(readCookie(cookies, SEEN_COOKIE), now);
202 const tz = readCookie(cookies, TZ_COOKIE);
203 const memberships = viewer.workspaces ?? [];
204 const slug = memberships[0]?.slug ?? null;
205 const mine = new Set(memberships.map((m) => m.slug.toLowerCase()));
206 const username = viewer.username;
207
208 const reposP = soft("repos", reposApi.list(viewer, { memberOnly: true }));
209 // The workspace's projects' open pull requests and recent events, read
210 // once each, all at once, as soon as the projects are known.
211 const perRepoP = reposP.then((repos) =>
212 Promise.all(
213 (repos ?? [])
214 .filter((repo) => mine.has(repo.namespace.toLowerCase()))
215 .slice(0, MAX_PROJECTS)
216 .map(async (repo) => {
217 const path = { namespace: repo.namespace, name: repo.name };
218 const [pulls, log] = await Promise.all([
219 work.listPulls(path, viewer, "open").catch(() => null),
220 eventLog.list({ repoId: repo.id, limit: EVENTS_PER_PROJECT }).catch(() => null),
221 ]);
222 return { repo, pulls: pulls?.ok ? pulls.value.slice(0, 60) : null, events: log };
223 }),
224 ),
225 );
226
227 const [repos, perRepo, active, assigned, models, profile, runs, overview, usage, authoredPulls, authoredIssues, projectList, memories] =
228 await Promise.all([
229 reposP,
230 soft("projects", perRepoP),
231 soft("pulls", work.listActivePulls(viewer)),
232 soft("assigned", work.listAssignedIssues(viewer)),
233 slug ? soft("models", env.RUNNER.modelAccess(slug)) : null,
234 soft("profile", identity.profile(username)),
235 slug ? soft("runs", agents.listRuns(viewer, { workspace: slug, limit: 150 })) : null,
236 slug ? soft("deploys", deployments.overview(slug, viewer)) : null,
237 slug ? soft("usage", billing.usage(slug, viewer, new Date(weekAgo - TIME.DAY).toISOString())) : null,
238 soft("authoredPulls", work.byAuthor(username, viewer, { kind: "pull", state: "open", sort: "updated", limit: 10 })),
239 soft("authoredIssues", work.byAuthor(username, viewer, { kind: "issue", state: "open", sort: "updated", limit: 10 })),
240 slug ? soft("projectList", projectsApi.list(slug, viewer)) : null,
241 slug ? soft("memories", agents.listMemories(viewer, slug, null)) : null,
242 ]);
243
244 const repoList = repos ?? [];
245 // Each issue and pull request is shown under its repository. Most are in
246 // the viewer's own, already listed; the rest are looked up once each.
247 const known = new Map<string, Repo>(repoList.map((repo) => [repo.id, repo]));
248 const missing = [
249 ...new Set([...(assigned ?? []).map((issue) => issue.repoId), ...(active ?? []).map(({ pull }) => pull.repoId)]),
250 ].filter((id) => !known.has(id));
251 const looked = await Promise.all(missing.map((id) => reposApi.getById(id, viewer).catch(() => null)));
252 for (const found of looked) if (found?.ok) known.set(found.value.id, found.value);
253 const pathOf = (repo: Repo): RepoPath => ({ namespace: repo.namespace, name: repo.name });
254
255 const activeList = (active ?? []).flatMap((item) => {
256 const repo = known.get(item.pull.repoId);
257 return repo ? [{ ...item, repo }] : [];
258 });
259 const runList = okOr(runs ?? null) ?? [];
260 const liveRuns = runList.filter((run) => isActiveRun(run.status));
261 const overviewList = okOr(overview ?? null);
262 const projectsOk = okOr(projectList ?? null);
263
264 // --- What the viewer's pull requests and issues are ----------------------
265 const lower = username.toLowerCase();
266 const openPulls = (perRepo ?? []).flatMap(({ repo, pulls }) => (pulls ?? []).map((pull) => ({ pull, repo })));
267 const reviewRequested = openPulls.filter(
268 ({ pull }) =>
269 pull.status === "open" &&
270 pull.author.username.toLowerCase() !== lower &&
271 pull.reviewers.some((name) => name.toLowerCase() === lower),
272 );
273 const assignedPulls = openPulls.filter(({ pull }) => pull.assignees.some((name) => name.toLowerCase() === lower));
274 const authoredRows =
275 activeList.length > 0
276 ? activeList.map(({ pull, lifecycle, repo }) => pullRow(pull, pathOf(repo), lifecycle))
277 : (okOr(authoredPulls)?.items ?? []).map(authoredPull);
278 const pullsTabs = {
279 authored: authoredRows.slice(0, 8),
280 review: reviewRequested.slice(0, 8).map(({ pull, repo }) => pullRow(pull, pathOf(repo), null)),
281 assigned: assignedPulls.slice(0, 8).map(({ pull, repo }) => pullRow(pull, pathOf(repo), null)),
282 };
283 const issueRow = (issue: Issue, repo: RepoPath): IssueRow => ({
284 key: issue.id,
285 repo,
286 number: issue.number,
287 title: issue.title,
288 agent: issue.agent,
289 queued: issue.queued,
290 updatedAt: issue.updatedAt,
291 });
292 const issuesTabs = {
293 assigned: (assigned ?? []).flatMap((issue) => {
294 const repo = known.get(issue.repoId);
295 return repo ? [issueRow(issue, pathOf(repo))] : [];
296 }).slice(0, 8),
297 authored: (okOr(authoredIssues)?.items ?? []).slice(0, 8).map((item) => ({
298 key: `${item.repo.namespace}/${item.repo.name}#${item.number}`,
299 repo: item.repo,
300 number: item.number,
301 title: item.title,
302 agent: null,
303 queued: false,
304 updatedAt: item.updatedAt,
305 })),
306 };
307
308 // --- Needs you ------------------------------------------------------------
309 const needs: Need[] = [];
310 for (const entry of overviewList ?? []) {
311 const latest = entry.latest;
312 if (latest?.kind === "production" && latest.status === "failed" && slug) {
313 needs.push({
314 key: `deploy:${entry.slug}`,
315 kind: "deploy",
316 title: `Production build of ${entry.slug} failed`,
317 detail: latest.error ?? "The last build of the default branch failed. Production still serves the build before it.",
318 to: `/${slug}/${entry.slug}/deployments/${latest.id}`,
319 action: "See the build",
320 at: Date.parse(latest.finishedAt ?? latest.createdAt),
321 where: `${slug}/${entry.slug}`,
322 });
323 }
324 }
325 for (const { pull, lifecycle, repo } of activeList) {
326 const where = `${repo.namespace}/${repo.name}#${pull.number}`;
327 const to = `/${repo.namespace}/${repo.name}/pull/${pull.number}`;
328 if (lifecycle?.stage === "needs_you") {
329 const conflict = /conflict/i.test(lifecycle.detail);
330 needs.push({
331 key: `pull:${pull.id}`,
332 kind: conflict ? "conflict" : "stalled",
333 title: pull.title,
334 detail: lifecycle.detail,
335 to,
336 action: conflict ? "Resolve" : "Decide",
337 at: Date.parse(pull.updatedAt),
338 where,
339 });
340 } else if (lifecycle?.stage === "ready") {
341 needs.push({ key: `pull:${pull.id}`, kind: "ready", title: pull.title, detail: "Checks passed and it was approved. It lands when you merge it.", to, action: "Merge", at: Date.parse(pull.updatedAt), where });
342 } else if (!lifecycle && pull.status === "open" && pull.checkStatus === "failed") {
343 needs.push({ key: `pull:${pull.id}`, kind: "checks", title: pull.title, detail: "Its acceptance checks failed on the latest push.", to, action: "See checks", at: Date.parse(pull.updatedAt), where });
344 }
345 }
346 for (const { pull, repo } of reviewRequested) {
347 needs.push({
348 key: `review:${pull.id}`,
349 kind: "review",
350 title: pull.title,
351 detail: `${pull.author.username} asked for your review.`,
352 to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
353 action: "Review",
354 at: Date.parse(pull.updatedAt),
355 where: `${repo.namespace}/${repo.name}#${pull.number}`,
356 });
357 }
358 let quietest: number | null = null;
359 for (const run of liveRuns) {
360 const minutes = stuckMinutes(run, now);
361 if (minutes == null) continue;
362 quietest = Math.max(quietest ?? 0, minutes);
363 needs.push({
364 key: `run:${run.id}`,
365 kind: "stuck",
366 title: run.title ?? `${run.agent}'s run`,
367 detail: `${run.agent} has reported nothing for ${minutes} min${run.step ? `. Last: ${run.step}` : ""}.`,
368 to: `/${run.repo.namespace}/${run.repo.name}/agents/runs/${run.id}`,
369 action: "Look",
370 at: Date.parse(run.updatedAt),
371 where: `${run.repo.namespace}/${run.repo.name}${run.number != null ? `#${run.number}` : ""}`,
372 });
373 }
374
375 // --- Activity -------------------------------------------------------------
376 const items: ActivityItem[] = [];
377 const allEvents: { repo: string; event: G1tEvent }[] = [];
378 for (const { repo, events } of perRepo ?? []) {
379 for (const event of events ?? []) {
380 allEvents.push({ repo: repo.id, event });
381 const item = eventItem(event, pathOf(repo));
382 if (item) items.push(item);
383 }
384 }
385 for (const entry of overviewList ?? []) {
386 const latest = entry.latest;
387 const served = latest?.status === "ready" || latest?.status === "replaced";
388 if (!slug || latest?.kind !== "production" || (!served && latest.status !== "failed")) continue;
389 items.push({
390 id: `deploy:${latest.id}`,
391 at: Date.parse(latest.finishedAt ?? latest.createdAt),
392 repo: { namespace: slug, name: entry.slug },
393 actor: latest.createdBy,
394 verb: served ? "deployed" : "deploy_failed",
395 number: null,
396 to: `/${slug}/${entry.slug}/deployments/${latest.id}`,
397 });
398 }
399 for (const memory of okOr(memories ?? null)?.workspace ?? []) {
400 const repo = memory.repo ?? memory.source.repo;
401 if (!repo || !slug) continue;
402 items.push({
403 id: `memory:${memory.id}`,
404 at: Date.parse(memory.createdAt),
405 repo,
406 actor: memory.createdBy,
407 verb: "learned",
408 number: null,
409 text: memory.text.length > 90 ? `${memory.text.slice(0, 88)}…` : memory.text,
410 to: `/${slug}/-/memory`,
411 });
412 }
413 const groups = groupActivity(items).slice(0, 60);
414
415 // --- Since you were last here --------------------------------------------
416 const since = seen.since ?? now - TIME.DAY;
417 const after = (verb: ActivityItem["verb"]) => items.filter((item) => item.verb === verb && item.at > since).length;
418 const digest = digestParts({
419 landed: after("landed"),
420 reviews: reviewRequested.length,
421 opened: after("opened_issue"),
422 deploys: after("deployed"),
423 failedDeploys: after("deploy_failed"),
424 stuck: quietest,
425 });
426
427 // --- Pulse ----------------------------------------------------------------
428 const week = allEvents.filter(({ event }) => Date.parse(event.time) >= weekAgo);
429 const mergedWeek = week.filter(({ event }) => event.type === "pull.merged");
430 const spans = issueToMerge(
431 allEvents.flatMap(({ repo, event }) => (event.type === "issue.opened" ? [{ repo, number: event.data.number, at: Date.parse(event.time) }] : [])),
432 mergedWeek.flatMap(({ repo, event }) => (event.type === "pull.merged" ? [{ repo, issue: event.data.issue ?? null, at: Date.parse(event.time) }] : [])),
433 );
434 const checkEvents = allEvents.flatMap(({ repo, event }) =>
435 event.type === "checks.completed" ? [{ repo, number: event.data.number, at: Date.parse(event.time), passed: event.data.status === "passed" }] : [],
436 );
437 const firstPass = firstPassRate(checkEvents.filter((c) => c.at >= weekAgo));
438 const usageOk = okOr(usage ?? null);
439 const costPoints = usageOk
440 ? usageOk.byDay.map((slice) => ({ at: Date.parse(`${slice.key.split("/")[0]}T12:00:00Z`), value: slice.micros / 1_000_000 }))
441 : runList.filter((run) => run.costUsd != null).map((run) => ({ at: Date.parse(run.createdAt), value: run.costUsd ?? 0 }));
442 const costDays = dailyBuckets(costPoints, 7, now);
443 const pulse = {
444 merged: mergedWeek.length,
445 mergedDays: dailyBuckets(mergedWeek.map(({ event }) => ({ at: Date.parse(event.time) })), 7, now),
446 hours: agentHours(runList, weekAgo, now),
447 hoursDays: dailyBuckets(
448 runList
449 .filter((run) => run.startedAt)
450 .map((run) => ({ at: Date.parse(run.startedAt!), value: agentHours([run], weekAgo, now) })),
451 7,
452 now,
453 ),
454 cost: costDays.reduce((sum, v) => sum + v, 0),
455 costDays,
456 issueToMerge: median(spans),
457 mergedWithIssue: spans.length,
458 firstPass: firstPass.rate,
459 firstPassOf: firstPass.of,
460 };
461
462 // --- Projects -------------------------------------------------------------
463 const byRepoName = new Map((perRepo ?? []).map((entry) => [`${entry.repo.namespace}/${entry.repo.name}`.toLowerCase(), entry]));
464 const projectsHealth: ProjectHealth[] = (projectsOk ?? []).slice(0, 12).map((project) => {
465 const repo = project.source.kind === "hosted" ? project.source.repo : null;
466 const key = repo ? `${repo.namespace}/${repo.name}`.toLowerCase() : "";
467 const entry = byRepoName.get(key);
468 const deploy = overviewList?.find((d) => d.slug === project.slug) ?? null;
469 const checks = (entry?.events ?? []).flatMap((event) => (event.type === "checks.completed" ? [event.data.status === "passed"] : []));
470 return {
471 slug: project.slug,
472 name: project.name,
473 private: project.private,
474 repo,
475 deploys: deploy?.enabled ?? false,
476 production: deploy?.production ?? null,
477 latest: deploy?.latest ?? null,
478 openPulls: entry?.pulls ? entry.pulls.length : null,
479 agents: liveRuns.filter((run) => repo && `${run.repo.namespace}/${run.repo.name}`.toLowerCase() === key).length,
480 passRate: checks.length ? checks.filter(Boolean).length / checks.length : null,
481 checks: checks.length,
482 };
483 });
484
485 const models_ = models ?? null;
486 times.total = Date.now() - started;
487 const serverTiming = Object.entries(times)
488 .map(([name, ms]) => `${name};dur=${ms}`)
489 .join(", ");
490 const secure = new URL(request.url).protocol === "https:" ? "; Secure" : "";
491 return data(
492 {
493 signedIn: true as const,
494 viewer,
495 name: profile?.name?.trim() || username,
496 greeting: greetingFor(hourIn(now, tz)),
497 seenBefore: seen.since,
498 repos: repoList,
499 canRunAgents: models_ == null || models_.hosted || models_.own != null,
500 trial: models_?.own == null ? (models_?.trial ?? null) : null,
501 active: activeList.map(({ pull, lifecycle, repo }) => ({ pull, lifecycle, repo })),
502 needs: rankNeeds(needs),
503 live: liveRuns.slice(0, 12) as AgentRun[],
504 liveTotal: liveRuns.length,
505 runsLoaded: runs != null && runs.ok,
506 pullsTabs,
507 issuesTabs,
508 perRepoLoaded: perRepo != null,
509 groups,
510 digest,
511 pulse,
512 projects: projectsHealth,
513 projectsLoaded: projectsOk != null,
514 },
515 {
516 headers: {
517 "Server-Timing": serverTiming,
518 "Set-Cookie": `${SEEN_COOKIE}=${seen.value}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax${secure}`,
519 },
520 },
521 );
522}
523
524type Loaded = Extract<Route.ComponentProps["loaderData"], { signedIn: true }>;
525
526// --- Pieces ---------------------------------------------------------------------
527
528const TONE = { fg: "text-fg", warn: "text-warn", danger: "text-danger", accent: "text-accent" };
529
530function Digest({ parts, seenBefore }: { parts: Loaded["digest"]; seenBefore: number | null }) {
531 const when = seenBefore ? (
532 <>
533 Since you were last here, <TimeAgo at={seenBefore} />
534 </>
535 ) : (
536 "In the last day"
537 );
538 if (parts.length === 0) {
539 return <p className="mt-1 text-sm text-muted">{when}: nothing new landed, and nothing is waiting on you.</p>;
540 }
541 return (
542 <p className="mt-1 text-sm leading-6 text-muted">
543 {when}:{" "}
544 {parts.map((part, index) => (
545 <span key={part.text}>
546 {index > 0 && (index === parts.length - 1 ? " and " : ", ")}
547 <a href={`#${part.anchor}`} className={`font-medium underline decoration-line-strong underline-offset-4 hover:decoration-current ${TONE[part.tone]}`}>
548 {part.text}
549 </a>
550 </span>
551 ))}
552 .
553 </p>
554 );
555}
556
557function PullList({ rows, empty }: { rows: Loaded["pullsTabs"]["authored"]; empty: ReactNode }) {
558 if (rows.length === 0) return <Quiet>{empty}</Quiet>;
559 return (
560 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
561 {rows.map((row) => (
562 <li key={row.key}>
563 <Link
564 prefetch="intent"
565 to={`/${row.repo.namespace}/${row.repo.name}/pull/${row.number}`}
566 className="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-raised"
567 >
568 <PullIcon status={row.status} />
569 <span className="min-w-0 grow">
570 <span className="block truncate text-sm font-medium">{row.title}</span>
571 <span className="block truncate font-mono text-xs text-muted">
572 {row.repo.name}#{row.number}
573 {row.agent && ` · ${row.agent}`}
574 </span>
575 </span>
576 {row.stage ? (
577 <span className="hidden shrink-0 items-center gap-2 text-xs text-muted sm:flex">
578 <StageDots stage={row.stage} />
579 <span className={row.stage === "needs_you" ? "text-warn" : ""}>{STAGE_LABEL[row.stage]}</span>
580 </span>
581 ) : (
582 <span className="hidden shrink-0 sm:block">
583 <CheckBadge status={row.checkStatus} />
584 </span>
585 )}
586 <span className="w-14 shrink-0 text-right text-xs text-faint">
587 <TimeAgo at={row.updatedAt} />
588 </span>
589 </Link>
590 </li>
591 ))}
592 </ul>
593 );
594}
595
596function IssueList({ rows, empty }: { rows: Loaded["issuesTabs"]["assigned"]; empty: ReactNode }) {
597 if (rows.length === 0) return <Quiet>{empty}</Quiet>;
598 return (
599 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
600 {rows.map((row) => (
601 <li key={row.key}>
602 <Link
603 prefetch="intent"
604 to={`/${row.repo.namespace}/${row.repo.name}/issues/${row.number}`}
605 className="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-raised"
606 >
607 <IssueIcon issue={{ state: "open", reason: null }} />
608 <span className="min-w-0 grow">
609 <span className="block truncate text-sm font-medium">{row.title}</span>
610 <span className="block truncate font-mono text-xs text-muted">
611 {row.repo.name}#{row.number}
612 </span>
613 </span>
614 {row.agent ? (
615 <span className="hidden shrink-0 items-center gap-1 text-xs text-merged sm:flex">
616 <Bot size={12} /> {row.agent} at work
617 </span>
618 ) : row.queued ? (
619 <span className="hidden shrink-0 text-xs text-muted sm:block">Queued for an agent</span>
620 ) : null}
621 <span className="w-14 shrink-0 text-right text-xs text-faint">
622 <TimeAgo at={row.updatedAt} />
623 </span>
624 </Link>
625 </li>
626 ))}
627 </ul>
628 );
629}
630
631function ProjectCard({ project, workspace }: { project: Loaded["projects"][number]; workspace: string }) {
632 const base = `/${workspace}/${project.slug}`;
633 const latest = project.latest;
634 return (
635 <li className="flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
636 <div className="flex items-center gap-2.5">
637 <span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
638 {project.private ? <Lock size={13} /> : <Box size={13} />}
639 </span>
640 <Link to={base} prefetch="intent" className="min-w-0 grow truncate font-medium hover:text-accent">
641 {project.name}
642 </Link>
643 {project.agents > 0 && (
644 <span className="inline-flex items-center gap-1 rounded-full border border-merged/40 bg-merged/10 px-2 py-0.5 text-[0.6875rem] text-merged">
645 <span className="size-1.5 animate-pulse rounded-full bg-merged" />
646 {project.agents} at work
647 </span>
648 )}
649 </div>
650 <div className="mt-3 min-h-10 text-xs">
651 {project.production ? (
652 <>
653 <a href={project.production.url} className="flex items-center gap-1 truncate font-mono text-fg-soft hover:text-accent">
654 {host(project.production.url)}
655 <ArrowUpRight size={12} className="shrink-0 text-faint" />
656 </a>
657 <p className="mt-1 flex items-center gap-2 text-muted">
658 {latest && <StatusDot status={latest.status} />}
659 <span>
660 deployed <TimeAgo at={project.production.deployedAt} />
661 </span>
662 </p>
663 </>
664 ) : project.deploys ? (
665 <p className="text-muted">
666 {latest ? <StatusDot status={latest.status} label={`${DEPLOY_STATUS[latest.status].label}, not live yet`} /> : "Deploys on the next push to the default branch."}
667 </p>
668 ) : (
669 <p className="text-faint">
670 Not deployed.{" "}
671 <Link to={`${base}/settings/deployments`} className="text-muted hover:text-fg">
672 Put it on g1t.page
673 </Link>
674 </p>
675 )}
676 </div>
677 <dl className="mt-3 grid grid-cols-3 gap-2 border-t border-line pt-3 text-xs">
678 <div>
679 <dt className="text-faint">Open PRs</dt>
680 <dd className="mt-0.5 font-medium tabular-nums">
681 <Link to={`${base}/pulls`} className="hover:text-accent">
682 {project.openPulls ?? "—"}
683 </Link>
684 </dd>
685 </div>
686 <div>
687 <dt className="text-faint">Agents</dt>
688 <dd className="mt-0.5 font-medium tabular-nums">
689 <Link to={`${base}/agents`} className="hover:text-accent">
690 {project.agents}
691 </Link>
692 </dd>
693 </div>
694 <div title={project.checks ? `${project.checks} recent check runs` : "No recent check runs"}>
695 <dt className="text-faint">Checks</dt>
696 <dd className="mt-0.5 font-medium tabular-nums">{percent(project.passRate)}</dd>
697 <dd className="mt-1">
698 <Meter value={project.passRate} tone={project.passRate != null && project.passRate < 0.7 ? "bg-warn" : "bg-accent"} />
699 </dd>
700 </div>
701 </dl>
702 </li>
703 );
704}
705
706type Step = { done: boolean; title: string; about: string; to: string | null; action: string };
707
708/**
709 * The first things to do, ticked off as they are done, until work has been
710 * handed to agents.
711 */
712function GetStarted({ steps }: { steps: Step[] }) {
713 const left = steps.filter((step) => !step.done).length;
714 return (
715 <section className="rounded-2xl bg-surface p-5 ring-1 ring-line">
716 <div className="flex items-baseline justify-between gap-3">
717 <h2 className="font-semibold tracking-tight">Get started</h2>
718 <span className="text-xs text-muted">
719 {steps.length - left} of {steps.length} done
720 </span>
721 </div>
722 <ol className="mt-4 space-y-2">
723 {steps.map((step, index) => (
724 <li
725 key={step.title}
726 className={`flex items-start gap-3 rounded-xl px-3 py-2.5 ${step.done ? "" : "bg-bg/50 ring-1 ring-line"}`}
727 >
728 <span
729 className={`mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-[0.6875rem] font-medium ${
730 step.done ? "bg-accent text-bg" : "text-muted ring-1 ring-line-strong"
731 }`}
732 >
733 {step.done ? "✓" : index + 1}
734 </span>
735 <span className="min-w-0 grow">
736 <span className={`block text-sm font-medium ${step.done ? "text-muted line-through decoration-faint" : ""}`}>
737 {step.title}
738 </span>
739 {!step.done && <span className="mt-0.5 block text-xs leading-5 text-muted">{step.about}</span>}
740 </span>
741 {!step.done && step.to && (
742 <Link
743 to={step.to}
744 className="shrink-0 rounded-md bg-fg px-2.5 py-1 text-xs font-medium text-bg transition-colors hover:bg-white"
745 >
746 {step.action}
747 </Link>
748 )}
749 </li>
750 ))}
751 </ol>
752 </section>
753 );
754}
755
756/** The workspace being looked at: what it holds and what is moving in it. */
757function WorkspaceCard({
758 slug,
759 name,
760 avatar,
761 role,
762 projects,
763 needs,
764 live,
765}: {
766 slug: string;
767 name: string;
768 avatar?: string | null;
769 role: string;
770 projects: number;
771 needs: number;
772 live: number;
773}) {
774 const stats = [
775 { value: projects, label: "projects" },
776 { value: live, label: "agents at work" },
777 { value: needs, label: "need you" },
778 ];
779 return (
780 <div className="rounded-xl border border-line bg-surface p-5">
781 <div className="flex items-center gap-3">
782 <Avatar name={slug} image={avatar} size={32} square />
783 <div className="min-w-0">
784 <Link to={`/${slug}`} title={name} className="block truncate font-medium hover:underline">
785 {name}
786 </Link>
787 <p className="truncate text-xs text-muted">
788 <span className="font-mono">g1t.sh/{slug}</span> · <span className="capitalize">{role}</span>
789 </p>
790 </div>
791 </div>
792 <dl className="mt-4 grid grid-cols-3 gap-2">
793 {stats.map((stat) => (
794 <div key={stat.label} className="rounded-lg bg-bg/60 px-2.5 py-2 ring-1 ring-line">
795 <dt className="sr-only">{stat.label}</dt>
796 <dd className="text-lg font-semibold tabular-nums tracking-tight">{stat.value}</dd>
797 <dd className="text-[0.6875rem] leading-tight text-muted">{stat.label}</dd>
798 </div>
799 ))}
800 </dl>
801 <div className="mt-4 flex flex-wrap gap-x-4 gap-y-1 text-sm">
802 <Link to={`/${slug}/-/people`} className="inline-flex items-center gap-1.5 text-muted hover:text-fg">
803 <Users size={13} />
804 Members
805 </Link>
806 <Link
807 to={role === "owner" ? `/${slug}/-/settings` : `/${slug}/-/people`}
808 className="inline-flex items-center gap-1.5 text-muted hover:text-fg"
809 >
810 <Settings size={13} />
811 Settings
812 </Link>
813 <Link to={`/${slug}/-/agents`} className="inline-flex items-center gap-1.5 text-muted hover:text-fg">
814 <Radio size={13} />
815 Fleet
816 </Link>
817 </div>
818 </div>
819 );
820}
821
822function UsageCard({ shell, weekCost }: { shell: ShellData; weekCost: number }) {
823 const slug = shell.workspace?.slug;
824 const used = shell.monthUsageMicros ?? 0;
825 const limit = shell.limit;
826 const ceiling = limit && !limit.comped ? limit.ceilingMicros : null;
827 const share = ceiling ? Math.min(1, limit!.exposureMicros / ceiling) : null;
828 const month = new Date().toLocaleDateString("en-US", { month: "long" });
829 return (
830 <div className="rounded-xl border border-line bg-surface p-5">
831 <div className="flex items-center justify-between">
832 <h2 className="flex items-center gap-1.5 text-sm font-semibold">
833 <Coins size={14} className="text-faint" />
834 Usage in {month}
835 </h2>
836 {slug && (
837 <Link to={`/${slug}/-/usage`} className="text-xs text-muted hover:text-fg">
838 Details
839 </Link>
840 )}
841 </div>
842 <p className="mt-3 text-2xl font-semibold tracking-tight tabular-nums">{dollars(used)}</p>
843 <p className="text-xs text-muted">
844 {shell.free ? "At cost. g1t charges nothing while it is being built out." : "Charged so far this month."}
845 {weekCost > 0 && ` ${formatCost(weekCost)} of it in the last 7 days.`}
846 </p>
847 {ceiling != null && (
848 <div className="mt-3">
849 <Meter value={share} tone={limit!.state === "ok" ? "bg-accent" : limit!.state === "warning" ? "bg-warn" : "bg-danger"} />
850 <p className="mt-1.5 text-[0.6875rem] text-faint">
851 {dollars(limit!.exposureMicros)} of the {dollars(ceiling)} limit
852 </p>
853 </div>
854 )}
855 </div>
856 );
857}
858
859function WhatsNew({ workspace }: { workspace: string | null }) {
860 return (
861 <div className="rounded-xl border border-line bg-surface p-5">
862 <h2 className="flex items-center gap-1.5 text-sm font-semibold">
863 <Sparkles size={14} className="text-accent" />
864 What's new on g1t
865 </h2>
866 <ul className="mt-3 space-y-3">
867 {CHANGELOG.slice(0, 5).map((entry) => {
868 const href = changelogHref(entry, workspace);
869 const title = <span className="text-sm font-medium">{entry.title}</span>;
870 return (
871 <li key={entry.title}>
872 {href ? (
873 <Link to={href} className="hover:text-accent">
874 {title}
875 </Link>
876 ) : (
877 title
878 )}
879 <p className="mt-0.5 text-xs leading-5 text-muted">{entry.about}</p>
880 </li>
881 );
882 })}
883 </ul>
884 <a href="https://docs.g1t.sh/" className="mt-4 inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
885 Read the docs <ArrowRight size={12} />
886 </a>
887 </div>
888 );
889}
890
891// --- The page -------------------------------------------------------------------
892
893export default function Home({ loaderData }: Route.ComponentProps) {
894 const shell = useRouteLoaderData("root")?.shell as ShellData | null | undefined;
895 const loaded = loaderData.signedIn ? loaderData : null;
896 // Agents are at work, so the page changes without anyone touching it.
897 const changing =
898 (loaded?.liveTotal ?? 0) > 0 ||
899 (loaded?.active ?? []).some((item) => item.lifecycle && item.lifecycle.stage !== "needs_you" && item.lifecycle.stage !== "ready");
900 useLiveRefresh(changing);
901 const [greeting, setGreeting] = useState(loaded?.greeting ?? "");
902 const [pullsTab, setPullsTab] = useState(() => (loaded && loaded.pullsTabs.review.length > 0 ? "review" : "authored"));
903 useEffect(() => {
904 // The greeting follows the viewer's own clock, and the server learns
905 // their zone for next time.
906 setGreeting(greetingFor(new Date().getHours()));
907 try {
908 const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
909 if (zone) document.cookie = `${TZ_COOKIE}=${encodeURIComponent(zone)}; Path=/; Max-Age=31536000; SameSite=Lax`;
910 } catch {
911 // Nothing to remember.
912 }
913 }, []);
914
915 if (!loaded) return <Landing repos={loaderData.repos} />;
916 const { viewer, repos, active, needs: serverNeeds, live, liveTotal, pullsTabs, issuesTabs, groups, pulse, projects, canRunAgents, trial } = loaded;
917
918 const workspace = shell?.workspace?.slug ?? viewer.workspaces?.[0]?.slug ?? null;
919 // The usage limit, as the sidebar knows it, heads the list when it bites.
920 const limitNeed: Need[] =
921 shell?.limit && !shell.limit.comped && shell.limit.state !== "ok" && workspace
922 ? [
923 {
924 key: "limit",
925 kind: "limit",
926 title: shell.limit.state === "stopped" ? "Agents are stopped: the usage limit was reached" : "Usage is nearing its limit",
927 detail:
928 shell.limit.state === "stopped"
929 ? "No new agent starts until the limit is raised or a card is added."
930 : `${dollars(shell.limit.exposureMicros)} of ${dollars(shell.limit.ceilingMicros ?? 0)} used. Add a card so work keeps going.`,
931 to: `/${workspace}/-/billing`,
932 action: "Billing",
933 at: Date.now(),
934 where: null,
935 },
936 ]
937 : [];
938 const needs = rankNeeds([...limitNeed, ...serverNeeds]);
939
940 const first = repos[0];
941 const handedOff = active.length > 0 || (shell?.monthUsageMicros ?? 0) > 0;
942 const steps: Step[] = [
943 {
944 done: Boolean(workspace),
945 title: "Create a workspace",
946 about: "Projects, people and agent credit live in one.",
947 to: "/workspaces/new",
948 action: "Create",
949 },
950 {
951 done: repos.length > 0,
952 title: "Add a project",
953 about: "Create a repository, or import one by its address. Push to it with git as usual.",
954 to: workspace ? `/new?workspace=${workspace}` : "/new",
955 action: "Add",
956 },
957 trial?.open
958 ? {
959 done: true,
960 title: "Try g1t's agents free",
961 about: `This workspace has ${dollars(trial.limitMicros - trial.usedMicros)} of its free ${dollars(trial.limitMicros)} left on g1t's models.`,
962 to: workspace ? `/${workspace}/-/integrations` : null,
963 action: "Connect",
964 }
965 : canRunAgents
966 ? {
967 done: Boolean(shell?.free) || shell?.limit == null || shell.limit.comped || shell.limit.state === "ok",
968 title: "Keep work running",
969 about: "Usage is charged after it runs. Add a card under Billing, so g1t charges it as you near your limit instead of stopping work.",
970 to: workspace ? `/${workspace}/-/billing` : null,
971 action: "Billing",
972 }
973 : {
974 done: false,
975 title: "Connect a model",
976 about: `${
977 trial?.reason === "used"
978 ? "This workspace has used its free allowance on g1t's models. "
979 : trial?.reason === "pool" || trial?.reason === "ended"
980 ? "The free allowance on g1t's models has ended. "
981 : ""
982 }Agents need a model to think with. Connect your Anthropic or OpenAI key, or any compatible endpoint.`,
983 to: workspace ? `/${workspace}/-/integrations` : null,
984 action: "Connect",
985 },
986 {
987 done: handedOff,
988 title: "Hand off an outcome",
989 about: "Describe what you want done above, or write an outcome on a project's Plans page and let a planner split it into issues.",
990 to: first ? `/${first.namespace}/${first.name}/plans` : null,
991 action: "Write one",
992 },
993 ];
994 const starting = steps.some((step) => !step.done);
995 const counts = (n: number) => (n > 0 ? <span className="ml-1 tabular-nums text-faint">{n}</span> : null);
996
997 return (
998 <main className="mx-auto grid max-w-7xl gap-x-10 gap-y-10 px-4 py-8 sm:py-10 lg:grid-cols-[minmax(0,1fr)_19rem]">
999 <div className="min-w-0 space-y-10">
1000 <header>
1001 <h1 className="text-2xl font-semibold tracking-tight sm:text-[1.75rem]" suppressHydrationWarning>
1002 {greeting}, {loaded.name}
1003 </h1>
1004 <Digest parts={loaded.digest} seenBefore={loaded.seenBefore} />
1005 </header>
1006
1007 {starting && <GetStarted steps={steps} />}
1008
1009 <Panel id="needs-you" title="Needs you" icon={<Hand size={14} className="text-warn" />} count={needs.length}>
1010 {needs.length === 0 ? (
1011 <Quiet>Nothing is waiting on you. Reviews, stopped work and failed deploys show up here first.</Quiet>
1012 ) : (
1013 <NeedsList needs={needs} />
1014 )}
1015 </Panel>
1016
1017 <Panel
1018 id="live"
1019 title="Live now"
1020 icon={<span className={`block size-2 rounded-full ${liveTotal > 0 ? "animate-pulse bg-merged" : "bg-line-strong"}`} />}
1021 count={liveTotal}
1022 all={workspace ? { to: `/${workspace}/-/agents`, label: "Fleet" } : null}
1023 >
1024 {!loaded.runsLoaded && workspace ? (
1025 <Unavailable what="Agent runs" />
1026 ) : live.length === 0 ? (
1027 <Quiet>No agent is at work right now. Assign an issue to g1t-agent and one starts on it in seconds.</Quiet>
1028 ) : (
1029 <ul className="space-y-3">
1030 {live.map((run) => (
1031 <RunCard key={run.id} run={run} member showRepo />
1032 ))}
1033 {liveTotal > live.length && workspace && (
1034 <li className="text-xs text-muted">
1035 <Link to={`/${workspace}/-/agents`} className="hover:text-fg">
1036 {liveTotal - live.length} more at work in the fleet
1037 </Link>
1038 </li>
1039 )}
1040 </ul>
1041 )}
1042 </Panel>
1043
1044 <Panel
1045 title="This week"
1046 icon={<ActivityIcon size={14} />}
1047 all={workspace ? { to: `/${workspace}/-/usage`, label: "Usage" } : null}
1048 >
1049 <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5">
1050 <PulseTile
1051 label="Changes landed"
1052 value={String(pulse.merged)}
1053 hint="last 7 days"
1054 values={pulse.mergedDays}
1055 format={(v) => `${v} landed`}
1056 />
1057 <PulseTile
1058 label="Agent hours"
1059 value={pulse.hours < 10 ? pulse.hours.toFixed(1) : String(Math.round(pulse.hours))}
1060 hint="time agents spent working"
1061 values={pulse.hoursDays}
1062 format={(v) => `${v.toFixed(1)} h`}
1063 to={workspace ? `/${workspace}/-/agents` : undefined}
1064 />
1065 <PulseTile
1066 label="Agent cost"
1067 value={formatCost(pulse.cost) ?? "$0.00"}
1068 hint="model and sandbox"
1069 values={pulse.costDays}
1070 format={(v) => formatCost(v) ?? "$0.00"}
1071 to={workspace ? `/${workspace}/-/usage` : undefined}
1072 />
1073 <PulseTile
1074 label="Issue to landed"
1075 value={formatSpan(pulse.issueToMerge)}
1076 hint={pulse.mergedWithIssue ? `median of ${pulse.mergedWithIssue}` : "no issue landed yet"}
1077 />
1078 <PulseTile
1079 label="Checks pass first time"
1080 value={percent(pulse.firstPass)}
1081 hint={pulse.firstPassOf ? `of ${pulse.firstPassOf} pull requests` : "no checks ran yet"}
1082 />
1083 </div>
1084 </Panel>
1085
1086 <div className="grid gap-10 xl:grid-cols-2">
1087 <Tabs value={pullsTab} onValueChange={setPullsTab}>
1088 <Panel
1089 id="your-pulls"
1090 title="Your pull requests"
1091 icon={<GitPullRequest size={14} />}
1092 all={{ to: `/u/${viewer.username}?tab=pulls` }}
1093 >
1094 <TabsList>
1095 <TabsTrigger value="authored">Opened{counts(pullsTabs.authored.length)}</TabsTrigger>
1096 <TabsTrigger value="review">Review requested{counts(pullsTabs.review.length)}</TabsTrigger>
1097 <TabsTrigger value="assigned">Assigned{counts(pullsTabs.assigned.length)}</TabsTrigger>
1098 </TabsList>
1099 <TabsContent value="authored">
1100 <PullList rows={pullsTabs.authored} empty="None open. Pull requests you or your agents start show here with where each stands." />
1101 </TabsContent>
1102 <TabsContent value="review">
1103 {loaded.perRepoLoaded ? (
1104 <PullList rows={pullsTabs.review} empty="No one is waiting on your review." />
1105 ) : (
1106 <Unavailable what="Review requests" />
1107 )}
1108 </TabsContent>
1109 <TabsContent value="assigned">
1110 <PullList rows={pullsTabs.assigned} empty="No open pull request is assigned to you." />
1111 </TabsContent>
1112 </Panel>
1113 </Tabs>
1114
1115 <Tabs defaultValue="assigned">
1116 <Panel title="Your issues" icon={<CircleDot size={14} />} all={{ to: `/u/${viewer.username}?tab=issues` }}>
1117 <TabsList>
1118 <TabsTrigger value="assigned">Assigned{counts(issuesTabs.assigned.length)}</TabsTrigger>
1119 <TabsTrigger value="authored">Opened{counts(issuesTabs.authored.length)}</TabsTrigger>
1120 </TabsList>
1121 <TabsContent value="assigned">
1122 <IssueList rows={issuesTabs.assigned} empty="Nothing is assigned to you." />
1123 </TabsContent>
1124 <TabsContent value="authored">
1125 <IssueList rows={issuesTabs.authored} empty="You have no open issues." />
1126 </TabsContent>
1127 </Panel>
1128 </Tabs>
1129 </div>
1130
1131 <Panel id="activity" title="Activity" icon={<ActivityIcon size={14} />}>
1132 {!loaded.perRepoLoaded ? (
1133 <Unavailable what="Activity" />
1134 ) : (
1135 <ActivityFeed groups={groups} empty="Nothing has moved yet. Merges, deploys, checks, reviews and what agents learn show up here." />
1136 )}
1137 </Panel>
1138
1139 {workspace && (
1140 <Panel
1141 id="projects"
1142 title="Projects"
1143 icon={<Box size={14} />}
1144 count={projects.length}
1145 all={{ to: `/${workspace}`, label: "Workspace" }}
1146 extra={
1147 <Link to={`/new?workspace=${workspace}`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
1148 <Plus size={12} /> New
1149 </Link>
1150 }
1151 >
1152 {!loaded.projectsLoaded ? (
1153 <Unavailable what="Projects" />
1154 ) : projects.length === 0 ? (
1155 <Quiet>No projects yet. Create a repository or import one, and it gets a project of its own.</Quiet>
1156 ) : (
1157 <ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
1158 {projects.map((project) => (
1159 <ProjectCard key={project.slug} project={project} workspace={workspace} />
1160 ))}
1161 </ul>
1162 )}
1163 </Panel>
1164 )}
1165 </div>
1166
1167 <aside className="space-y-4">
1168 {shell?.workspace && (
1169 <WorkspaceCard
1170 slug={shell.workspace.slug.toLowerCase()}
1171 name={shell.workspace.name || shell.workspace.slug}
1172 avatar={shell.workspace.avatar}
1173 role={shell.workspace.role}
1174 projects={shell.repos.length}
1175 needs={needs.length}
1176 live={liveTotal}
1177 />
1178 )}
1179 {shell?.workspace && <UsageCard shell={shell} weekCost={pulse.cost} />}
1180 <WhatsNew workspace={workspace} />
1181 <div className="rounded-xl border border-line bg-surface p-5">
1182 <h2 className="text-sm font-semibold">Connect your own agent</h2>
1183 <p className="mt-1.5 text-xs leading-5 text-muted">
1184 Add g1t to Claude Code, then run /mcp in it to sign in through your browser.
1185 </p>
1186 <div className="mt-3">
1187 <CopyLine prompt text="claude mcp add --transport http g1t https://mcp.g1t.sh" />
1188 </div>
1189 <Link
1190 to="https://docs.g1t.sh/guides/bring-your-own-agent/"
1191 className="mt-3 inline-flex items-center gap-1 text-xs text-muted hover:text-fg"
1192 >
1193 How it works <ArrowRight size={12} />
1194 </Link>
1195 </div>
1196 </aside>
1197 </main>
1198 );
1199}