715 lines29,228 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 { type ReactNode, useEffect, useState } from "react";
19import { Link, useRouteLoaderData } from "react-router";
20
21import { CHANGELOG, changelogHref } from "../lib/changelog";
22import { trialClosed } from "../lib/trial";
23import { type Need, formatSpan, greetingFor, rankNeeds } from "../lib/mission";
24import { AgentSetup } from "./agent-setup";
25import type { ShellData } from "./shell";
26import { STAGE_LABEL, StageDots } from "./lifecycle";
27import { RunCard, formatCost, useLiveRefresh } from "./agents";
28import { CheckBadge } from "./checks";
29import { DEPLOY_STATUS, StatusDot, host } from "./deploy";
30import { ActivityFeed, Meter, NeedsList, Panel, PulseTile, Quiet, Unavailable, percent } from "./mission";
31import { Avatar, TimeAgo } from "./ui";
32import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
33import { IssueIcon, PullIcon } from "./work-icons";
34import type { Loaded } from "../routes/home";
35
36/*
37 * Mission control: the home page of someone signed in. It is a chunk of its
38 * own, loaded only for them, so the signed-out home page does not carry it.
39 * routes/home.tsx loads its data.
40 */
41
42/** The viewer's time zone, set by the page itself, so the greeting fits their day. */
43const TZ_COOKIE = "g1t_tz";
44
45const dollars = (micros: number) => `$${(Math.max(0, micros) / 1_000_000).toFixed(2)}`;
46
47// --- Pieces ---------------------------------------------------------------------
48
49const TONE = { fg: "text-fg", warn: "text-warn", danger: "text-danger", accent: "text-accent" };
50
51function Digest({ parts, seenBefore }: { parts: Loaded["digest"]; seenBefore: number | null }) {
52 const when = seenBefore ? (
53 <>
54 Since you were last here, <TimeAgo at={seenBefore} />
55 </>
56 ) : (
57 "In the last day"
58 );
59 if (parts.length === 0) {
60 return <p className="mt-1 text-sm text-muted">{when}: nothing new landed, and nothing is waiting on you.</p>;
61 }
62 return (
63 <p className="mt-1 text-sm leading-6 text-muted">
64 {when}:{" "}
65 {parts.map((part, index) => (
66 <span key={part.text}>
67 {index > 0 && (index === parts.length - 1 ? " and " : ", ")}
68 <a href={`#${part.anchor}`} className={`font-medium underline decoration-line-strong underline-offset-4 hover:decoration-current ${TONE[part.tone]}`}>
69 {part.text}
70 </a>
71 </span>
72 ))}
73 .
74 </p>
75 );
76}
77
78function PullList({ rows, empty }: { rows: Loaded["pullsTabs"]["authored"]; empty: ReactNode }) {
79 if (rows.length === 0) return <Quiet>{empty}</Quiet>;
80 return (
81 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
82 {rows.map((row) => (
83 <li key={row.key}>
84 <Link
85 prefetch="intent"
86 to={`/${row.repo.namespace}/${row.repo.name}/pull/${row.number}`}
87 className="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-raised"
88 >
89 <PullIcon status={row.status} />
90 <span className="min-w-0 grow">
91 <span className="block truncate text-sm font-medium">{row.title}</span>
92 <span className="block truncate font-mono text-xs text-muted">
93 {row.repo.name}#{row.number}
94 {row.agent && ` · ${row.agent}`}
95 </span>
96 </span>
97 {row.stage ? (
98 <span className="hidden shrink-0 items-center gap-2 text-xs text-muted sm:flex">
99 <StageDots stage={row.stage} />
100 <span className={row.stage === "needs_you" ? "text-warn" : ""}>{STAGE_LABEL[row.stage]}</span>
101 </span>
102 ) : (
103 <span className="hidden shrink-0 sm:block">
104 <CheckBadge status={row.checkStatus} />
105 </span>
106 )}
107 <span className="w-14 shrink-0 text-right text-xs text-faint">
108 <TimeAgo at={row.updatedAt} />
109 </span>
110 </Link>
111 </li>
112 ))}
113 </ul>
114 );
115}
116
117function IssueList({ rows, empty }: { rows: Loaded["issuesTabs"]["assigned"]; empty: ReactNode }) {
118 if (rows.length === 0) return <Quiet>{empty}</Quiet>;
119 return (
120 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
121 {rows.map((row) => (
122 <li key={row.key}>
123 <Link
124 prefetch="intent"
125 to={`/${row.repo.namespace}/${row.repo.name}/issues/${row.number}`}
126 className="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-raised"
127 >
128 <IssueIcon issue={{ state: "open", reason: null }} />
129 <span className="min-w-0 grow">
130 <span className="block truncate text-sm font-medium">{row.title}</span>
131 <span className="block truncate font-mono text-xs text-muted">
132 {row.repo.name}#{row.number}
133 </span>
134 </span>
135 {row.agent ? (
136 <span className="hidden shrink-0 items-center gap-1 text-xs text-merged sm:flex">
137 <Bot size={12} /> {row.agent} at work
138 </span>
139 ) : row.queued ? (
140 <span className="hidden shrink-0 text-xs text-muted sm:block">Queued for an agent</span>
141 ) : null}
142 <span className="w-14 shrink-0 text-right text-xs text-faint">
143 <TimeAgo at={row.updatedAt} />
144 </span>
145 </Link>
146 </li>
147 ))}
148 </ul>
149 );
150}
151
152function ProjectCard({ project, workspace }: { project: Loaded["projects"][number]; workspace: string }) {
153 const base = `/${workspace}/${project.slug}`;
154 const latest = project.latest;
155 return (
156 <li className="flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
157 <div className="flex items-center gap-2.5">
158 <span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
159 {project.private ? <Lock size={13} /> : <Box size={13} />}
160 </span>
161 <Link to={base} prefetch="intent" className="min-w-0 grow truncate font-medium hover:text-accent">
162 {project.name}
163 </Link>
164 {project.agents > 0 && (
165 <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">
166 <span className="size-1.5 animate-pulse rounded-full bg-merged" />
167 {project.agents} at work
168 </span>
169 )}
170 </div>
171 <div className="mt-3 min-h-10 text-xs">
172 {project.production ? (
173 <>
174 <a href={project.production.url} className="flex items-center gap-1 truncate font-mono text-fg-soft hover:text-accent">
175 {host(project.production.url)}
176 <ArrowUpRight size={12} className="shrink-0 text-faint" />
177 </a>
178 <p className="mt-1 flex items-center gap-2 text-muted">
179 {latest && <StatusDot status={latest.status} />}
180 <span>
181 deployed <TimeAgo at={project.production.deployedAt} />
182 </span>
183 </p>
184 </>
185 ) : project.deploys ? (
186 <p className="text-muted">
187 {latest ? <StatusDot status={latest.status} label={`${DEPLOY_STATUS[latest.status].label}, not live yet`} /> : "Deploys on the next push to the default branch."}
188 </p>
189 ) : (
190 <p className="text-faint">
191 Deployments are off.{" "}
192 <Link to={`${base}/settings/deployments`} className="text-muted hover:text-fg">
193 Turn on deployments
194 </Link>
195 </p>
196 )}
197 </div>
198 <dl className="mt-3 grid grid-cols-3 gap-2 border-t border-line pt-3 text-xs">
199 <div>
200 <dt className="text-faint">Open PRs</dt>
201 <dd className="mt-0.5 font-medium tabular-nums">
202 <Link to={`${base}/pulls`} className="hover:text-accent">
203 {project.openPulls ?? "—"}
204 </Link>
205 </dd>
206 </div>
207 <div>
208 <dt className="text-faint">Agents</dt>
209 <dd className="mt-0.5 font-medium tabular-nums">
210 <Link to={`${base}/agents`} className="hover:text-accent">
211 {project.agents}
212 </Link>
213 </dd>
214 </div>
215 <div title={project.checks ? `${project.checks} recent check runs` : "No recent check runs"}>
216 <dt className="text-faint">Checks</dt>
217 <dd className="mt-0.5 font-medium tabular-nums">{percent(project.passRate)}</dd>
218 <dd className="mt-1">
219 <Meter value={project.passRate} tone={project.passRate != null && project.passRate < 0.7 ? "bg-warn" : "bg-accent"} />
220 </dd>
221 </div>
222 </dl>
223 </li>
224 );
225}
226
227type Step = { done: boolean; title: string; about: string; to: string | null; action: string };
228
229/**
230 * The first things to do, ticked off as they are done, until work has been
231 * handed to agents.
232 */
233function GetStarted({ steps }: { steps: Step[] }) {
234 const left = steps.filter((step) => !step.done).length;
235 return (
236 <section className="rounded-2xl bg-surface p-5 ring-1 ring-line">
237 <div className="flex items-baseline justify-between gap-3">
238 <h2 className="font-semibold tracking-tight">Get started</h2>
239 <span className="text-xs text-muted">
240 {steps.length - left} of {steps.length} done
241 </span>
242 </div>
243 <ol className="mt-4 space-y-2">
244 {steps.map((step, index) => (
245 <li
246 key={step.title}
247 className={`flex items-start gap-3 rounded-xl px-3 py-2.5 ${step.done ? "" : "bg-bg/50 ring-1 ring-line"}`}
248 >
249 <span
250 className={`mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-[0.6875rem] font-medium ${
251 step.done ? "bg-accent text-bg" : "text-muted ring-1 ring-line-strong"
252 }`}
253 >
254 {step.done ? "✓" : index + 1}
255 </span>
256 <span className="min-w-0 grow">
257 <span className={`block text-sm font-medium ${step.done ? "text-muted line-through decoration-faint" : ""}`}>
258 {step.title}
259 </span>
260 {!step.done && <span className="mt-0.5 block text-xs leading-5 text-muted">{step.about}</span>}
261 </span>
262 {!step.done && step.to && (
263 <Link
264 to={step.to}
265 className="shrink-0 rounded-md bg-fg px-2.5 py-1 text-xs font-medium text-bg transition-colors hover:bg-white"
266 >
267 {step.action}
268 </Link>
269 )}
270 </li>
271 ))}
272 </ol>
273 </section>
274 );
275}
276
277/** The workspace being looked at: what it holds and what is moving in it. */
278function WorkspaceCard({
279 slug,
280 name,
281 avatar,
282 role,
283 projects,
284 needs,
285 live,
286}: {
287 slug: string;
288 name: string;
289 avatar?: string | null;
290 role: string;
291 projects: number;
292 needs: number;
293 live: number;
294}) {
295 const stats = [
296 { value: projects, label: "projects" },
297 { value: live, label: "agents at work" },
298 { value: needs, label: "need you" },
299 ];
300 return (
301 <div className="rounded-xl border border-line bg-surface p-5">
302 <div className="flex items-center gap-3">
303 <Avatar name={slug} image={avatar} size={32} square />
304 <div className="min-w-0">
305 <Link to={`/${slug}`} title={name} className="block truncate font-medium hover:underline">
306 {name}
307 </Link>
308 <p className="truncate text-xs text-muted">
309 <span className="font-mono">g1t.sh/{slug}</span> · <span className="capitalize">{role}</span>
310 </p>
311 </div>
312 </div>
313 <dl className="mt-4 grid grid-cols-3 gap-2">
314 {stats.map((stat) => (
315 <div key={stat.label} className="rounded-lg bg-bg/60 px-2.5 py-2 ring-1 ring-line">
316 <dt className="sr-only">{stat.label}</dt>
317 <dd className="text-lg font-semibold tabular-nums tracking-tight">{stat.value}</dd>
318 <dd className="text-[0.6875rem] leading-tight text-muted">{stat.label}</dd>
319 </div>
320 ))}
321 </dl>
322 <div className="mt-4 flex flex-wrap gap-x-4 gap-y-1 text-sm">
323 <Link to={`/${slug}/-/people`} className="inline-flex items-center gap-1.5 text-muted hover:text-fg">
324 <Users size={13} />
325 Members
326 </Link>
327 <Link
328 to={role === "owner" ? `/${slug}/-/settings` : `/${slug}/-/people`}
329 className="inline-flex items-center gap-1.5 text-muted hover:text-fg"
330 >
331 <Settings size={13} />
332 Settings
333 </Link>
334 <Link to={`/${slug}/-/agents`} className="inline-flex items-center gap-1.5 text-muted hover:text-fg">
335 <Radio size={13} />
336 Fleet
337 </Link>
338 </div>
339 </div>
340 );
341}
342
343function UsageCard({ shell, weekCost }: { shell: ShellData; weekCost: number }) {
344 const slug = shell.workspace?.slug;
345 const used = shell.monthUsageMicros ?? 0;
346 const limit = shell.limit;
347 const ceiling = limit && !limit.comped ? limit.ceilingMicros : null;
348 const share = ceiling ? Math.min(1, limit!.exposureMicros / ceiling) : null;
349 const month = new Date().toLocaleDateString("en-US", { month: "long" });
350 return (
351 <div className="rounded-xl border border-line bg-surface p-5">
352 <div className="flex items-center justify-between">
353 <h2 className="flex items-center gap-1.5 text-sm font-semibold">
354 <Coins size={14} className="text-faint" />
355 Usage in {month}
356 </h2>
357 {slug && (
358 <Link to={`/${slug}/-/usage`} className="text-xs text-muted hover:text-fg">
359 Details
360 </Link>
361 )}
362 </div>
363 <p className="mt-3 text-2xl font-semibold tracking-tight tabular-nums">{dollars(used)}</p>
364 <p className="text-xs text-muted">
365 {shell.free ? "At cost. g1t charges nothing while it is being built out." : "Charged so far this month."}
366 {weekCost > 0 && ` ${formatCost(weekCost)} of it in the last 7 days.`}
367 </p>
368 {ceiling != null && (
369 <div className="mt-3">
370 <Meter value={share} tone={limit!.state === "ok" ? "bg-accent" : limit!.state === "warning" ? "bg-warn" : "bg-danger"} />
371 <p className="mt-1.5 text-[0.6875rem] text-faint">
372 {dollars(limit!.exposureMicros)} of the {dollars(ceiling)} limit
373 </p>
374 </div>
375 )}
376 </div>
377 );
378}
379
380function WhatsNew({ workspace }: { workspace: string | null }) {
381 return (
382 <div className="rounded-xl border border-line bg-surface p-5">
383 <h2 className="flex items-center gap-1.5 text-sm font-semibold">
384 <Sparkles size={14} className="text-accent" />
385 What's new on g1t
386 </h2>
387 <ul className="mt-3 space-y-3">
388 {CHANGELOG.slice(0, 5).map((entry) => {
389 const href = changelogHref(entry, workspace);
390 const title = <span className="text-sm font-medium">{entry.title}</span>;
391 return (
392 <li key={entry.title}>
393 {href ? (
394 <Link to={href} className="hover:text-accent">
395 {title}
396 </Link>
397 ) : (
398 title
399 )}
400 <p className="mt-0.5 text-xs leading-5 text-muted">{entry.about}</p>
401 </li>
402 );
403 })}
404 </ul>
405 <a href="https://docs.g1t.sh/" className="mt-4 inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
406 Read the docs <ArrowRight size={12} />
407 </a>
408 </div>
409 );
410}
411
412// --- The page -------------------------------------------------------------------
413
414export default function MissionControl({ loaderData }: { loaderData: Loaded }) {
415 const shell = useRouteLoaderData("root")?.shell as ShellData | null | undefined;
416 const loaded: Loaded = loaderData;
417 // Agents are at work, so the page changes without anyone touching it.
418 const changing =
419 (loaded?.liveTotal ?? 0) > 0 ||
420 (loaded?.active ?? []).some((item) => item.lifecycle && item.lifecycle.stage !== "needs_you" && item.lifecycle.stage !== "ready");
421 useLiveRefresh(changing);
422 const [greeting, setGreeting] = useState(loaded?.greeting ?? "");
423 const [pullsTab, setPullsTab] = useState(() => (loaded && loaded.pullsTabs.review.length > 0 ? "review" : "authored"));
424 useEffect(() => {
425 // The greeting follows the viewer's own clock, and the server learns
426 // their zone for next time.
427 setGreeting(greetingFor(new Date().getHours()));
428 try {
429 const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
430 if (zone) document.cookie = `${TZ_COOKIE}=${encodeURIComponent(zone)}; Path=/; Max-Age=31536000; SameSite=Lax`;
431 } catch {
432 // Nothing to remember.
433 }
434 }, []);
435
436 const { viewer, repos, active, needs: serverNeeds, live, liveTotal, pullsTabs, issuesTabs, groups, pulse, projects, canRunAgents, trial } = loaded;
437
438 const workspace = shell?.workspace?.slug ?? viewer.workspaces?.[0]?.slug ?? null;
439 // The usage limit, as the sidebar knows it, heads the list when it bites.
440 const limitNeed: Need[] =
441 shell?.limit && !shell.limit.comped && shell.limit.state !== "ok" && workspace
442 ? [
443 {
444 key: "limit",
445 kind: "limit",
446 title: shell.limit.state === "stopped" ? "Agents are stopped: the usage limit was reached" : "Usage is nearing its limit",
447 detail:
448 shell.limit.state === "stopped"
449 ? "No new agent starts until the limit is raised or a card is added."
450 : `${dollars(shell.limit.exposureMicros)} of ${dollars(shell.limit.ceilingMicros ?? 0)} used. Add a card so work keeps going.`,
451 to: `/${workspace}/-/billing`,
452 action: "Billing",
453 at: Date.now(),
454 where: null,
455 },
456 ]
457 : [];
458 const needs = rankNeeds([...limitNeed, ...serverNeeds]);
459
460 const first = repos[0];
461 const handedOff = active.length > 0 || (shell?.monthUsageMicros ?? 0) > 0;
462 const steps: Step[] = [
463 {
464 done: Boolean(workspace),
465 title: "Create a workspace",
466 about: "Projects, people and agent credit live in one.",
467 to: "/workspaces/new",
468 action: "Create",
469 },
470 {
471 done: repos.length > 0,
472 title: "Add a project",
473 about: "Create a repository, or import one by its address. Push to it with git as usual.",
474 to: workspace ? `/new?workspace=${workspace}` : "/new",
475 action: "Add",
476 },
477 trial?.open
478 ? {
479 done: true,
480 title: "Try g1t's agents free",
481 about: trial.granted
482 ? `This workspace has ${dollars(trial.limitMicros - trial.usedMicros)} of its ${dollars(trial.limitMicros)} trial credit left, for g1t's models and sandboxes.`
483 : `This workspace gets ${dollars(trial.limitMicros)} of trial credit, for g1t's models and sandboxes, the first time its agents work.`,
484 to: workspace ? `/${workspace}/-/integrations` : null,
485 action: "Connect",
486 }
487 : canRunAgents
488 ? {
489 done: Boolean(shell?.free) || shell?.limit == null || shell.limit.comped || shell.limit.state === "ok",
490 title: "Keep work running",
491 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.",
492 to: workspace ? `/${workspace}/-/billing` : null,
493 action: "Billing",
494 }
495 : {
496 done: false,
497 title: "Connect a model",
498 about: `${
499 trialClosed(trial, workspace ?? "This workspace") ? `${trialClosed(trial, workspace ?? "This workspace")} ` : ""
500 }Agents need a model to think with. Connect your Anthropic or OpenAI key, or any compatible endpoint.`,
501 to: workspace ? `/${workspace}/-/integrations` : null,
502 action: "Connect",
503 },
504 {
505 done: handedOff,
506 title: "Hand off an outcome",
507 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.",
508 to: first ? `/${first.namespace}/${first.name}/plans` : null,
509 action: "Write one",
510 },
511 ];
512 const starting = steps.some((step) => !step.done);
513 const counts = (n: number) => (n > 0 ? <span className="ml-1 tabular-nums text-faint">{n}</span> : null);
514
515 return (
516 <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]">
517 <div className="min-w-0 space-y-10">
518 <header>
519 <h1 className="text-2xl font-semibold tracking-tight sm:text-[1.75rem]" suppressHydrationWarning>
520 {greeting}, {loaded.name}
521 </h1>
522 <Digest parts={loaded.digest} seenBefore={loaded.seenBefore} />
523 </header>
524
525 {starting && <GetStarted steps={steps} />}
526
527 <Panel id="needs-you" title="Needs you" icon={<Hand size={14} className="text-warn" />} count={needs.length}>
528 {needs.length === 0 ? (
529 <Quiet>Nothing is waiting on you. Reviews, stopped work and failed deploys show up here first.</Quiet>
530 ) : (
531 <NeedsList needs={needs} />
532 )}
533 </Panel>
534
535 <Panel
536 id="live"
537 title="Live now"
538 icon={<span className={`block size-2 rounded-full ${liveTotal > 0 ? "animate-pulse bg-merged" : "bg-line-strong"}`} />}
539 count={liveTotal}
540 all={workspace ? { to: `/${workspace}/-/agents`, label: "Fleet" } : null}
541 >
542 {!loaded.runsLoaded && workspace ? (
543 <Unavailable what="Agent runs" />
544 ) : live.length === 0 ? (
545 <Quiet>No agent is at work right now. Assign an issue to g1t-agent and one starts on it in seconds.</Quiet>
546 ) : (
547 <ul className="space-y-3">
548 {live.map((run) => (
549 <RunCard key={run.id} run={run} member showRepo />
550 ))}
551 {liveTotal > live.length && workspace && (
552 <li className="text-xs text-muted">
553 <Link to={`/${workspace}/-/agents`} className="hover:text-fg">
554 {liveTotal - live.length} more at work in the fleet
555 </Link>
556 </li>
557 )}
558 </ul>
559 )}
560 </Panel>
561
562 <Panel
563 title="This week"
564 icon={<ActivityIcon size={14} />}
565 all={workspace ? { to: `/${workspace}/-/usage`, label: "Usage" } : null}
566 >
567 <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5">
568 <PulseTile
569 label="Changes landed"
570 value={String(pulse.merged)}
571 hint="last 7 days"
572 values={pulse.mergedDays}
573 format={(v) => `${v} landed`}
574 />
575 <PulseTile
576 label="Agent hours"
577 value={pulse.hours < 10 ? pulse.hours.toFixed(1) : String(Math.round(pulse.hours))}
578 hint="time agents spent working"
579 values={pulse.hoursDays}
580 format={(v) => `${v.toFixed(1)} h`}
581 to={workspace ? `/${workspace}/-/agents` : undefined}
582 />
583 <PulseTile
584 label="Agent cost"
585 value={formatCost(pulse.cost) ?? "$0.00"}
586 hint="model and sandbox"
587 values={pulse.costDays}
588 format={(v) => formatCost(v) ?? "$0.00"}
589 to={workspace ? `/${workspace}/-/usage` : undefined}
590 />
591 <PulseTile
592 label="Issue to landed"
593 value={formatSpan(pulse.issueToMerge)}
594 hint={pulse.mergedWithIssue ? `median of ${pulse.mergedWithIssue}` : "no issue landed yet"}
595 />
596 <PulseTile
597 label="Checks pass first time"
598 value={percent(pulse.firstPass)}
599 hint={pulse.firstPassOf ? `of ${pulse.firstPassOf} pull requests` : "no checks ran yet"}
600 />
601 </div>
602 </Panel>
603
604 <div className="grid gap-10 xl:grid-cols-2">
605 <Tabs value={pullsTab} onValueChange={setPullsTab}>
606 <Panel
607 id="your-pulls"
608 title="Your pull requests"
609 icon={<GitPullRequest size={14} />}
610 all={{ to: `/u/${viewer.username}?tab=pulls` }}
611 >
612 <TabsList>
613 <TabsTrigger value="authored">Opened{counts(pullsTabs.authored.length)}</TabsTrigger>
614 <TabsTrigger value="review">Review requested{counts(pullsTabs.review.length)}</TabsTrigger>
615 <TabsTrigger value="assigned">Assigned{counts(pullsTabs.assigned.length)}</TabsTrigger>
616 </TabsList>
617 <TabsContent value="authored">
618 <PullList rows={pullsTabs.authored} empty="None open. Pull requests you or your agents start show here with where each stands." />
619 </TabsContent>
620 <TabsContent value="review">
621 {loaded.perRepoLoaded ? (
622 <PullList rows={pullsTabs.review} empty="No one is waiting on your review." />
623 ) : (
624 <Unavailable what="Review requests" />
625 )}
626 </TabsContent>
627 <TabsContent value="assigned">
628 <PullList rows={pullsTabs.assigned} empty="No open pull request is assigned to you." />
629 </TabsContent>
630 </Panel>
631 </Tabs>
632
633 <Tabs defaultValue="assigned">
634 <Panel title="Your issues" icon={<CircleDot size={14} />} all={{ to: `/u/${viewer.username}?tab=issues` }}>
635 <TabsList>
636 <TabsTrigger value="assigned">Assigned{counts(issuesTabs.assigned.length)}</TabsTrigger>
637 <TabsTrigger value="authored">Opened{counts(issuesTabs.authored.length)}</TabsTrigger>
638 </TabsList>
639 <TabsContent value="assigned">
640 <IssueList rows={issuesTabs.assigned} empty="Nothing is assigned to you." />
641 </TabsContent>
642 <TabsContent value="authored">
643 <IssueList rows={issuesTabs.authored} empty="You have no open issues." />
644 </TabsContent>
645 </Panel>
646 </Tabs>
647 </div>
648
649 <Panel id="activity" title="Activity" icon={<ActivityIcon size={14} />}>
650 {!loaded.perRepoLoaded ? (
651 <Unavailable what="Activity" />
652 ) : (
653 <ActivityFeed groups={groups} empty="Nothing has moved yet. Merges, deploys, checks, reviews and what agents learn show up here." />
654 )}
655 </Panel>
656
657 {workspace && (
658 <Panel
659 id="projects"
660 title="Projects"
661 icon={<Box size={14} />}
662 count={projects.length}
663 all={{ to: `/${workspace}`, label: "Workspace" }}
664 extra={
665 <Link to={`/new?workspace=${workspace}`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
666 <Plus size={12} /> New
667 </Link>
668 }
669 >
670 {!loaded.projectsLoaded ? (
671 <Unavailable what="Projects" />
672 ) : projects.length === 0 ? (
673 <Quiet>No projects yet. Create a repository or import one, and it gets a project of its own.</Quiet>
674 ) : (
675 <ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
676 {projects.map((project) => (
677 <ProjectCard key={project.slug} project={project} workspace={workspace} />
678 ))}
679 </ul>
680 )}
681 </Panel>
682 )}
683 </div>
684
685 <aside className="space-y-4">
686 {shell?.workspace && (
687 <WorkspaceCard
688 slug={shell.workspace.slug.toLowerCase()}
689 name={shell.workspace.name || shell.workspace.slug}
690 avatar={shell.workspace.avatar}
691 role={shell.workspace.role}
692 projects={shell.repos.length}
693 needs={needs.length}
694 live={liveTotal}
695 />
696 )}
697 {shell?.workspace && <UsageCard shell={shell} weekCost={pulse.cost} />}
698 <WhatsNew workspace={workspace} />
699 <div className="rounded-xl border border-line bg-surface p-5">
700 <h2 className="text-sm font-semibold">Connect your own agent</h2>
701 <p className="mt-1.5 text-xs leading-5 text-muted">
702 Add g1t to your coding agent. It signs in through your browser; there is no token to copy.
703 </p>
704 <AgentSetup className="mt-3" />
705 <Link
706 to="https://docs.g1t.sh/guides/bring-your-own-agent/"
707 className="mt-3 inline-flex items-center gap-1 text-xs text-muted hover:text-fg"
708 >
709 How it works <ArrowRight size={12} />
710 </Link>
711 </div>
712 </aside>
713 </main>
714 );
715}