Skip to content

g1t/apps/web/app/components/mission-control.tsx

682 lines28,008 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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