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.

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