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.

366 linesCodeBlameRaw
1import { Suspense, useEffect } from "react";
2import { Await, data } from "react-router";
3
4import { hasCodeAccess } from "@g1t/contracts";
5
6import type { Route } from "./+types/home";
7import {
8 AwayCard,
9 Card,
10 DeploysCard,
11 LandedCard,
12 NeedsList,
13 PeopleCard,
14 RowsSkeleton,
15 RunningList,
16 SpanSkeleton,
17 SpendCard,
18 SpendSkeleton,
19 StartHere,
20 WindowSwitch,
21} from "../../components/home";
22import { HomeAsk, WhatChangedCard } from "../../components/home-ask";
23import { Skeleton } from "../../components/ui/skeleton";
24import { sidebarOrNull } from "../../lib/chat.server";
25import {
26 type Attention,
27 type Away,
28 type Digest,
29 attention,
30 awayOf,
31 deploysIn,
32 digestOf,
33 landed,
34 landedDeploys,
35 landedFromActivity,
36 landedIn,
37 parseWindow,
38 pushRows,
39 settledIn,
40 withSessions,
41 withShipped,
42 running,
43 runningDeploys,
44 runningWorkflows,
45 spanFor,
46 within,
47 spanSentence,
48 waitingSentence,
49} from "../../lib/home";
50import {
51 actorKeys,
52 loadActivity,
53 loadChatActivity,
54 loadCodeWork,
55 loadDeploys,
56 loadDirectPushes,
57 loadLastVisit,
58 loadNames,
59 loadPullSessions,
60 loadRepos,
61 loadSettled,
62 loadShipped,
63 loadSpend,
64 loadWorkflowRuns,
65 reachFor,
66 repoRefs,
67} from "../../lib/home.server";
68import { readCookie } from "../../lib/mission";
69import { loadOnboarding } from "../../lib/onboarding.server";
70import { OnboardingOnPage } from "../../components/onboarding";
71import { loadMissionControl } from "../../lib/mission-control.server";
72import { page } from "../../lib/meta";
73import { inbox } from "../../lib/services.server";
74import { requireUser, roleIn } from "../../lib/session.server";
75import { homePagePath } from "../../lib/workspace-nav";
76
77export function meta({ params, ...args }: Route.MetaArgs) {
78 return page(args, { title: `Home · ${params.owner} · g1t` });
79}
80
81/** Unread notifications read for Needs you. */
82const NOTIFICATIONS_READ = 50;
83
84/**
85 * Home: the workspace's front page. First a box that asks g1t
86 * (components/home-ask: What changed summarizes the span on the page, and
87 * Explain on each change that landed asks about it); then where you're
88 * needed now, with no time window; what happened since you were last here (or over the last 24
89 * hours or 7 days, `?window=`): what people did, from the events
90 * service's digest of the span and chat's, then what landed; what is
91 * running; what the span cost. The last visit is kept by notify, per
92 * person and workspace, and marked by the page once it has been looked
93 * at (routes/workspace/home-seen.ts), so a refresh never wipes it. Only
94 * the last visit (for the span) is waited for; every section streams in
95 * on its own, and one whose service does not answer says so.
96 * Definitions: lib/home.ts and the Home guide.
97 */
98export async function loader({ params, context, request }: Route.LoaderArgs) {
99 const viewer = requireUser(context, request);
100 const slug = params.owner.toLowerCase();
101 if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
102 const membership = viewer.workspaces?.find((m) => m.slug === slug) ?? null;
103 const code = hasCodeAccess(membership);
104 const now = Date.now();
105 const tz = readCookie(request.headers.get("cookie"), "g1t_tz");
106 const url = new URL(request.url);
107 const windowKey = parseWindow(url.searchParams.get("window"));
108 // Getting started, for an owner: streamed, and opened by itself until dismissed (`?setup` opens it anyway).
109 const setupAsked = url.searchParams.has("setup");
110 const onboarding = loadOnboarding(viewer, slug, !setupAsked).catch(() => null);
111
112 // What needs you has no window: read at once, beside the last visit.
113 const missionP = code ? loadMissionControl(viewer, request, slug).then(({ value }) => value).catch(() => null) : Promise.resolve(null);
114 const chatP = sidebarOrNull(slug, viewer);
115 const notificationsP = inbox
116 .list(viewer, { unread: true, limit: NOTIFICATIONS_READ })
117 .then((result) => result.items)
118 .catch(() => null);
119 const lastSeenP = windowKey === "last" ? loadLastVisit(viewer, slug).catch(() => undefined) : Promise.resolve(null);
120
121 const waiting: Promise<Attention> = Promise.all([missionP, notificationsP, chatP])
122 .then(([mission, notifications, sidebar]) =>
123 attention({ slug, code, codeNeeds: mission?.needs ?? null, notifications, chat: sidebar?.entries ?? null }),
124 )
125 .catch(() => ({ rows: [], start: null, missing: ["Code", "Notifications", "Chat"] }));
126
127 const span = spanFor(windowKey, now, await lastSeenP, tz);
128
129 // What happened in the span: the repositories once, for every section that reads them.
130 const reposP = code ? loadRepos(viewer, slug).catch(() => null) : Promise.resolve(null);
131 const codeWorkP = code ? reposP.then((owned) => loadCodeWork(viewer, slug, reachFor(span), owned)).catch(() => null) : Promise.resolve("no_access" as const);
132 const deploysP = code ? loadDeploys(viewer, slug, span.from).catch(() => null) : Promise.resolve(null);
133 const activityP = reposP.then((owned) => loadActivity(slug, span, owned)).catch(() => null);
134 const chatActivityP = loadChatActivity(viewer, slug, span).catch(() => null);
135 // Each push to a default branch, with the commits it brought: the projects the digest says were pushed to.
136 const pushesP = code ? Promise.all([activityP, reposP]).then(([activity, owned]) => loadDirectPushes(viewer, activity, owned, span)).catch(() => null) : Promise.resolve(null);
137 const namesP = Promise.all([activityP, chatActivityP, deploysP, pushesP])
138 .then(([activity, chatActivity, deploys, pushes]) => loadNames([...actorKeys(activity, chatActivity, deploys), ...(pushes?.pushes ?? []).map((push) => push.actorKey)]))
139 .catch(() => ({}) as Record<string, string>);
140
141 // What people did, from events and chat.
142 const digest: Promise<Digest | null> = Promise.all([activityP, chatActivityP, namesP, reposP])
143 .then(([activity, chatActivity, names, owned]) => digestOf({ slug, repos: repoRefs(owned), activity, chat: chatActivity, names, code }))
144 .catch(() => null);
145 const happened = Promise.all([codeWorkP, deploysP, activityP, namesP, reposP, pushesP])
146 .then(async ([codeWork, deploys, activity, names, owned, pushes]) => {
147 const deployRows = deploys ? deploysIn(deploys.projects, slug, span, names) : null;
148 const merges = codeWork && codeWork !== "no_access" ? landedIn(codeWork.pulls, span) : null;
149 // Each push on its own where its project's pushes were read; the digest's one row a project elsewhere.
150 const named = (key: string) => (key ? (names[key] ?? "someone") : "an import");
151 const each = pushes ? pushRows(pushes.pushes.map((push) => ({ ...push, by: named(push.actorKey) })), pushes.history, span) : [];
152 const fromActivity = landedFromActivity(activity, repoRefs(owned), names, slug).filter((row) => !(row.kind === "push" && pushes?.history[row.key.slice("push:".length)]));
153 const all = merges == null && activity == null ? null : landed([merges ?? [], each, fromActivity, landedDeploys(deployRows ?? [])]);
154 // Whether each merge and push is live, and which pull requests a harness session made: read together.
155 const [shipped, sessions] = all
156 ? await Promise.all([
157 loadShipped(viewer, slug, all).catch(() => null),
158 codeWork && codeWork !== "no_access" ? loadPullSessions(viewer, codeWork.pulls.filter((pull) => pull.status === "merged" && within(pull.mergedAt, span))).catch(() => null) : null,
159 ])
160 : [null, null];
161 return {
162 // What landed: merges, direct pushes, releases and packages, production builds; null when nothing of Code could be read.
163 landed: all ? withSessions(withShipped(all, shipped), sessions) : null,
164 landedMissing: [
165 ...(code && merges == null ? ["Pull requests"] : []),
166 ...(code && activity == null ? ["Pushes and releases"] : []),
167 ...(code && deployRows == null ? ["Deployments"] : []),
168 ],
169 deploys: deployRows,
170 deploysComplete: deploys?.complete ?? true,
171 };
172 })
173 .catch(() => null);
174 // What g1t settled for you in the span, what shipped, and what still needs you.
175 const settledP = loadSettled(viewer, span).catch(() => null);
176 const away: Promise<Away | null> = Promise.all([settledP, happened, waiting])
177 .then(([settled, value, att]) => (settled == null ? null : awayOf({ settled: settledIn(settled, slug, code, span), landed: value?.landed ?? null, needs: att.rows.length })))
178 .catch(() => null);
179 const workflowsP = code ? Promise.all([activityP, reposP]).then(([activity, owned]) => loadWorkflowRuns(viewer, activity, owned)).catch(() => null) : Promise.resolve(null);
180 const runningNow = Promise.all([deploysP, workflowsP])
181 .then(([deploys, workflows]) => ({
182 rows: running([runningWorkflows(workflows?.projects ?? []), runningDeploys(deploys?.overview ?? [], slug)]),
183 missing: [...(code && (!workflows || !workflows.complete) ? ["Workflows"] : []), ...(code && !deploys ? ["Deployments"] : [])],
184 }))
185 .catch(() => ({ rows: [], missing: ["Workflows", "Deployments"] }));
186 const spend = loadSpend(viewer, slug, span).catch(() => null);
187
188 return {
189 slug,
190 code,
191 now,
192 tz,
193 span,
194 digest,
195 happened,
196 attention: waiting,
197 away,
198 running: runningNow,
199 /** The sentence under the heading needs the digest, what landed and what waits. */
200 head: Promise.all([digest, happened, waiting]),
201 spend,
202 onboarding,
203 setupAsked,
204 };
205}
206
207/** How long the page must have been in view before it counts as a visit. */
208const SEEN_AFTER_MS = 5_000;
209
210/**
211 * Marks this visit once the page has been in view for a few seconds, with
212 * the time it loaded: what happened after that shows next time. Never on
213 * first paint, so a quick refresh keeps what was not looked at yet.
214 */
215function useMarkSeen(slug: string, loadedAt: number) {
216 useEffect(() => {
217 let timer: ReturnType<typeof setTimeout> | null = null;
218 let done = false;
219 const mark = () => {
220 if (done) return;
221 done = true;
222 void fetch(`${homePagePath(slug)}/seen`, {
223 method: "POST",
224 headers: { "content-type": "application/json" },
225 body: JSON.stringify({ at: loadedAt }),
226 keepalive: true,
227 }).catch(() => undefined);
228 };
229 const arm = () => {
230 if (timer) clearTimeout(timer);
231 timer = document.visibilityState === "visible" ? setTimeout(mark, SEEN_AFTER_MS) : null;
232 };
233 arm();
234 document.addEventListener("visibilitychange", arm);
235 return () => {
236 document.removeEventListener("visibilitychange", arm);
237 if (timer) clearTimeout(timer);
238 };
239 }, [slug, loadedAt]);
240}
241
242export default function Home({ loaderData }: Route.ComponentProps) {
243 const { slug, now, tz, span, digest, happened, attention: waiting, away, running: runningNow, head, spend } = loaderData;
244 useMarkSeen(slug, now);
245 return (
246 <div className="mx-auto w-full max-w-260 space-y-5 px-4 py-5 md:px-10 md:py-8">
247 <OnboardingOnPage slug={slug} view={loaderData.onboarding} asked={loaderData.setupAsked} />
248 {/* 0. Ask g1t anything: the answer opens in the top bar's Ask panel. */}
249 <header className="space-y-3">
250 <HomeAsk span={span} />
251 <p className="mx-auto max-w-2xl text-center text-sm leading-relaxed text-muted">
252 <Suspense fallback={<Skeleton className="inline-block h-3.5 w-72 align-middle" />}>
253 <Await resolve={head}>
254 {([digest, happened, att]) => (
255 <>
256 <span className="text-fg-soft">{waitingSentence(att.rows.length)}</span> {spanSentence(span, digest, happened?.landed?.length ?? null)}
257 </>
258 )}
259 </Await>
260 </Suspense>
261 </p>
262 </header>
263
264 {/* 1. Where you're needed now: no window, first and biggest. Start here beside it on wide screens, under it on phones. */}
265 <div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
266 <div id="needed" className="scroll-mt-4 lg:col-start-1 lg:row-start-1">
267 <Card
268 title="Where you're needed"
269 hint="Everything waiting on you, however long ago it started"
270 link={{ label: "Notifications", to: "/notifications" }}
271 >
272 <Suspense fallback={<RowsSkeleton rows={4} />}>
273 <Await resolve={waiting}>{(att) => <NeedsList attention={att} now={now} />}</Await>
274 </Suspense>
275 </Card>
276 </div>
277 <div className="lg:col-start-2 lg:row-start-1">
278 <Suspense
279 fallback={
280 <Card title="Start here">
281 <RowsSkeleton rows={1} />
282 </Card>
283 }
284 >
285 <Await resolve={waiting}>{(att) => <StartHere attention={att} now={now} />}</Await>
286 </Suspense>
287 </div>
288 </div>
289
290 {/* 2. What happened in the span. */}
291 <section aria-labelledby="since" className="space-y-4">
292 <div className="flex flex-wrap items-center gap-x-3 gap-y-2 pt-2">
293 <div className="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1">
294 <h2 id="since" className="text-lg font-semibold tracking-tight text-fg">
295 {span.key === "last" && span.note == null ? "Since you were last here" : span.words}
296 </h2>
297 <p className="text-sm text-muted">
298 {span.key === "last" && span.note == null ? `${span.words} · ${span.length}` : spanNote(span.note)}
299 </p>
300 </div>
301 <div className="sm:ml-auto">
302 <WindowSwitch span={span} />
303 </div>
304 </div>
305 <WhatChangedCard key={span.key} slug={slug} span={span} tz={tz} />
306 <Suspense
307 fallback={
308 <Card title={span.key === "last" && span.note == null ? "While you were away" : "Settled for you"}>
309 <RowsSkeleton rows={2} />
310 </Card>
311 }
312 >
313 <Await resolve={away}>{(value) => <AwayCard away={value} span={span} now={now} />}</Await>
314 </Suspense>
315 <Suspense fallback={<SpanSkeleton />}>
316 <Await resolve={digest}>{(value) => <PeopleCard digest={value} span={span} code={loaderData.code} />}</Await>
317 </Suspense>
318 <div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
319 <div className="space-y-5">
320 <Suspense
321 fallback={
322 <Card title="Landed">
323 <RowsSkeleton rows={3} />
324 </Card>
325 }
326 >
327 <Await resolve={happened}>
328 {(value) => <LandedCard landed={value?.landed ?? null} missing={value?.landedMissing ?? []} code={loaderData.code} now={now} tz={tz} byDay={span.key === "7d"} />}
329 </Await>
330 </Suspense>
331 <Suspense fallback={null}>
332 <Await resolve={happened}>
333 {(value) => <DeploysCard deploys={value?.deploys ?? null} complete={value?.deploysComplete ?? true} code={loaderData.code} now={now} tz={tz} />}
334 </Await>
335 </Suspense>
336 </div>
337 <div className="space-y-5">
338 <Suspense
339 fallback={
340 <Card title="Spent">
341 <SpendSkeleton />
342 </Card>
343 }
344 >
345 <Await resolve={spend}>{(value) => <SpendCard spend={value} slug={slug} span={span} />}</Await>
346 </Suspense>
347 </div>
348 </div>
349 </section>
350
351 {/* 3. Running now. */}
352 <Card title="Running now">
353 <Suspense fallback={<RowsSkeleton rows={2} />}>
354 <Await resolve={runningNow}>{(value) => <RunningList rows={value.rows} missing={value.missing} now={now} />}</Await>
355 </Suspense>
356 </Card>
357 </div>
358 );
359}
360
361function spanNote(note: "first" | "capped" | "unknown" | null): string {
362 if (note === "first") return "Your first visit here, so this is the last 24 hours.";
363 if (note === "capped") return "You were away longer than that; this is as far back as Home looks.";
364 if (note === "unknown") return "Your last visit couldn't be read, so this is the last 24 hours.";
365 return "";
366}