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.

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