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