Skip to content

g1t/apps/web/app/routes/home.tsx

800 lines34,686 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import { Suspense, lazy } from "react";
3import { type ShouldRevalidateFunctionArgs, data, redirect } from "react-router";
4
5import {
6 type Confidence,
7 type G1tEvent,
8 type Lifecycle,
9 type Pull,
10 type Repo,
11 type RepoPath,
12 type Viewer,
13 REPO_ROLE_LABELS,
14 isActiveRun,
15 workOwner,
16} from "@g1t/contracts";
17
18import type { Route } from "./+types/home";
19import { page } from "../lib/meta";
20import { WORKSPACE_COOKIE, chosenWorkspace } from "../lib/workspace-choice";
21import {
22 type ActivityItem,
23 type Need,
24 TIME,
25 actorIds,
26 agentHours,
27 dailyBuckets,
28 eventItem,
29 greetingFor,
30 groupActivity,
31 hourIn,
32 isAgent,
33 nameActor,
34 rankNeeds,
35 readCookie,
36 stuckMinutes,
37 waitedFor,
38} from "../lib/mission";
39import {
40 type Fact,
41 type Merged,
42 pushedCommits,
43 type NeedRow,
44 type QuickAction,
45 RUN_LABEL,
46 confidenceAsk,
47 dateLine,
48 dayKey,
49 landedToday,
50 needPathKey,
51 pullFacts,
52 reachesBack,
53 reasonFor,
54 summaryLine,
55 usd,
56 waitingRows,
57 weekOf,
58 who,
59 whyFor,
60 withConfidence,
61} from "../lib/mission-control";
62import { type NotStarted, chosenRepo, delegateForm, issuePath, notStarted } from "../lib/delegate";
63import { needsYou } from "../lib/inbox";
64import { Landing } from "../components/landing";
65import { Skeleton, SkeletonCard, SkeletonLine, SkeletonStat } from "../components/ui/skeleton";
66import {
67 agents,
68 billing,
69 deployments,
70 events as eventLog,
71 identity,
72 inbox,
73 projects as projectsApi,
74 repos as reposApi,
75 work,
76} from "../lib/services.server";
77import { assertSameOrigin, getViewer, requireUser, roleIn } from "../lib/session.server";
78import { runners } from "../lib/runners.server";
79import { readableRepos } from "../lib/access.server";
80
81/** The viewer's time zone, which mission control sets, so the greeting fits their day. */
82const TZ_COOKIE = "g1t_tz";
83/** The most projects whose pull requests and events are read for the page. */
84const MAX_PROJECTS = 10;
85const EVENTS_PER_PROJECT = 80;
86/** How many pull requests work lists at once (`LIST_PAGE` in services/work). */
87const PULL_PAGE = 100;
88/** Unread inbox items read for the Needs you card, and how many it shows. */
89const INBOX_READ = 50;
90const INBOX_SHOWN = 4;
91
92export function meta(args: Route.MetaArgs) {
93 // Someone signed in is on mission control; visitors get the landing page's title.
94 if (args.loaderData?.signedIn) return page(args, { title: "Mission control · g1t" });
95 return page(args, {
96 title: "g1t — where people and agents ship software together",
97 description:
98 "The open-source git platform for people and agents: issues, pull requests and review, agents you assign like teammates, a merge queue that keeps main green, and deployments to the edge. Priced at cost plus 20%, never per seat.",
99 });
100}
101
102export function headers({ loaderHeaders }: Route.HeadersArgs) {
103 const out = new Headers();
104 const timing = loaderHeaders.get("Server-Timing");
105 if (timing) out.set("Server-Timing", timing);
106 const cookie = loaderHeaders.get("Set-Cookie");
107 if (cookie) out.set("Set-Cookie", cookie);
108 return out;
109}
110
111/**
112 * Switching tabs, the sort or the activity list changes only the address:
113 * everything is already loaded. Marking an inbox item read or done changes
114 * nothing else here: the card steps out by itself, and the bell's count
115 * comes with the shell (root.tsx). A refresh, a form, or anything else
116 * loads again as usual.
117 */
118export function shouldRevalidate({ currentUrl, nextUrl, formMethod, formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
119 if (!formMethod && currentUrl.pathname === nextUrl.pathname && currentUrl.search !== nextUrl.search) return false;
120 if (formAction === "/inbox") return false;
121 return defaultShouldRevalidate;
122}
123
124/** Where a need came from, so its row can say what is known about it. */
125type Extra = Partial<Pick<NeedRow, "repo" | "ref" | "by" | "for" | "facts" | "quick" | "link" | "open">> & {
126 /** How sure g1t is of the agent's change, for a pull request. */
127 confidence?: Confidence | null;
128};
129
130/** What the composer said back, when the issue opened but the agent did not start, or nothing opened. */
131export type DelegateResult = { error: string; notStarted: null } | { error: null; notStarted: NotStarted };
132
133/**
134 * "Put an agent on it": opens an issue in one of the viewer's projects and
135 * puts g1t on it, then lands on the issue. When the agent could not
136 * start, the issue is still open, and the composer says why and where to
137 * fix it.
138 */
139export async function action({ request, context }: Route.ActionArgs) {
140 assertSameOrigin(request);
141 const user = requireUser(context, request);
142 const form = await request.formData();
143 if (form.get("intent") !== "delegate") return data<DelegateResult>({ error: "Nothing to do.", notStarted: null }, { status: 400 });
144 const projects = await reposApi.list(user, { memberOnly: true }).catch(() => []);
145 const repo = chosenRepo(form.get("repo"), projects.map((repo) => ({ namespace: repo.namespace, name: repo.name })));
146 if (!repo) return data<DelegateResult>({ error: "Choose one of your projects.", notStarted: null }, { status: 400 });
147 const result = await env.RUNNER.delegate(user, repo, delegateForm(form));
148 if (!result.ok) return data<DelegateResult>({ error: result.error.message, notStarted: null }, { status: 400 });
149 const { issue, agent } = result.value;
150 const refused = notStarted(agent, repo, issue.number);
151 if (!refused) throw redirect(issuePath(repo, issue.number));
152 return data<DelegateResult>({ error: null, notStarted: refused });
153}
154
155/** Pushes to the default branch read for the week, per project, and commits read back from each. */
156const PUSHES_READ = 40;
157const PUSH_DEPTH = 60;
158
159/**
160 * The commits people pushed straight to a project's default branch in the
161 * last two weeks, each with when its push landed. Merges and agents'
162 * commits are left out: pull requests count those.
163 */
164async function directCommits(repo: Repo, viewer: Viewer): Promise<{ hash: string; at: string }[]> {
165 const since = Date.now() - 14 * TIME.DAY;
166 const pushes = (await eventLog.list({ repoId: repo.id, types: ["git.push"], limit: PUSHES_READ })).filter(
167 (event): event is G1tEvent<"git.push"> => event.type === "git.push" && event.data.defaultBranch && Date.parse(event.time) >= since,
168 );
169 const path = { namespace: repo.namespace, name: repo.name };
170 const read = await Promise.all(
171 pushes.map(async (push) => {
172 const history = await reposApi.log(path, viewer, push.data.after, PUSH_DEPTH).catch(() => null);
173 return history?.ok ? pushedCommits(history.value, push.data.before).map((commit) => ({ hash: commit.hash, at: push.time })) : [];
174 }),
175 );
176 // A commit pushed twice (after a force push, say) counts once, at its first landing.
177 const seen = new Map<string, string>();
178 for (const commit of read.flat().reverse()) if (!seen.has(commit.hash)) seen.set(commit.hash, commit.at);
179 return [...seen].map(([hash, at]) => ({ hash, at }));
180}
181
182export async function loader({ context, request }: Route.LoaderArgs) {
183 const viewer = getViewer(context);
184 if (!viewer) {
185 // The signed-out home page is about signing up; it lists no repositories.
186 return data({ signedIn: false as const });
187 }
188
189 const now = Date.now();
190 const weekAgo = now - 7 * TIME.DAY;
191 const started = Date.now();
192 const times: Record<string, number> = {};
193 // Each call is timed, and one that fails leaves its section empty rather
194 // than taking the page down.
195 const soft = <T,>(name: string, promise: Promise<T>): Promise<T | null> =>
196 promise.then(
197 (value) => ((times[name] = Date.now() - started), value),
198 (error) => {
199 console.warn(`home: ${name} failed`, error);
200 times[name] = Date.now() - started;
201 return null;
202 },
203 );
204 const okOr = <T,>(result: { ok: true; value: T } | { ok: false } | null): T | null => (result?.ok ? result.value : null);
205
206 const cookies = request.headers.get("cookie");
207 const tz = readCookie(cookies, TZ_COOKIE);
208 const memberships = viewer.workspaces ?? [];
209 // The workspace you chose, as the sidebar shows it (lib/workspace-choice.ts).
210 const slug = chosenWorkspace(memberships, readCookie(cookies, WORKSPACE_COOKIE))?.slug ?? null;
211 const username = viewer.username;
212
213 const reposP = soft("repos", reposApi.list(viewer, { memberOnly: true }));
214 // Workflow jobs stuck waiting for a self-hosted runner that is not there.
215 const stuckJobsP = soft("stuck_jobs", runners.stuck(viewer));
216 // The chosen workspace's projects: their open and recently merged pull
217 // requests, all in one call to work, and their recent events, read as
218 // soon as the projects are known.
219 const perRepoP = reposP.then(async (repos) => {
220 const chosen = (repos ?? [])
221 .filter((repo) => slug != null && repo.namespace.toLowerCase() === slug.toLowerCase())
222 .slice(0, MAX_PROJECTS);
223 const [batch, logs, pushes] = await Promise.all([
224 work.pullsForRepos(chosen.map((repo) => repo.id), viewer, PULL_PAGE).catch(() => []),
225 Promise.all(chosen.map((repo) => eventLog.list({ repoId: repo.id, limit: EVENTS_PER_PROJECT }).catch(() => null))),
226 // People's pushes straight to the default branch, which no pull
227 // request counts: the commits each brought, for the week.
228 Promise.all(chosen.map((repo) => directCommits(repo, viewer).catch(() => []))),
229 ]);
230 const byId = new Map(batch.map((entry) => [entry.repoId, entry]));
231 return Promise.all(
232 chosen.map(async (repo, index) => {
233 const found = byId.get(repo.id);
234 if (found) return { repo, pulls: found.open.slice(0, 60), closed: found.closed, events: logs[index], direct: pushes[index] ?? [] };
235 // A fork, which the batch leaves out: asked on its own.
236 const path = { namespace: repo.namespace, name: repo.name };
237 const [pulls, closed] = await Promise.all([
238 work.listPulls(path, viewer, "open").catch(() => null),
239 work.listPulls(path, viewer, "closed").catch(() => null),
240 ]);
241 return {
242 repo,
243 pulls: pulls?.ok ? pulls.value.slice(0, 60) : null,
244 closed: closed?.ok ? closed.value : null,
245 events: logs[index],
246 direct: pushes[index] ?? [],
247 };
248 }),
249 );
250 });
251
252 const [repos, perRepo, active, models, profile, runs, overview, usage, projectList, memories, invitations, tokens, myTokens, unread] = await Promise.all([
253 reposP,
254 soft("projects", perRepoP),
255 soft("pulls", work.listActivePulls(viewer)),
256 slug ? soft("models", env.RUNNER.modelAccess(slug)) : null,
257 soft("profile", identity.profile(username)),
258 slug ? soft("runs", agents.listRuns(viewer, { workspace: slug, limit: 150 })) : null,
259 slug ? soft("deploys", deployments.overview(slug, viewer)) : null,
260 slug ? soft("usage", billing.usage(slug, viewer, new Date(weekAgo - TIME.DAY).toISOString())) : null,
261 slug ? soft("projectList", projectsApi.list(slug, viewer)) : null,
262 slug ? soft("memories", agents.listMemories(viewer, slug, null)) : null,
263 // Repositories someone has invited the viewer to.
264 soft("invitations", identity.myRepoInvitations(viewer)),
265 // Model tokens over the last six weeks: the workspace's, and yours.
266 slug ? soft("tokens", billing.tokenUsage(slug, viewer)) : null,
267 slug ? soft("my_tokens", billing.tokenUsage(slug, viewer, { person: username })) : null,
268 // What in their inbox is unread: an agent waiting on them, or a failure, goes on the Needs you card.
269 soft("inbox", inbox.list(viewer, { unread: true, limit: INBOX_READ })),
270 ]);
271
272 const repoList = repos ?? [];
273 // Each pull request is shown under its repository. Most are in the
274 // viewer's own, already listed; the rest are looked up once each.
275 const known = new Map<string, Repo>(repoList.map((repo) => [repo.id, repo]));
276 const missing = [...new Set((active ?? []).map(({ pull }) => pull.repoId))].filter((id) => !known.has(id));
277 for (const repo of await readableRepos(missing, viewer)) known.set(repo.id, repo);
278 const pathOf = (repo: Repo): RepoPath => ({ namespace: repo.namespace, name: repo.name });
279 const inWorkspace = (repo: RepoPath) => slug != null && repo.namespace.toLowerCase() === slug.toLowerCase();
280
281 const activeList = (active ?? []).flatMap((item) => {
282 const repo = known.get(item.pull.repoId);
283 return repo ? [{ ...item, repo }] : [];
284 });
285 const runList = okOr(runs ?? null) ?? [];
286 const liveRuns = runList.filter((run) => isActiveRun(run.status));
287 const overviewList = okOr(overview ?? null);
288 const projectsOk = okOr(projectList ?? null);
289 const runsOn = (repo: RepoPath, number: number) =>
290 runList.filter(
291 (run) => run.number === number && `${run.repo.namespace}/${run.repo.name}`.toLowerCase() === `${repo.namespace}/${repo.name}`.toLowerCase(),
292 );
293
294 const lower = username.toLowerCase();
295 const openPulls = (perRepo ?? []).flatMap(({ repo, pulls }) => (pulls ?? []).map((pull) => ({ pull, repo })));
296 // Never your own, nor one g1t made for you.
297 const reviewRequested = openPulls.filter(
298 ({ pull }) =>
299 pull.status === "open" &&
300 workOwner(pull).username.toLowerCase() !== lower &&
301 pull.reviewers.some((name) => name.toLowerCase() === lower),
302 );
303
304 // --- Needs you ------------------------------------------------------------
305 const needs: Need[] = [];
306 const extras = new Map<string, Extra>();
307 /** What every pull request's row shares: where it is, who is on it, what is known. */
308 const pullExtra = (pull: Pull, repo: Repo, lifecycle: Lifecycle | null): Extra => {
309 const base = `/${repo.namespace}/${repo.name}`;
310 const agentWork = isAgent(pull.agent);
311 return {
312 repo: pathOf(repo),
313 ref: `#${pull.number}`,
314 by: agentWork ? who(pull.agent) : who(pull.author.username),
315 for: agentWork ? workOwner(pull).username : null,
316 facts: pullFacts({
317 checkStatus: pull.checkStatus,
318 files: pull.files,
319 lifecycle,
320 runs: runsOn(repo, pull.number),
321 confidence: pull.confidence,
322 }),
323 open: pull.issue != null ? `${base}/issues/${pull.issue}` : `${base}/pull/${pull.number}?tab=changes`,
324 confidence: pull.confidence ?? null,
325 };
326 };
327 const pullAction = (repo: Repo, pull: Pull, quick: Omit<QuickAction, "to">): QuickAction => ({
328 ...quick,
329 to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
330 });
331 const approve = (repo: Repo, pull: Pull) =>
332 pullAction(repo, pull, {
333 label: isAgent(pull.agent) ? "Approve the agent's change" : "Approve the change",
334 fields: { action: "comment", verdict: "approve", body: "" },
335 done: "Approved",
336 });
337
338 for (const invitation of invitations ?? []) {
339 if (invitation.status !== "pending") continue;
340 const key = `invitation:${invitation.id}`;
341 needs.push({
342 key,
343 kind: "invitation",
344 title: `${invitation.invited_by ?? "Someone"} invited you to ${invitation.repo}`,
345 detail: `With the ${REPO_ROLE_LABELS[invitation.role]} role. The invitation expires ${new Date(invitation.expires_at).toISOString().slice(0, 10)}.`,
346 to: `/${invitation.repo}/invitations`,
347 action: "Respond",
348 at: Date.parse(invitation.created_at),
349 where: invitation.repo,
350 });
351 const [namespace, name] = invitation.repo.split("/");
352 extras.set(key, {
353 repo: namespace && name ? { namespace, name } : null,
354 by: who(invitation.invited_by),
355 facts: [
356 { label: "Role", value: REPO_ROLE_LABELS[invitation.role], tone: null },
357 { label: "Expires", value: new Date(invitation.expires_at).toISOString().slice(0, 10), tone: null },
358 ],
359 });
360 }
361 for (const entry of overviewList ?? []) {
362 const latest = entry.latest;
363 if (latest?.kind === "production" && latest.status === "failed" && slug) {
364 const key = `deploy:${entry.slug}`;
365 const to = `/${slug}/${entry.slug}/deployments/${latest.id}`;
366 needs.push({
367 key,
368 kind: "deploy",
369 title: `Production build of ${entry.slug} failed`,
370 detail: latest.error ?? "The last build of the default branch failed. Production still serves the build before it.",
371 to,
372 action: "See the build",
373 at: Date.parse(latest.finishedAt ?? latest.createdAt),
374 where: `${slug}/${entry.slug}`,
375 });
376 const facts: Fact[] = [
377 { label: "Commit", value: latest.commit.slice(0, 7), tone: null },
378 {
379 label: "Production",
380 value: entry.production ? "Serving the build before" : "Not live yet",
381 tone: entry.production ? "good" : "warn",
382 },
383 ];
384 extras.set(key, {
385 repo: { namespace: slug, name: entry.slug },
386 by: who(latest.createdBy),
387 facts,
388 link: { label: "Deployment settings", to: `/${slug}/${entry.slug}/settings/deployments` },
389 });
390 }
391 }
392 for (const { pull, lifecycle, repo } of activeList) {
393 const where = `${repo.namespace}/${repo.name}#${pull.number}`;
394 const to = `/${repo.namespace}/${repo.name}/pull/${pull.number}`;
395 const key = `pull:${pull.id}`;
396 const extra = pullExtra(pull, repo, lifecycle);
397 // Held for its low confidence: the ask says so in a sentence of its own.
398 const lowConfidence = pull.confidence?.level === "low" ? pull.confidence : null;
399 if (lifecycle?.stage === "needs_you") {
400 const held = lowConfidence != null && /confidence in this change is low/i.test(lifecycle.detail);
401 const conflict = !held && /conflict/i.test(lifecycle.detail);
402 const need: Need = {
403 key,
404 kind: conflict ? "conflict" : "stalled",
405 title: pull.title,
406 detail: held && lowConfidence ? confidenceAsk(lowConfidence) : lifecycle.detail,
407 to,
408 action: conflict ? "Resolve" : "Decide",
409 at: Date.parse(pull.updatedAt),
410 where,
411 };
412 needs.push(need);
413 const reason = held ? "low_confidence" : reasonFor(need);
414 extras.set(key, {
415 ...extra,
416 quick:
417 reason === "needs_review" || reason === "low_confidence"
418 ? approve(repo, pull)
419 : /required checks? .*still fails?\b/i.test(lifecycle.detail)
420 ? pullAction(repo, pull, { label: "Re-run failed jobs", fields: { action: "rerun-failed" }, done: "Re-running" })
421 : null,
422 link: reason === "outside_guardrails" ? { label: "Raise the cap", to: `/${repo.namespace}/${repo.name}/settings/guardrails` } : null,
423 });
424 } else if (lifecycle?.stage === "ready") {
425 needs.push({
426 key,
427 kind: "ready",
428 title: pull.title,
429 detail: lowConfidence ? confidenceAsk(lowConfidence) : "Its required checks passed and it was approved. It lands when you merge it.",
430 to,
431 action: "Merge",
432 at: Date.parse(pull.updatedAt),
433 where,
434 });
435 extras.set(key, {
436 ...extra,
437 quick: pullAction(repo, pull, {
438 label: isAgent(pull.agent) ? "Merge the agent's change" : "Merge it",
439 fields: { action: "merge" },
440 done: "Merging",
441 }),
442 });
443 } else if (!lifecycle && pull.status === "open" && pull.checkStatus === "failed") {
444 // Taken out of the merge queue: its change failed combined with what was ahead.
445 needs.push({ key, kind: "checks", title: pull.title, detail: "It failed in the merge queue. Push a fix, then merge it again.", to, action: "See checks", at: Date.parse(pull.updatedAt), where });
446 extras.set(key, { ...extra, quick: null });
447 }
448 }
449 for (const { pull, repo } of reviewRequested) {
450 const key = `review:${pull.id}`;
451 needs.push({
452 key,
453 kind: "review",
454 title: pull.title,
455 detail: `${workOwner(pull).username} asked for your review.`,
456 to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
457 action: "Review",
458 at: Date.parse(pull.updatedAt),
459 where: `${repo.namespace}/${repo.name}#${pull.number}`,
460 });
461 extras.set(key, { ...pullExtra(pull, repo, null), quick: approve(repo, pull) });
462 }
463 for (const job of (await stuckJobsP) ?? []) {
464 const key = `runner:${job.id}`;
465 const minutes = Math.max(10, Math.round((now - Date.parse(job.queuedAt)) / 60_000));
466 const [namespace, name] = job.repo.split("/");
467 needs.push({
468 key,
469 kind: "runner",
470 title: `${job.name} is waiting for a self-hosted runner`,
471 detail: `No runner with labels ${job.labels} is online. Start one, or change the job's runs-on.`,
472 to: `/${job.repo}/actions/runs/${job.runId}`,
473 action: "Look",
474 at: Date.parse(job.queuedAt),
475 where: job.repo,
476 });
477 extras.set(key, {
478 repo: namespace && name ? { namespace, name } : undefined,
479 facts: [
480 { label: "Labels", value: job.labels, tone: null },
481 { label: "Waiting", value: waitedFor(minutes), tone: "warn" },
482 ],
483 // Runners are the owners' to see to.
484 ...(namespace && roleIn(viewer, namespace) === "owner" ? { link: { label: "Runners", to: `/${namespace}/-/runners` } } : {}),
485 });
486 }
487 for (const run of liveRuns) {
488 const minutes = stuckMinutes(run, now);
489 if (minutes == null) continue;
490 const key = `run:${run.id}`;
491 needs.push({
492 key,
493 kind: "stuck",
494 title: run.title ?? `${run.agent}'s run`,
495 detail: `${run.agent} has reported nothing for ${waitedFor(minutes)}${run.step ? `. Last: ${run.step}` : ""}.`,
496 to: `/${run.repo.namespace}/${run.repo.name}/agents/runs/${run.id}`,
497 action: "Look",
498 at: Date.parse(run.updatedAt),
499 where: `${run.repo.namespace}/${run.repo.name}${run.number != null ? `#${run.number}` : ""}`,
500 });
501 extras.set(key, {
502 repo: run.repo,
503 ref: run.number != null ? `#${run.number}` : null,
504 by: who(run.agent),
505 facts: [
506 { label: "Run", value: RUN_LABEL[run.kind], tone: null },
507 { label: "Quiet for", value: waitedFor(minutes), tone: "warn" },
508 { label: "Steps so far", value: String(run.stepCount), tone: null },
509 ...(run.costUsd != null ? [{ label: "Cost so far", value: usd(run.costUsd), tone: null }] : []),
510 ],
511 open: run.number != null ? `/${run.repo.namespace}/${run.repo.name}/pull/${run.number}` : undefined,
512 });
513 }
514
515 const needRows: NeedRow[] = rankNeeds(needs).map((need) => {
516 const extra = extras.get(need.key) ?? {};
517 const reason = withConfidence(reasonFor(need), extra.confidence);
518 return {
519 key: need.key,
520 reason,
521 repo: extra.repo ?? null,
522 ref: extra.ref ?? null,
523 title: need.title,
524 ask: need.detail,
525 by: extra.by ?? null,
526 for: extra.for ?? null,
527 at: need.at,
528 to: need.to,
529 open: extra.open ?? need.to,
530 facts: extra.facts ?? [],
531 why: whyFor(reason, need, extra.confidence),
532 quick: extra.quick ?? null,
533 link: extra.link ?? null,
534 };
535 });
536
537 // --- Waiting on agents ----------------------------------------------------
538 const needKeys = new Set(
539 needRows.flatMap((row) => (row.repo && row.ref ? [needPathKey(row.repo, Number(row.ref.slice(1)))] : [])),
540 );
541 const waiting = waitingRows({
542 active: activeList.filter(({ repo }) => inWorkspace(repo)).map(({ pull, lifecycle, repo }) => ({ pull, lifecycle, repo: pathOf(repo) })),
543 live: liveRuns,
544 drafts: openPulls.filter(({ pull }) => pull.status === "draft").map(({ pull, repo }) => ({ ...pull, repo: pathOf(repo) })),
545 needKeys,
546 });
547
548 // --- Landed ---------------------------------------------------------------
549 const merged: Merged[] = (perRepo ?? []).flatMap(({ repo, closed }) =>
550 (closed ?? []).flatMap((pull) =>
551 pull.status === "merged" && pull.mergedAt
552 ? [
553 {
554 repo: pathOf(repo),
555 number: pull.number,
556 title: pull.title,
557 agent: pull.agent,
558 // Who wrote it, not who merged it: a person's change that g1t
559 // auto-merged is still theirs.
560 authoredByAgent: pull.author.kind === "agent" || isAgent(pull.author.username),
561 mergedBy: pull.mergedBy,
562 mergedAt: pull.mergedAt,
563 files: pull.files,
564 },
565 ]
566 : [],
567 ),
568 );
569 const twoWeeksAgo = now - 14 * TIME.DAY;
570 const complete = perRepo != null && perRepo.every(({ closed }) => closed != null && reachesBack(closed, twoWeeksAgo, PULL_PAGE));
571 // A person's commits pushed straight to the default branch are their own
572 // changes too, on the day they landed.
573 const direct = (perRepo ?? []).flatMap(({ direct }) =>
574 direct.map((commit) => ({ mergedAt: commit.at, mergedBy: null, authoredByAgent: false })),
575 );
576 const week = weekOf([...merged, ...direct], now, tz, complete);
577 const landed = landedToday(merged, now, tz);
578
579 // --- Activity -------------------------------------------------------------
580 const items: ActivityItem[] = [];
581 const titles: Record<string, string> = {};
582 const titleKey = (repo: RepoPath, number: number) => `${repo.namespace}/${repo.name}#${number}`.toLowerCase();
583 for (const { repo, pulls, closed, events } of perRepo ?? []) {
584 for (const pull of [...(pulls ?? []), ...(closed ?? [])]) titles[titleKey(repo, pull.number)] = pull.title;
585 for (const event of events ?? []) {
586 if (event.type === "issue.opened") titles[titleKey(repo, event.data.number)] ??= event.data.title;
587 const item = eventItem(event, pathOf(repo));
588 if (item) items.push(item);
589 }
590 }
591 for (const entry of overviewList ?? []) {
592 const latest = entry.latest;
593 const served = latest?.status === "ready" || latest?.status === "replaced";
594 if (!slug || latest?.kind !== "production" || (!served && latest.status !== "failed")) continue;
595 items.push({
596 id: `deploy:${latest.id}`,
597 at: Date.parse(latest.finishedAt ?? latest.createdAt),
598 repo: { namespace: slug, name: entry.slug },
599 actor: latest.createdBy,
600 verb: served ? "deployed" : "deploy_failed",
601 number: null,
602 to: `/${slug}/${entry.slug}/deployments/${latest.id}`,
603 });
604 }
605 for (const memory of okOr(memories ?? null)?.workspace ?? []) {
606 const repo = memory.repo ?? memory.source.repo;
607 if (!repo || !slug) continue;
608 items.push({
609 id: `memory:${memory.id}`,
610 at: Date.parse(memory.createdAt),
611 repo,
612 actor: memory.createdBy,
613 verb: "learned",
614 number: null,
615 text: memory.text.length > 90 ? `${memory.text.slice(0, 88)}…` : memory.text,
616 to: `/${slug}/-/memory`,
617 });
618 }
619 // The log names people by account id: their usernames, in one lookup.
620 const ids = actorIds(items.map((item) => item.actor));
621 const names = ids.length > 0 ? await soft("usernames", identity.usernames(ids)) : {};
622 const groups = groupActivity(items.map((item) => ({ ...item, actor: nameActor(item.actor, names) }))).slice(0, 40);
623 // Only the titles the feed names travel to the page.
624 const shownTitles: Record<string, string> = {};
625 for (const group of groups) {
626 for (const part of group.parts) {
627 for (const number of part.numbers) {
628 const key = titleKey(group.repo, number);
629 if (titles[key]) shownTitles[key] = titles[key];
630 }
631 }
632 }
633
634 // --- The strip ------------------------------------------------------------
635 const usageOk = okOr(usage ?? null);
636 const costPoints = usageOk
637 ? usageOk.byDay.map((slice) => ({ at: Date.parse(`${slice.key.split("/")[0]}T12:00:00Z`), value: slice.micros / 1_000_000 }))
638 : runList.filter((run) => run.costUsd != null).map((run) => ({ at: Date.parse(run.createdAt), value: run.costUsd ?? 0 }));
639 const weekCost = dailyBuckets(costPoints, 7, now).reduce((sum, v) => sum + v, 0);
640 const month = dayKey(now, tz).slice(0, 7);
641 const projectCount = projectsOk?.length ?? (perRepo != null ? perRepo.length : null);
642 const projectsThisMonth = projectsOk ? projectsOk.filter((project) => dayKey(Date.parse(project.createdAt), tz).slice(0, 7) === month).length : null;
643
644 const models_ = models ?? null;
645 times.total = Date.now() - started;
646 const serverTiming = Object.entries(times)
647 .map(([name, ms]) => `${name};dur=${ms}`)
648 .join(", ");
649 return data(
650 {
651 signedIn: true as const,
652 viewer,
653 name: profile?.name?.trim() || username,
654 greeting: greetingFor(hourIn(now, tz)),
655 date: dateLine(now, tz),
656 summary: summaryLine({
657 total: week.total,
658 byAgents: week.byAgents,
659 agentChanges: week.agentChanges,
660 people: week.people,
661 live: liveRuns.length,
662 needs: needRows.length,
663 }),
664 workspace: slug,
665 repos: repoList.filter((repo) => inWorkspace(repo)).map(pathOf),
666 canRunAgents: models_ == null || models_.hosted || models_.own != null,
667 trial: models_?.own == null ? (models_?.trial ?? null) : null,
668 handedOff: activeList.length > 0 || runList.length > 0 || merged.length > 0,
669 needs: needRows,
670 waiting,
671 landed,
672 liveTotal: liveRuns.length,
673 runsLoaded: runs != null && runs.ok,
674 perRepoLoaded: perRepo != null,
675 stats: {
676 projects: projectCount,
677 projectsThisMonth,
678 agentHours: agentHours(runList, weekAgo, now),
679 weekCost,
680 },
681 week,
682 tokens: { workspace: okOr(tokens), mine: okOr(myTokens) },
683 groups,
684 titles: shownTitles,
685 inboxNeeds: needsYou(unread?.items ?? [], INBOX_SHOWN),
686 // A run that is going makes the page worth refreshing on its own.
687 changing:
688 liveRuns.length > 0 ||
689 activeList.some((item) => item.lifecycle && item.lifecycle.stage !== "needs_you" && item.lifecycle.stage !== "ready"),
690 },
691 {
692 headers: { "Server-Timing": serverTiming },
693 },
694 );
695}
696
697export type Loaded = Extract<Route.ComponentProps["loaderData"], { signedIn: true }>;
698
699/**
700 * Mission control, loaded only for someone signed in (components/mission-control.tsx).
701 * The app shell starts loading it once the browser is idle, so the
702 * skeleton below is rarely seen.
703 */
704const MissionControl = lazy(() => import("../components/mission-control"));
705
706/**
707 * Mission control's outline while its code arrives, piece for piece as
708 * components/mission-control.tsx lays it out: the greeting, the Agent box,
709 * the actions under it, the stats, then the work list beside the week. Each
710 * piece has the padding and line heights of the real one, so nothing moves
711 * when it arrives.
712 */
713function MissionControlSkeleton() {
714 return (
715 <main aria-busy="true" className="mx-auto max-w-7xl space-y-6 px-4 py-8 sm:px-6 sm:py-10">
716 <span role="status" className="sr-only">
717 Loading…
718 </span>
719 <header>
720 <SkeletonLine className="w-72 max-w-full text-[1.75rem] leading-tight sm:text-[2rem]" barClassName="h-8" />
721 {/* The date and the week's summary: one line wide, three on a phone. */}
722 <div className="mt-1.5 text-sm">
723 <SkeletonLine className="w-[36rem] max-w-full" barClassName="h-3.5" />
724 <SkeletonLine className="w-full sm:hidden" barClassName="h-3.5" />
725 <SkeletonLine className="w-2/3 sm:hidden" barClassName="h-3.5" />
726 </div>
727 </header>
728 {/* The Agent box (components/ask-composer.tsx), then the actions under it. */}
729 <div className="space-y-3">
730 <div className="rounded-xl border border-line bg-surface">
731 <SkeletonLine className="mx-4 mt-3 w-36 text-sm" />
732 <div className="px-4 pt-3.5 pb-2 text-sm">
733 <SkeletonLine className="w-[30rem] max-w-full" />
734 <span className="block h-[1lh]" />
735 </div>
736 <div className="flex flex-wrap items-center gap-2 px-3 pb-3">
737 <Skeleton className="h-8 w-[4.5rem] rounded-md" />
738 <Skeleton className="h-8 w-[9.5rem] rounded-md" />
739 <Skeleton className="size-8 rounded-md" />
740 <Skeleton className="size-8 rounded-md" />
741 <Skeleton className="ml-auto size-8 rounded-md" />
742 </div>
743 </div>
744 <div className="flex flex-wrap items-center gap-2">
745 <Skeleton className="h-[38px] w-60 rounded-md" />
746 <Skeleton className="h-[38px] w-28 rounded-md" />
747 <Skeleton className="h-[38px] w-44 rounded-md" />
748 </div>
749 </div>
750 <div className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-line bg-line lg:grid-cols-5">
751 {Array.from({ length: 5 }, (_, index) => (
752 <SkeletonStat key={index} className={index === 4 ? "col-span-2 lg:col-span-1" : undefined} />
753 ))}
754 </div>
755 <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] xl:grid-cols-[minmax(0,1fr)_23rem]">
756 <div className="self-start overflow-hidden rounded-xl border border-line bg-surface">
757 <div className="flex items-center gap-4 border-b border-line px-4 py-3 text-sm sm:px-5">
758 <SkeletonLine className="w-24" />
759 <SkeletonLine className="w-32" />
760 <SkeletonLine className="hidden w-28 sm:flex" />
761 </div>
762 <div className="divide-y divide-line">
763 {Array.from({ length: 5 }, (_, index) => (
764 <div key={index}>
765 <div className="flex items-center gap-3 px-4 py-3 sm:px-5">
766 <Skeleton className="size-8 shrink-0 rounded-md" />
767 <span className="min-w-0 grow">
768 <SkeletonLine className="text-sm" width={`${70 - ((index * 17) % 30)}%`} />
769 <SkeletonLine className="mt-0.5 text-xs" width={`${50 - ((index * 11) % 20)}%`} />
770 </span>
771 <Skeleton className="hidden h-4 w-24 shrink-0 sm:block" />
772 </div>
773 {/* The first row stands open, as the real list's does. */}
774 {index === 0 && (
775 <div className="px-4 pb-4 sm:px-5 sm:pb-5">
776 <Skeleton className="h-[17rem] rounded-xl" />
777 <Skeleton className="mt-3 h-8 w-44 rounded-md" />
778 </div>
779 )}
780 </div>
781 ))}
782 </div>
783 </div>
784 <div className="space-y-6">
785 <SkeletonCard lines={0} className="h-80 p-5" />
786 <SkeletonCard lines={6} className="p-5" />
787 </div>
788 </div>
789 </main>
790 );
791}
792
793export default function Home({ loaderData, actionData }: Route.ComponentProps) {
794 if (!loaderData.signedIn) return <Landing />;
795 return (
796 <Suspense fallback={<MissionControlSkeleton />}>
797 <MissionControl loaderData={loaderData} delegated={actionData ?? null} />
798 </Suspense>
799 );
800}