Skip to content

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

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