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