Skip to content

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

Commit

Improve project dashboard context and simplify agent setup

syntaqxcommitted Parent176105bBrowse files
7 files+191−2080/7 viewed
+14−42
11 import { Check, Copy, ExternalLink } from "lucide-react";
2−import { type KeyboardEvent, useId, useMemo, useState, useSyncExternalStore } from "react";
2+import { useMemo, useState, useSyncExternalStore } from "react";
33 import { useRouteLoaderData } from "react-router";
44
55 import type { loader as rootLoader } from "../root";
1515 subscribeAgentChoice,
1616 } from "../lib/agent-setup";
1717 import { useAddresses } from "../lib/addresses";
18−import { cn } from "../lib/cn";
18+import { SelectField } from "./ui/select";
1919 import { Button } from "./ui/button";
2020 import { Card } from "./ui/card";
2121
2828 }
2929
3030 /**
31− * How to connect g1t to a coding agent: a tab per agent, the command or
31+ * How to connect g1t to a coding agent: a client picker, the command or
3232 * config for the one chosen, and a copy button. Choosing an agent switches
3333 * every block on the page and is remembered.
3434 */
3535 export function AgentSetup({ hint = true, className }: { hint?: boolean; className?: string }) {
3636 const [chosen, choose] = useAgentChoice();
3737 const [copied, setCopied] = useState(false);
38− const base = useId();
3938 const { mcp } = useAddresses();
4039 const agents = useMemo(() => agentsFor(mcp), [mcp]);
4140 const agent = agents[chosen];
42−
43− const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
44− const edge = event.key === "Home" ? 0 : event.key === "End" ? AGENT_IDS.length - 1 : null;
45− const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
46− if (!step && edge === null) return;
47− event.preventDefault();
48− const next = AGENT_IDS[edge ?? (AGENT_IDS.indexOf(chosen) + step + AGENT_IDS.length) % AGENT_IDS.length];
49− choose(next);
50− document.getElementById(`${base}-${next}`)?.focus();
51− };
5241
5342 const copy = () => {
5443 void navigator.clipboard?.writeText(agent.code).then(
6352 return (
6453 <div className={className}>
6554 <Card radius="lg" className="@container overflow-hidden">
66− <div
67− role="tablist"
68− aria-label="AI client"
69− onKeyDown={onKeyDown}
70− className="flex flex-wrap items-center gap-0.5 border-b border-line bg-bg/40 px-1.5 py-1.5"
71− >
72− {AGENT_IDS.map((id) => {
73− const on = id === chosen;
74− return (
75− <button
76− key={id}
77− id={`${base}-${id}`}
78− type="button"
79− role="tab"
80− aria-selected={on}
81− aria-controls={`${base}-panel`}
82− tabIndex={on ? 0 : -1}
83− onClick={() => { setCopied(false); choose(id); }}
84− className={cn(
85− "shrink-0 rounded-md px-1.5 py-1 text-xs @sm:px-2.5 font-medium whitespace-nowrap transition-colors outline-none focus-visible:ring-2 focus-visible:ring-accent/50",
86− on ? "bg-raised text-fg" : "text-muted hover:text-fg",
87− )}
88− >
89− {agents[id].label}
90− </button>
91− );
92− })}
55+ <div className="flex items-center justify-between gap-3 border-b border-line px-3.5 py-2">
56+ <span className="text-xs text-muted">Connect with</span>
57+ <SelectField
58+ aria-label="AI client"
59+ value={chosen}
60+ onValueChange={(id) => { setCopied(false); choose(id as AgentId); }}
61+ options={AGENT_IDS.map((id) => ({ value: id, label: agents[id].label }))}
62+ size="sm"
63+ className="w-44"
64+ />
9365 </div>
94− <div id={`${base}-panel`} role="tabpanel" aria-labelledby={`${base}-${chosen}`} className="relative">
66+ <div className="relative" role="region" aria-label={`${agent.label} setup`}>
9567 {agent.file && (
9668 <p className="px-3.5 pt-2.5 font-mono text-[0.6875rem] text-faint">{agent.file}</p>
9769 )}
+25−0
150150 </Suspense>
151151 );
152152 }
153+
154+/** A readable summary with the same detailed checks as the repository orb. */
155+export function CommitChecksSummary({ checks, sha }: { checks: ChecksSource; sha: string }) {
156+ const summary = (found: Record<string, CommitChecks> | null | undefined) => {
157+ if (found == null) return <p className="text-xs text-muted">Checks could not be loaded.</p>;
158+ const result = found[sha];
159+ if (!result || result.total === 0 || result.state === "none") return <p className="text-xs text-muted">No checks reported for this commit.</p>;
160+ return (
161+ <details className="group rounded-lg border border-line">
162+ <summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-xs hover:bg-raised/40 [&::-webkit-details-marker]:hidden">
163+ <CheckStateIcon state={result.state} />
164+ <span className="min-w-0 flex-1"><span className="block font-medium">{checksHeadline(result)}</span><span className="text-muted">{checksTally(result)}</span></span>
165+ <span className="text-accent group-open:hidden">Show checks</span>
166+ <span className="hidden text-accent group-open:inline">Hide checks</span>
167+ </summary>
168+ <ChecksList checks={result} className="border-t border-line" />
169+ </details>
170+ );
171+ };
172+ return checks instanceof Promise ? (
173+ <Suspense fallback={<Skeleton className="h-12 w-full rounded-lg" />}>
174+ <Await resolve={checks} errorElement={<p className="text-xs text-muted">Checks could not be loaded.</p>}>{summary}</Await>
175+ </Suspense>
176+ ) : summary(checks);
177+}
+20−18
6969 sessionTree,
7070 } from "../lib/coordination";
7171 import { AgentSetup } from "./agent-setup";
72−import { CopyLine, EmptyState, Input, SubmitButton, TimeAgo } from "./ui";
72+import { Input, SubmitButton, TimeAgo } from "./ui";
7373 import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
7474 import {
7575 AlertDialog,
11731173 /** What to do when nobody is working on a project: bring your own harness. */
11741174 export function StartHere({ title }: { title: string }) {
11751175 return (
1176− <EmptyState title={title}>
1177− <div className="mx-auto mt-3 max-w-lg space-y-3 text-left">
1178− <p>Connect your coding agent to work with this project:</p>
1179− <AgentSetup />
1180− <p>Ask your agent to claim an issue and record its session, or claim one with the CLI:</p>
1181− <CopyLine text="g1t work claim 12" prompt />
1182− <p>
1183− <a href="https://docs.g1t.sh/guides/bring-your-own-agent/" className="text-fg underline underline-offset-4">
1184− Connect an AI client
1185− </a>{" "}
1186− sets up coding agents and chat apps;{" "}
1187− <a href="https://docs.g1t.sh/guides/sessions/" className="text-fg underline underline-offset-4">
1188− Sessions, claims and dependencies
1189− </a>{" "}
1190− says how it works.
1191− </p>
1176+ <Card className="flex flex-wrap items-center justify-between gap-4 p-5">
1177+ <div>
1178+ <p className="text-sm font-medium">{title}</p>
1179+ <p className="mt-1 text-xs text-muted">Sessions and claimed issues appear here as work happens.</p>
11921180 </div>
1193− </EmptyState>
1181+ <Dialog>
1182+ <DialogTrigger asChild>
1183+ <Button variant="outline" size="sm"><Bot size={14} /> Connect an agent</Button>
1184+ </DialogTrigger>
1185+ <DialogContent className="max-w-xl">
1186+ <DialogHeader>
1187+ <DialogTitle>Connect your agent</DialogTitle>
1188+ <DialogDescription>Choose your client, connect it to g1t, then ask it to work on this project.</DialogDescription>
1189+ </DialogHeader>
1190+ <AgentSetup />
1191+ <p className="text-xs text-muted">Ask your agent to claim an issue and record its session so the team can follow along.</p>
1192+ <a href="https://docs.g1t.sh/guides/bring-your-own-agent/" className="text-sm text-accent hover:underline">Connection guide &rarr;</a>
1193+ </DialogContent>
1194+ </Dialog>
1195+ </Card>
11941196 );
11951197 }
+4−4
250250 * The About editor: a project's description and links, from its overview.
251251 * Posts `intent=about` to the page's action, and closes once saved.
252252 */
253−export function AboutEditor({ project }: { project: Pick<Project, "description" | "links" | "name"> }) {
253+export function AboutEditor({ project, action }: { project: Pick<Project, "description" | "links" | "name">; action?: string }) {
254254 const fetcher = useFetcher<{ error?: string; notice?: string }>();
255255 const [open, setOpen] = useState(false);
256256 const saved = fetcher.state === "idle" && fetcher.data != null && !fetcher.data.error;
260260 return (
261261 <Dialog open={open} onOpenChange={setOpen}>
262262 <Hint label="Edit the description and links">
263− <DialogTrigger className="rounded-md p-1 text-faint transition-colors hover:bg-raised hover:text-fg" aria-label="Edit About">
264− <Pencil size={14} />
263+ <DialogTrigger asChild>
264+ <Button variant="outline" size="sm" aria-label="Edit project details"><Pencil size={14} /> Edit details</Button>
265265 </DialogTrigger>
266266 </Hint>
267267 <DialogContent className="max-w-xl">
269269 <DialogTitle>Edit {project.name}'s About</DialogTitle>
270270 <DialogDescription>Its description and links show here, on its cards, and in its workspace's Projects.</DialogDescription>
271271 </DialogHeader>
272− <fetcher.Form method="post" className="space-y-5">
272+ <fetcher.Form method="post" action={action} className="space-y-5">
273273 <input type="hidden" name="intent" value="about" />
274274 <label className="block">
275275 <span className="mb-1.5 block text-sm font-medium text-muted">Description</span>
+3−2
157157 {deployment ? (
158158 <>
159159 <StatusDot status={deploymentStatus(deployment.state)} />
160+ {checks}
160161 <span className="inline-flex items-center gap-1 font-mono">
161162 <GitCommitHorizontal size={13} className="text-faint" />
162− {deployment.sha.slice(0, 7)}
163+ <Link to={`${base}/commit/${deployment.sha}`} className="hover:text-fg">{deployment.sha.slice(0, 7)}</Link>
163164 </span>
164165 <span>
165− deployed <TimeAgo at={deployment.created_at} />
166+ {deployment.state === "success" ? "Deployed" : "Updated"} <TimeAgo at={deployment.created_at} />
166167 {deployment.run_url ? (
167168 <>
168169 {" "}
+7−11
66 import type { Project } from "@g1t/contracts";
77
88 import type { Route } from "./+types/layout";
9+import { AboutEditor, LinkList } from "../../components/project-about";
910 import { Topics } from "../../components/topics";
1011 import { projectHomepage } from "../../lib/about";
1112 import { page } from "../../lib/meta";
219220 <div className={`mx-auto max-w-6xl px-4 pt-4 sm:px-5 ${signedIn ? "pb-4 max-md:pb-0" : ""}`}>
220221 <Header
221222 project={project}
223+ large={!rest}
222224 isPrivate={repo.isPrivate}
223225 archived={Boolean(repo.archivedAt)}
224226 namespace={repo.namespace}
225227 name={repo.name}
226228 parent={repo.parent}
227229 actions={
228− watching || stars || forking || (project && pinned != null) ? (
230+ watching || stars || forking || (project && (pinned != null || access.can.manage_settings)) ? (
229231 <>
232+ {project && access.can.manage_settings && <AboutEditor project={project} action={`${base}?index`} />}
230233 {project && pinned != null && (
231234 <PinButton workspace={project.workspace} slug={project.slug} name={project.name} pinned={pinned} />
232235 )}
238241 }
239242 />
240243 {/* What it is, where it lives and its topics, the same on every page of it. */}
241− {description && <p className="mt-2 line-clamp-2 max-w-3xl text-sm text-muted">{description}</p>}
242− {site && (
243− <a
244− href={site}
245− rel="noopener nofollow"
246− className="mt-1.5 flex w-fit max-w-full min-w-0 items-center gap-1.5 text-sm font-medium text-accent hover:underline"
247− >
248− <Link2 size={14} className="shrink-0" />
249− <span className="truncate">{site.replace(/^https?:\/\//, "").replace(/\/$/, "")}</span>
250− </a>
244+ {description && <p className="mt-2 max-w-3xl text-sm text-muted">{description}</p>}
245+ {project ? <LinkList links={{ ...project.links, homepage: project.links.homepage ?? site }} className="mt-2 flex flex-wrap gap-x-5 gap-y-2 space-y-0" /> : site && (
246+ <a href={site} rel="noopener nofollow" className="mt-2 inline-flex items-center gap-1.5 text-sm text-accent hover:underline"><Link2 size={14} />{site.replace(/^https?:\/\//, "")}</a>
251247 )}
252248 <Topics topics={repo.topics} className="mt-3" />
253249 <div className={signedIn ? "mt-3 md:hidden" : "mt-3"}>
+118−131
22 Activity,
33 ArrowRight,
44 ArrowUpRight,
5− BookOpen,
65 Code2,
76 GitBranch,
87 GitCommitHorizontal,
9− Globe,
108 Hand,
119 HeartPulse,
1210 Lock,
1412 Radio,
1513 Rocket,
1614 RotateCw,
17− Tag,
1815 } from "lucide-react";
1916 import { type ReactNode, Suspense } from "react";
2017 import { Await, Form, Link } from "react-router";
3532 import { Card } from "../../components/ui/card";
3633 import { type LivePull, LiveNowList, StartHere } from "../../components/coordination";
3734 import { awakeAndIdle, liveByIssue } from "../../lib/coordination";
38−import { type Need, ageBuckets, firstPassRate, passRate, rankNeeds, runHealth } from "../../lib/mission";
35+import { type Need, ageBuckets, rankNeeds, runHealth } from "../../lib/mission";
3936 import { count, duration, percent } from "../../lib/observability";
4037 import { ECOSYSTEM_LABEL, installCommands } from "../../lib/packages";
4138 import {
4239 PUBLISH_GUIDES,
43− bare,
4440 kindLabel,
45− linksToShow,
4641 latestTag,
4742 libraryPackages,
4843 packageName,
5146 projectPlan,
5247 publishGuide,
5348 } from "../../lib/project-kind";
54−import { AboutEditor, KindMenu, LinkList } from "../../components/project-about";
49+import { KindMenu } from "../../components/project-about";
5550 import { DocsHead, ElsewhereHead, type ExternalDeployment, OtherHead, WhereItRuns, deploymentStatus } from "../../components/project-head";
56−import { actions, deployments, events as eventLog, packages, projects, repos, telemetry, work } from "../../lib/services.server";
57−import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
51+import { actions, deployments, packages, projects, repos, telemetry, work } from "../../lib/services.server";
52+import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
5853 import { accessTo, countsFor, projectFor, refusal, repoFor } from "../../lib/access.server";
5954 import { shotVersion } from "./production-screenshot";
6055 import { DeploymentStateIcon, DeploymentsPanel } from "../../components/deployments-panel";
6257 import { cardSources, externalTarget, externalVersion, productionImageUrl } from "../../lib/production-image";
6358 import { useAddresses } from "../../lib/addresses";
6459 import { commitChecksFor } from "../../lib/commit-checks.server";
65−import { CommitChecksBadge } from "../../components/commit-checks";
60+import { CommitChecksBadge, CommitChecksSummary } from "../../components/commit-checks";
61+import { StatusIcon, Duration, shortRef, standingWord } from "../../components/actions";
62+import { resolveRange, rangeLabel } from "../../lib/usage";
63+import { deliveryDays, duration as deliveryDuration } from "../../lib/workspace-insights";
6664 import { repoPages } from "../../lib/repo-live";
6765 import { useRepoLive } from "../../lib/use-repo-live";
6866
115113 // A library or a tool shows its packages where an app shows production.
116114 const libraryRepoP = Promise.all([planP, repoP]).then(([plan, repo]) => (plan === "release" && repo?.ok ? repo.value : null));
117115 const packagesP = libraryRepoP.then((repo) => (repo ? soft(packages.list(params.owner, viewer, { repoId: repo.id })) : null));
118− // How often checks pass: pull requests' checks when any finished lately,
119− // otherwise the project's workflow runs.
120− const checkEventsP = repoP.then((repo) =>
121− repo?.ok ? soft(eventLog.list({ repoId: repo.value.id, types: ["checks.completed"], limit: 100 })) : null,
122− );
123116 const workflowRunsP = soft(actions.runs(path, viewer, { limit: 30 }));
124117 // What waits on someone signed in: their pull requests' lifecycles, and the open ones.
125118 const minePullsP = repoP.then((repo) =>
147140 : null,
148141 )
149142 : Promise.resolve(null);
150− // An app deployed by its own pipeline that has reported no deployment
151− // shows the default branch's latest commit instead.
152− const commitP = Promise.all([planP, environmentsP]).then(([plan, found]) =>
153− plan === "elsewhere" && !reportedProduction(found?.ok ? found.value.environments : [])
154− ? soft(repos.log(path, viewer, null, 1)).then((log) => (log?.ok ? (log.value[0] ?? null) : null))
155− : null,
156− );
143+ const commitP = soft(repos.log(path, viewer, null, 1)).then((log) => log?.ok ? (log.value[0] ?? null) : null);
144+ const range = resolveRange("30d", {}, new Date(now));
145+ const insightsP = repoP.then((repo) => repo?.ok
146+ ? forMembers(() => work.deliveryInsights([repo.value.id], viewer, range.from, range.until))
147+ : null);
157148
158149 const [
159150 { insider: member, can },
169160 mine,
170161 packageList,
171162 tagList,
172− checkEvents,
163+ insights,
173164 workflowRuns,
174165 observed,
175166 commit,
191182 packagesP,
192183 // The latest release, for About.
193184 soft(repos.tags(path, viewer)),
194− checkEventsP,
185+ insightsP,
195186 workflowRunsP,
196187 telemetryP,
197188 commitP,
242233 }
243234
244235 // --- Health ------------------------------------------------------------------
245− const checkResults = (checkEvents ?? []).flatMap((event) =>
246− event.type === "checks.completed" ? [{ repo: "", number: event.data.number, at: Date.parse(event.time), passed: event.data.status === "passed" }] : [],
247− );
248236 const openIssues = ok(issues);
249237
250238 // --- Live now ------------------------------------------------------------------
284272
285273 return {
286274 member,
275+ workspaceMember: roleIn(viewer, params.owner) != null,
287276 can,
288277 github,
289278 project: projectValue,
291280 builds: builds.slice(0, 30),
292281 previews: live.filter((app) => app.kind === "preview"),
293282 production,
283+ serving: current ? { sha: current.sha, at: current.updated_at } : null,
294284 // The screenshot of production on g1t.page, for members (like its builds).
295285 productionShot: member && liveProduction ? shotVersion(liveProduction) : null,
296286 latestProductionBuild: latestProduction ? { status: latestProduction.status } : null,
297287 productionLatest: productionLatest
298− ? { id: productionLatest.id, state: productionLatest.state, at: productionLatest.updated_at, environment: productionEnv!.name }
288+ ? { id: productionLatest.id, sha: productionLatest.sha, state: productionLatest.state, at: productionLatest.updated_at, environment: productionEnv!.name }
299289 : null,
300290 commit: commit ? { hash: commit.hash, authoredAt: commit.authoredAt } : null,
301291 // The commit's checks, streamed in after the page.
302− checks: commit ? commitChecksFor(path, viewer, [commit.hash]) : Promise.resolve(null),
292+ checks: commitChecksFor(path, viewer, [commit?.hash, deployment?.sha, productionLatest?.sha]),
293+ workflowRuns: ok(workflowRuns),
294+ insights,
295+ range,
303296 release: latestTag(ok(tagList) ?? []),
304297 // Production as reported from outside g1t, for an app deployed elsewhere.
305298 deployment,
324317 livePulls,
325318 defaultBranch,
326319 health: {
327− // A pull request's checks when any finished lately; otherwise the
328− // project's workflow runs, for one that ships straight to its branch.
329− ...(checkResults.length > 0 || !workflowRuns?.ok
330− ? { passRate: passRate(checkResults.map((c) => c.passed)), firstPass: firstPassRate(checkResults), checkRuns: checkResults.length }
331− : runHealth(workflowRuns.value)),
320+ ...runHealth(ok(workflowRuns) ?? []),
332321 ages: openIssues ? ageBuckets(openIssues.map((issue) => issue.createdAt), now) : null,
333322 },
334323 telemetry: telemetryData ? { overview: telemetryData.overview!, errors: telemetryData.errors ?? [] } : null,
551540 * instead, so nothing is said twice.
552541 */
553542 function Facts({ loaderData, base, plan }: { loaderData: Loaded; base: string; plan: string }) {
554− const { productionLatest, release, library, telemetry, health, open, environments } = loaderData;
543+ const { productionLatest, release, library, telemetry, open, environments } = loaderData;
555544 const headShowsProduction = !library && (plan === "production" || plan === "elsewhere");
556545 const releaseFact = release ? (
557546 <Fact
592581 ) : (
593582 <Fact label="Latest release" to={`${base}/tags`} value={<span className="font-normal text-muted">None yet</span>} />
594583 );
595− const checks = (
596− <Fact
597− label="Checks passing"
598− to={`${base}/actions`}
599− hint={health.checkRuns ? `Of the last ${plural(health.checkRuns, "run")}` : "No checks have run recently"}
600− value={
601− health.checkRuns ? (
602− <span className={health.passRate != null && health.passRate < 0.7 ? "text-warn" : undefined}>{share(health.passRate)}</span>
603− ) : (
604− <span className="font-normal text-muted">None lately</span>
605− )
606− }
607− />
608− );
584+ const branch = <Fact label="Default branch" to={`${base}/code`} value={loaderData.defaultBranch ?? "Unavailable"} />;
609585 return (
610586 <div className="grid grid-cols-2 divide-line border-t border-line text-sm sm:grid-cols-4 sm:divide-x">
611587 {first}
623599 </span>
624600 }
625601 />
626− {checks}
602+ {branch}
627603 </>
628604 ) : (
629605 <>
630− {checks}
631− <Fact
632− label="Pass on the first try"
633− to={`${base}/actions`}
634− hint={health.firstPass.of ? `Of ${plural(health.firstPass.of, "change")} checked lately` : undefined}
635− value={health.firstPass.of ? share(health.firstPass.rate) : <span className="font-normal text-muted">—</span>}
636− />
637− <Fact label="Open" to={`${base}/issues`} value={`${plural(open.issues, "issue")} · ${plural(open.pulls, "pull request")}`} />
606+ {branch}
607+ <Fact label="Open issues" to={`${base}/issues`} value={open.issues} />
608+ <Fact label="Open pull requests" to={`${base}/pulls`} value={open.pulls} />
638609 </>
639610 )}
640611 </div>
799770 actionData: Route.ComponentProps["actionData"];
800771 params: Route.ComponentProps["params"];
801772 }) {
802− const { member, project, settings, production, needs, library, claims, sessions, titles, livePulls, defaultBranch, previews } = loaderData;
773+ const { member, project, needs, library, claims, sessions, titles, livePulls, defaultBranch, previews } = loaderData;
803774 const base = `/${params.owner}/${params.repo}`;
804775 const name = project?.name ?? params.repo;
805776 const plan = project ? projectPlan(project) : "production";
806777 const canChange = member && loaderData.can.manage_settings;
807− const productionUrl = production ? (settings?.primaryDomain ? `https://${settings.primaryDomain}` : production.url) : null;
808− // Its homepage and docs beside what it is, unless the card below already links there.
809− const headUrl =
810− plan === "production"
811− ? (productionUrl ?? settings?.productionUrl)
812− : plan === "elsewhere"
813− ? (loaderData.deployment?.url ?? project?.productionUrl)
814− : plan === "docs"
815− ? (project?.links.docs ?? project?.productionUrl ?? project?.links.homepage)
816− : plan === "other"
817− ? (project?.links.homepage ?? project?.links.docs)
818− : null;
819− const stripLinks = project ? linksToShow({ ...project.links, custom: [] }, [headUrl]) : [];
778+ const checkSha = loaderData.productionLatest?.sha ?? loaderData.commit?.hash;
820779 const addresses = useAddresses();
821780 // The production card's picture: an uploaded image, served from the
822781 // usercontent origin, and where someone who may change it adds one.
830789 const busy = claims.length + awake.length > 0;
831790 const working = busy || idle.length > 0;
832791 // Deployments, checks, releases, claims and sessions show as they happen.
833− useRepoLive({ match: repoPages.overview(loaderData.commit?.hash) });
792+ useRepoLive({ match: (change) => repoPages.overview()(change) || repoPages.runs()(change) || change.kind === "git.push" });
834793 // Claims lapse and sessions go idle with no event to say so.
835− useRefreshWhile(working, 15_000);
794+ useRefreshWhile(working || (loaderData.workflowRuns ?? []).some((run) => run.status !== "completed"), 15_000);
836795 const live = liveByIssue(claims, awake);
837796 const moreIssues = live.issues.length - LIVE_SHOWN;
838797
845804 {project && (
846805 <div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-line px-5 py-2.5 text-xs sm:px-6">
847806 <KindMenu project={project} label={kindLabel(project)} canChange={canChange} />
848− {stripLinks.map((link) => (
849− <DeployLink key={link.key} href={link.url} className="inline-flex min-w-0 items-center gap-1.5 text-muted hover:text-fg">
850− {link.type === "docs" ? <BookOpen size={12} className="shrink-0 text-faint" /> : <Globe size={12} className="shrink-0 text-faint" />}
851− <span className="truncate">{link.type === "docs" ? `Docs · ${bare(link.url)}` : link.label}</span>
852− </DeployLink>
853− ))}
807+
854808 </div>
855809 )}
856810 {plan === "elsewhere" && project ? (
861815 canDeploy={member && loaderData.can.manage_integrations}
862816 deployment={loaderData.deployment}
863817 commit={loaderData.commit}
864− checks={loaderData.commit ? <CommitChecksBadge checks={loaderData.checks} sha={loaderData.commit.hash} className="size-5" /> : undefined}
818+ checks={(loaderData.deployment?.sha ?? loaderData.commit?.hash) ? <CommitChecksBadge checks={loaderData.checks} sha={(loaderData.deployment?.sha ?? loaderData.commit?.hash)!} /> : undefined}
865819 shot={
866820 <ProductionShot
867821 sources={cardSources(loaderData.elsewhereShot, upload)}
883837 ) : (
884838 <ProductionHead loaderData={loaderData} base={base} name={name} upload={upload} addImage={addImage} />
885839 )}
840+ {checkSha && (
841+ <div className="border-t border-line px-5 py-4 sm:px-6">
842+ <div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted">
843+ <GitCommitHorizontal size={14} />
844+ <span>{loaderData.productionLatest ? "Latest production deployment" : `Latest on ${defaultBranch ?? "default branch"}`}</span>
845+ <Link to={`${base}/commit/${checkSha}`} className="font-mono hover:text-fg">{checkSha.slice(0, 7)}</Link>
846+ </div>
847+ <CommitChecksSummary checks={loaderData.checks} sha={checkSha} />
848+ {loaderData.serving && loaderData.serving.sha !== checkSha && (
849+ <p className="mt-2 text-xs text-muted">Currently serving <Link to={`${base}/commit/${loaderData.serving.sha}`} className="font-mono text-fg-soft hover:underline">{loaderData.serving.sha.slice(0, 7)}</Link> &middot; deployed <TimeAgo at={loaderData.serving.at} /></p>
850+ )}
851+ </div>
852+ )}
886853 {actionData && (
887854 <p className={`border-t border-line px-6 py-2.5 text-sm ${"error" in actionData ? "text-danger" : "text-success"}`}>
888855 {"error" in actionData ? actionData.error : actionData.notice}
949916 )}
950917 </Panel>
951918
919+ <DeliveryPanel loaderData={loaderData} base={base} />
920+ <WorkflowPanel loaderData={loaderData} base={base} />
921+
952922 {previews.length > 0 && (
953923 <Panel title="Previews" icon={<GitBranch size={14} />} count={previews.length} all={{ to: `${base}/deployments`, label: "Deployments" }}>
954924 <Card asChild divided className="overflow-hidden">
991961 )}
992962 {!loaderData.telemetryLoaded && <Unavailable what="Observability" compact />}
993963 {loaderData.telemetry && <ObservabilityCard overview={loaderData.telemetry.overview} errors={loaderData.telemetry.errors} base={base} />}
994− {project && <AboutCard project={project} canChange={canChange} base={base} release={loaderData.release} />}
964+
995965 <HealthCard loaderData={loaderData} base={base} plan={plan} />
996966 </aside>
997967 </div>
11371107 );
11381108 }
11391109
1140−/** What the project is: its description, links, kind and latest release. */
1141−function AboutCard({
1142− project,
1143− canChange,
1144− base,
1145− release,
1146−}: {
1147− project: NonNullable<Loaded["project"]>;
1148− canChange: boolean;
1149− base: string;
1150− release: Loaded["release"];
1151−}) {
1110+/** The same date range and metrics as the project's workspace Insights. */
1111+function DeliveryPanel({ loaderData: d, base }: { loaderData: Loaded; base: string }) {
1112+ if (!d.member) return null;
1113+ const metrics = d.insights?.projects[0];
1114+ const days = deliveryDays(d.range.from, d.range.until, d.insights?.days ?? []);
1115+ const peak = Math.max(1, ...days.map((day) => day.merged));
1116+ return (
1117+ <Panel title="Delivery" icon={<Activity size={14} />} extra={<span className="text-xs text-muted">Last 30 days</span>}
1118+ all={d.workspaceMember ? { to: `/${base.split("/")[1]}/-/insights?period=30d&project=${encodeURIComponent(base.split("/")[2])}`, label: "Insights" } : undefined}>
1119+ {!metrics ? <Unavailable what="Delivery insights" /> : (
1120+ <Card className="overflow-hidden">
1121+ <dl className="grid grid-cols-1 divide-y divide-line border-b border-line sm:grid-cols-3 sm:divide-x sm:divide-y-0">
1122+ {[
1123+ { label: "Pull requests merged", value: metrics.merged },
1124+ { label: "Average time to merge", value: deliveryDuration(metrics.merged ? metrics.leadSeconds / metrics.merged : null) },
1125+ { label: "Average first review", value: deliveryDuration(metrics.reviewed ? metrics.reviewSeconds / metrics.reviewed : null) },
1126+ ].map((stat) => <div key={stat.label} className="p-4"><dt className="text-xs text-muted">{stat.label}</dt><dd className="mt-2 text-xl font-semibold tabular-nums">{stat.value}</dd></div>)}
1127+ </dl>
1128+ <div className="p-4">
1129+ <div className="mb-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted"><span>Merged pull requests per day</span><span>{rangeLabel(d.range.from, d.range.until)} · UTC</span></div>
1130+ <div className="flex h-16 items-end gap-1" role="img" aria-label={`${metrics.merged} pull requests merged in the last 30 days`}>
1131+ {days.map((day) => <Hint key={day.day} label={`${day.day}: ${plural(day.merged, "merged pull request")}`}><span className={`min-w-0 flex-1 rounded-sm ${day.merged ? "bg-accent/70" : "bg-line"}`} style={{ height: `${Math.max(4, day.merged / peak * 100)}%` }} /></Hint>)}
1132+ </div>
1133+ {metrics.merged === 0 && <p className="mt-3 text-xs text-faint">No pull requests merged in this period. Direct pushes appear in workflows below.</p>}
1134+ </div>
1135+ <div className="flex flex-wrap gap-x-5 gap-y-2 border-t border-line px-4 py-3 text-xs text-muted">
1136+ <Link to={`${base}/pulls`} className="hover:text-fg">{metrics.active} active pull requests</Link>
1137+ <Link to={`${base}/pulls`} className="hover:text-fg">{metrics.unreviewed} awaiting first review</Link>
1138+ <Link to={`${base}/queue`} className="hover:text-fg">{metrics.queued} in merge queue</Link>
1139+ </div>
1140+ </Card>
1141+ )}
1142+ </Panel>
1143+ );
1144+}
1145+
1146+/** The latest sample, with real times, outcomes and links to each run. */
1147+function WorkflowPanel({ loaderData: d, base }: { loaderData: Loaded; base: string }) {
1148+ const runs = d.workflowRuns;
1149+ const oldest = runs?.at(-1);
11521150 return (
1153− <Card asChild className="p-5">
1154− <section>
1155− <div className="flex items-center justify-between">
1156− <h2 className="text-sm font-semibold">About</h2>
1157− {canChange && <AboutEditor project={project} />}
1158− </div>
1159− <p className={`mt-2.5 text-sm ${project.description ? "text-fg-soft" : "text-faint"}`}>{project.description ?? "No description."}</p>
1160− <LinkList links={project.links} className="mt-3" />
1161− <ul className="mt-4 space-y-2 text-xs text-muted">
1162− <li className="flex items-center gap-2">
1163− {project.kind === "library" || project.kind === "tool" ? (
1164− <PackageGlyph size={13} className="shrink-0 text-faint" />
1165− ) : project.kind === "docs" ? (
1166− <BookOpen size={13} className="shrink-0 text-faint" />
1167− ) : (
1168− <Rocket size={13} className="shrink-0 text-faint" />
1169− )}
1170− <Hint label={project.kindReason.detail}>
1171− <span>{kindLabel(project)}</span>
1172− </Hint>
1173− </li>
1174− {release && (
1175− <li className="flex items-center gap-2">
1176− <Tag size={13} className="shrink-0 text-faint" />
1177− <Link to={`${base}/tags`} className="hover:text-fg">
1178− <span className="font-mono text-fg-soft">{release.name}</span>
1179− {release.at && (
1180− <span className="text-faint">
1181− {" · "}
1182− <TimeAgo at={release.at} />
1183− </span>
1184− )}
1151+ <Panel title="Recent workflows" icon={<Activity size={14} />} all={{ to: `${base}/actions`, label: "All workflows" }}>
1152+ {runs == null ? <Unavailable what="Workflows" /> : runs.length === 0 ? <Quiet>No workflows have run yet.</Quiet> : (
1153+ <Card className="overflow-hidden">
1154+ <div className="space-y-3 border-b border-line p-4">
1155+ <div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted">
1156+ <span>Latest {plural(runs.length, "run")} · all branches</span>
1157+ <span>{oldest && <TimeAgo at={oldest.createdAt} />} → latest</span>
1158+ </div>
1159+ <div className="flex gap-1" aria-label="Workflow history, oldest first">
1160+ {[...runs].reverse().map((run) => <Hint key={run.id} label={`${run.name}: ${standingWord(run)} · ${run.createdAt}`}>
1161+ <Link to={`${base}/actions/runs/${run.id}`} aria-label={`${run.name}: ${standingWord(run)}`} className={`h-6 min-w-0 flex-1 rounded-sm transition-opacity hover:opacity-70 focus-visible:outline-2 focus-visible:outline-accent ${run.status !== "completed" ? "bg-warn/70 motion-safe:animate-pulse" : run.conclusion === "success" ? "bg-success/70" : run.conclusion === "failure" ? "bg-danger/70" : "bg-line"}`} />
1162+ </Hint>)}
1163+ </div>
1164+ <p className="text-xs text-muted">{d.health.checkRuns ? `${share(d.health.passRate)} succeeded across ${d.health.checkRuns} completed runs. Cancelled and skipped runs excluded.` : "No completed successful or failed runs in this sample."}</p>
1165+ </div>
1166+ <ul className="divide-y divide-line">
1167+ {runs.slice(0, 5).map((run) => <li key={run.id}>
1168+ <Link to={`${base}/actions/runs/${run.id}`} className="flex items-center gap-3 px-4 py-3 hover:bg-raised/40">
1169+ <StatusIcon status={run.status} conclusion={run.conclusion} />
1170+ <span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium">{run.title || run.name}</span><span className="block truncate text-xs text-muted">{run.name} · {shortRef(run.ref)} · {standingWord(run)}{run.attempt > 1 ? ` · attempt ${run.attempt}` : ""}</span></span>
1171+ <span className="shrink-0 text-right text-xs text-muted"><TimeAgo at={run.createdAt} />{run.startedAt && <Duration className="block" start={run.startedAt} end={run.finishedAt} />}</span>
11851172 </Link>
1186− </li>
1187− )}
1188− </ul>
1189− </section>
1190− </Card>
1173+ </li>)}
1174+ </ul>
1175+ </Card>
1176+ )}
1177+ </Panel>
11911178 );
11921179 }

Comments

Sign in to comment.