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.

g1t.sh

Commit

Merge remote-tracking branch 'g1t/main' into HEAD

syntaqxcommitted Parentsd28c49a0c83797Browse files
60 files+1092−10928 files not counted0/60 viewed
+1−1
33753375 "view": {
33763376 "type": "string",
33773377 "enum": ["inbox", "saved", "done"],
3378− "description": "inbox (the default): not done and not snoozed. saved: what you saved. done: what you marked done.",
3378+ "description": "inbox (the default): not done and not snoozed. saved: what you saved. done: what is done, marked by you or settled by itself; an item that settled itself carries `settled_reason` (such as `CI passed in run 330` or `Merged`) and `settled_by` (a username, or g1t, when someone did).",
33793379 },
33803380 "reason": {
33813381 "type": "string",
+1−1
7777 | `g1t-events-webhooks` | `webhooks` | Everything |
7878 | `g1t-events-security` | `security` | `git.push`, pull request changes, `comment.created`, `repo.created`, `repo.visibility_changed` |
7979 | `g1t-events-search` | `search` | `git.push`, `repo.*`, `issue.*`, `pull.*`, `user.*`, `workspace.*` |
80−| `g1t-events-deployments` | `deployments` | Pushes, the pull request lifecycle, workspace deletion and restore |
80+| `g1t-events-deployments` | `deployments` | Pushes, the pull request lifecycle, workspace deletion and restore, and deployment statuses (what each production deployment shipped) |
8181 | `g1t-events-projects` | `projects` | Repositories, packages and deployments |
8282 | `g1t-events-repos` | `repos` | `pull.merged`, `pull.closed`, `pull.reopened`, deletions and restores |
8383 | `g1t-events-packages` | `packages` | `git.push`, `repo.visibility_changed`, workspace and team changes |
+11−4
477477
478478 Each Worker keeps a share of its invocations' logs, set by
479479 `head_sampling_rate` in its `wrangler.jsonc`: every one for billing,
480−identity, runner, actions, deployments, status and sudo, and a tenth for
481−the rest. Repos and work keep every one for now, while rare ~60 s stalls
482−are traced, and go back to a tenth once they are found. Metrics are not sampled. To chase a rare error on a sampled
483−Worker, raise its rate to 1 while you look.
480+identity, runner, actions, deployments, status and sudo, a quarter for
481+repos and work, which every page asks, and a tenth for the rest. Metrics
482+are not sampled. To chase a rare error on a sampled Worker, raise its rate
483+to 1 while you look.
484+
485+Slow work in repos logs itself as fields, so the invocations kept can be
486+counted by what was slow: an RPC answer that took a second or more logs
487+`rpc` (the method) and `answered_ms`, and a slow or partial About run logs
488+`about_run` (the repository's id), `took_ms` and `partial`. In Workers
489+Logs, filter `g1t-repos` on `answered_ms` greater than 0, and multiply
490+what you count by four.
+60−6
88
99 1. **Where you're needed**: everything waiting on you, however long ago
1010 it started, with the action beside each item.
11−2. **What happened while you were away**: what people did; what landed;
12− deploys, decisions and spend, since you were last here.
11+2. **What happened while you were away**: what settled by itself and
12+ what went live; what people did; what landed; deploys and spend,
13+ since you were last here.
1314 3. **What is running now**: workflow runs and builds still going.
1415
1516 **Start here** picks the one item to do first.
103104 When Code and Chat both failed to answer, the sentence ends with "what
104105 people did couldn't be read" rather than reading as quiet.
105106
107+### While you were away
108+
109+The first card says what finished by itself in the span, and what is
110+live. (Over **Last 24 hours** and **Last 7 days** it is called **Settled
111+for you**.) Its first line counts:
112+
113+| Count | What it is |
114+| --- | --- |
115+| *4 notifications settled* | Your notifications in this workspace that [settled themselves](/guides/notifications/#threads-that-settle-themselves) in the span: a later run passed, the pull request merged, the deployment went out. One you marked done yourself is not counted |
116+| *3 changes live*, *1 deploying*, *1 waiting to deploy*, *1 deploy failed* | The merges and pushes under [Landed](#landed), by whether each is [live](#live-after-merge) |
117+| *2 things still need you* | The rows of [Where you're needed](#where-youre-needed); it takes you there |
118+
119+Under it, each notification that settled, newest first, with why it
120+settled and who did it, such as **CI passed in run 330** or **Merged by
121+@ana**. **Done** opens the notifications that are done.
122+
106123 ### People
107124
108−The first card, **People**, lists what people did in the span, one line
125+The second card, **People**, lists what people did in the span, one line
109126 per kind of thing with its count, and each line opens the place that lists
110127 them: a project's Activity tab for pushes, its pull requests or issues,
111128 Artifacts, Chat. Beside the heading are the faces of the five most active,
135152 | Row | What it is |
136153 | --- | --- |
137154 | A pull request merged | With who wrote it, and who merged it when that was someone else |
138−| Commits pushed to a default branch | One row per project with any: how many commits landed on its default branch without a pull request, in how many pushes, and by whom |
155+| A push to a default branch | One row per push a person made straight to a default branch: its commit's first line when it brought one, else how many commits, and who pushed. For the ten projects pushed to most in the span; past those, one row per project with how many commits and pushes |
139156 | A production build that went live | A project's production build that built and was served (previews stay under Deploys) |
140157 | A release published | Its tag and name, and who released it |
141158 | A package version published | Its name and version, and who published it |
142159
143160 The card's line counts them by kind. The first
144−five show; **Show more** opens the rest. When one of its sources (pull
161+five show; **Show more** opens the rest. Over **Last 7 days** the rows are
162+grouped by day (**Today**, **Yesterday**, **Sun, Oct 11**), five of each
163+day showing.
164+
165+#### Live after merge
166+
167+Each merge and push says whether it is live yet, for a project that
168+deploys to production. Landing on `main` is not shipping: a change is
169+shipped once a production deployment that contains it succeeds.
170+
171+| Label | When |
172+| --- | --- |
173+| **Live 9 min after merge** | A production deployment that succeeded shipped it, this long after it landed. The label opens that deployment, or the workflow run that deployed it |
174+| **Deploying** | A production deployment after it is under way |
175+| **Deploy failed** | The production deployment after it failed, and none has shipped it since |
176+| **Waiting to deploy** | Nothing has deployed it yet |
177+
178+How g1t knows: when a production deployment succeeds, a
179+[g1t.page build](/guides/deployments/) or one a workflow or the API
180+reported, g1t reads its commit's history back and writes down each
181+commit no production deployment shipped before as shipped by this one.
182+So a commit's first production deployment is the one that took it live,
183+and a rollback to an older commit ships nothing new. Home reads only what
184+was written down, so this costs nothing when the page loads. A change
185+that landed before g1t began writing this down for its project goes live
186+with the first production deployment that succeeded after it.
187+
188+#### Made with an agent
189+
190+A merge or push made in a harness session, such as Claude Code working
191+for you, carries the agent marker beside its title; it opens the
192+session. A pull request is marked when a session is tied to it (it
193+pushed the branch, named the pull request, or a commit on it carries a
194+`G1t-Session:` trailer); a push when one of its commits carries the
195+trailer. The change is still yours, and stays in the same list. When one of its sources (pull
145196 requests, pushes and releases, deployments) didn't answer, the card says
146197 so under the list.
147198
200251 - People counts what could be read, and names the part that is missing
201252 (Code or Chat). If nothing could be read, the card says so, and the
202253 sentence under the heading says what people did couldn't be read.
203−- Landed, Deploys and Spent each say their part didn't answer.
254+- Landed, Deploys and Spent each say their part didn't answer. When
255+ deployments or sessions can't be read, Landed shows its rows without
256+ their live labels or agent markers.
257+- While you were away says when your notifications can't be read.
204258 - If your last visit can't be read, the span is the last 24 hours and the
205259 page says why.
206260
+5−2
3333
3434 The license, security policy, languages and contributors are read from the
3535 default branch in the background each time it moves, and kept by commit, so
36−the page never waits for them. A repository pushed to for the first time
37−shows **Reading the default branch…** for a few seconds.
36+the page never waits for them. A push to the default branch starts the
37+reading as soon as git has its answer; a branch that moved otherwise, such
38+as by merging a pull request, is read again when its page is next opened.
39+A repository pushed to for the first time shows **Reading the default
40+branch…** for a few seconds.
3841
3942 ### Languages
4043
+10−0
117117 | A repository's security alerts | A scan finds none of that kind open |
118118 | g1t stopping on a change it made for you | It picks back up, its head moves, a merge is asked for, or it is merged or closed |
119119
120+A thread that settled by itself says why under its title in **Done**, in
121+a few words, and who did it when someone did: **CI passed in run 330**,
122+**Merged by @ana**, **Production of site deployed at 8e30fb4**,
123+**Review no longer asked of you**. A thread you marked done yourself says
124+nothing there. [Home](/guides/home/#while-you-were-away) lists what
125+settled for you since your last visit.
126+
127+A notification that settled is no longer shown again: tabs you open later
128+do not replay it, and tabs already open drop it from their recent list.
129+
120130 What g1t does itself and leaves nothing for you to do is never a
121131 notification: what landed and what deployed is on [Home](/guides/home/).
122132
+10−0
9494 request when it caught up with `main` is credited to whoever wrote it on
9595 `main`, not to the merge.
9696
97+Blame reads up to 400 commits back, for up to six seconds. A line older
98+than that is credited to the oldest commit read that has it, and the view
99+says the history is long. While it is worked out the file shows as it is,
100+with "Working out who changed each line" above it, and each line's commit
101+fills in when it is ready. A file's blame at a commit is kept once worked
102+out, so opening it again, or from a commit's link, shows at once. Search
103+engines and other crawlers are asked to leave blame pages alone
104+(`Disallow: /*?blame=`, and `/*/*/blame/` for the blame data the view
105+fetches, in `/robots.txt`); the file itself stays open to them.
106+
97107 ### Why over the API
98108
99109 The `repository` tool's `why` action, or
+0−0

Binary or large file; its contents are not shown.

+1−0
639639 </Link>
640640 <Link
641641 to={`${fileBase}/${file.path}?blame=1`}
642+ rel="nofollow"
642643 className="rounded px-1.5 py-0.5 text-muted hover:bg-raised hover:text-fg"
643644 >
644645 Blame
+185−21
99 ArrowRight,
1010 AtSign,
1111 Bell,
12+ CheckCheck,
1213 GitCommitHorizontal,
1314 GitMerge,
1415 GitPullRequest,
2930 type Attention,
3031 type AttentionKind,
3132 type AttentionRow,
33+ type Away,
3234 type DeployRow,
3335 type Digest,
3436 type DigestActor,
3537 type DigestColumn,
3638 type LandedChange,
3739 type RunningRow,
40+ type ShipInfo,
3841 type Span,
3942 type Spend,
4043 type Stake,
4144 WINDOWS,
45+ agentLabel,
46+ landedByDay,
4247 listOf,
48+ shippedLabel,
49+ timeOfDay,
4350 waited,
4451 whenShort,
4552 } from "../lib/home";
4653 import { cn } from "../lib/cn";
4754 import { money } from "../lib/money";
4855 import { ButtonLink } from "./ui";
49−import { Avatar } from "./ui/avatar";
50−import { Badge } from "./ui/badge";
56+import { AgentMarker, Avatar } from "./ui/avatar";
57+import { Badge, type BadgeTone } from "./ui/badge";
5158 import { Skeleton } from "./ui/skeleton";
5259 import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
5360
433440 /** Rows each list shows before the rest fold away. */
434441 const LIST_ROWS = 5;
435442
436−function When({ at, now, tz }: { at: number; now: number; tz: string | null }) {
443+function When({ at, now, tz, timeOnly }: { at: number; now: number; tz: string | null; timeOnly?: boolean }) {
437444 return (
438445 <span className="shrink-0 text-xs text-faint tabular-nums" suppressHydrationWarning>
439− {whenShort(at, now, tz)}
446+ {timeOnly ? timeOfDay(at, tz) : whenShort(at, now, tz)}
440447 </span>
441448 );
442449 }
483490 return parts.join(" · ");
484491 }
485492
493+const SHIP_TONE: Record<ShipInfo["state"], BadgeTone> = {
494+ live: "success",
495+ deploying: "info",
496+ waiting: "neutral",
497+ failed: "danger",
498+};
499+
500+/** Whether a merge or a push is live yet, linked to the deployment that shipped it (or the one under way, or the one that failed). */
501+function Shipped({ change }: { change: LandedChange }) {
502+ const ship = change.shipped;
503+ if (!ship) return null;
504+ const label = shippedLabel(ship, change);
505+ const badge = (
506+ <Badge tone={SHIP_TONE[ship.state]} className="relative z-10">
507+ {ship.state === "live" && <Rocket size={10} aria-hidden="true" />}
508+ {ship.state === "deploying" && <Loader size={10} aria-hidden="true" className="animate-spin motion-reduce:animate-none" />}
509+ {label}
510+ </Badge>
511+ );
512+ return ship.to ? (
513+ <Link to={ship.to} className="relative z-10 rounded-full focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none">
514+ {badge}
515+ </Link>
516+ ) : (
517+ badge
518+ );
519+}
520+
521+/** The agent marker on a change made in a harness session, linked to the session. */
522+function Agent({ change }: { change: LandedChange }) {
523+ const mark = change.agent;
524+ if (!mark) return null;
525+ const label = agentLabel(mark);
526+ const marker = <AgentMarker size={16} inline />;
527+ return (
528+ <Tooltip>
529+ <TooltipTrigger asChild>
530+ {mark.to ? (
531+ <Link to={mark.to} aria-label={label} className="relative z-10 inline-flex shrink-0 rounded-full focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none">
532+ {marker}
533+ </Link>
534+ ) : (
535+ <span role="img" aria-label={label} className="inline-flex shrink-0">
536+ {marker}
537+ </span>
538+ )}
539+ </TooltipTrigger>
540+ <TooltipContent>{label}</TooltipContent>
541+ </Tooltip>
542+ );
543+}
544+
545+function LandedItem({ change, now, tz, timeOnly }: { change: LandedChange; now: number; tz: string | null; timeOnly?: boolean }) {
546+ return (
547+ <li className={cn(rowClass, "items-start")}>
548+ <span className="mt-0.5 shrink-0">{LANDED_ICON[change.kind]}</span>
549+ <div className="min-w-0 grow">
550+ <div className="flex min-w-0 items-center gap-1.5">
551+ <Link to={change.to} className="line-clamp-2 text-sm text-fg hover:underline sm:truncate">
552+ {change.title}
553+ </Link>
554+ <Agent change={change} />
555+ </div>
556+ <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
557+ <p className="min-w-0 truncate text-xs text-muted">{change.detail}</p>
558+ <Shipped change={change} />
559+ </div>
560+ </div>
561+ <When at={change.at} now={now} tz={tz} timeOnly={timeOnly} />
562+ </li>
563+ );
564+}
565+
486566 /**
487567 * Everything that landed in the span, newest first: pull requests merged,
488568 * commits pushed straight to a default branch, production builds that went
489− * live, releases and package versions published.
569+ * live, releases and package versions published. Each merge and push says
570+ * whether it is live yet, and one made in a harness session carries the
571+ * agent marker. Over the last 7 days, day by day.
490572 */
491−export function LandedCard({ landed, missing, code, now, tz }: { landed: LandedChange[] | null; missing: string[]; code: boolean; now: number; tz: string | null }) {
573+export function LandedCard({
574+ landed,
575+ missing,
576+ code,
577+ now,
578+ tz,
579+ byDay = false,
580+}: {
581+ landed: LandedChange[] | null;
582+ missing: string[];
583+ code: boolean;
584+ now: number;
585+ tz: string | null;
586+ byDay?: boolean;
587+}) {
492588 if (!code) return null;
589+ const render = (change: LandedChange) => <LandedItem key={change.key} change={change} now={now} tz={tz} timeOnly={byDay} />;
493590 return (
494591 <Card title="Landed" hint={landed && landed.length > 0 ? landedHint(landed) : undefined}>
495592 {!landed ? (
496593 <Quiet>Code didn&apos;t answer, so what landed can&apos;t be listed right now.</Quiet>
497594 ) : landed.length === 0 ? (
498595 <Quiet>{missing.length > 0 ? "Nothing landed in what could be read." : "Nothing merged, pushed to a default branch, deployed to production or published in this span."}</Quiet>
596+ ) : byDay ? (
597+ landedByDay(landed, now, tz).map((day) => (
598+ <section key={day.key} aria-label={day.label} className="border-t border-line first-of-type:border-t-0">
599+ <h3 className="flex items-baseline gap-2 bg-raised/40 px-5 py-1.5 text-xs font-medium text-fg-soft">
600+ {day.label}
601+ <span className="font-normal text-faint tabular-nums">{day.rows.length.toLocaleString("en-US")}</span>
602+ </h3>
603+ <Folded items={day.rows} render={render} />
604+ </section>
605+ ))
499606 ) : (
500− <Folded
501− items={landed}
502− render={(change) => (
503− <li key={change.key} className={rowClass}>
504− {LANDED_ICON[change.kind]}
505− <div className="min-w-0 grow">
506− <Link to={change.to} className="block truncate text-sm text-fg hover:underline">
507− {change.title}
508− </Link>
509− <p className="truncate text-xs text-muted">{change.detail}</p>
510− </div>
511− <When at={change.at} now={now} tz={tz} />
512− </li>
513− )}
514− />
607+ <Folded items={landed} render={render} />
515608 )}
516609 {landed && <Missing names={missing} what="everything that landed" />}
517610 </Card>
518611 );
519612 }
520613
614+// --- While you were away -----------------------------------------------------------
615+
616+/**
617+ * What g1t settled for you since you were last here (a later run passed,
618+ * it merged, it deployed), what shipped, and what still needs you: the
619+ * counts on one line, then each thing that settled and why.
620+ */
621+export function AwayCard({ away, span, now }: { away: Away | null; span: Pick<Span, "key" | "note" | "words">; now: number }) {
622+ const title = span.key === "last" && span.note == null ? "While you were away" : "Settled for you";
623+ if (!away) {
624+ return (
625+ <Card title={title}>
626+ <Quiet>Your notifications couldn&apos;t be read, so what settled can&apos;t be listed right now.</Quiet>
627+ </Card>
628+ );
629+ }
630+ const facts: { key: string; text: string; tone: BadgeTone; to: string | null }[] = [];
631+ const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
632+ facts.push({ key: "settled", text: `${plural(away.settled.length, "notification")} settled`, tone: away.settled.length > 0 ? "success" : "neutral", to: "/notifications?view=done" });
633+ if (away.shipped > 0) facts.push({ key: "shipped", text: `${plural(away.shipped, "change")} live`, tone: "success", to: null });
634+ if (away.deploying > 0) facts.push({ key: "deploying", text: `${away.deploying.toLocaleString("en-US")} deploying`, tone: "info", to: null });
635+ if (away.waiting > 0) facts.push({ key: "waiting", text: `${away.waiting.toLocaleString("en-US")} waiting to deploy`, tone: "neutral", to: null });
636+ if (away.failed > 0) facts.push({ key: "failed", text: `${away.failed.toLocaleString("en-US")} deploy failed`, tone: "danger", to: null });
637+ facts.push({ key: "needs", text: away.needs > 0 ? `${plural(away.needs, "thing")} still ${away.needs === 1 ? "needs" : "need"} you` : "Nothing needs you", tone: away.needs > 0 ? "warn" : "neutral", to: "#needed" });
638+ return (
639+ <Card title={title} hint="What finished by itself, and what is live" link={{ label: "Done", to: "/notifications?view=done" }}>
640+ <ul className="flex flex-wrap gap-1.5 px-5 pb-3" aria-label="In short">
641+ {facts.map((fact) => (
642+ <li key={fact.key}>
643+ {fact.to ? (
644+ <Link to={fact.to} className="rounded-full focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none">
645+ <Badge tone={fact.tone} size="md">
646+ {fact.text}
647+ </Badge>
648+ </Link>
649+ ) : (
650+ <Badge tone={fact.tone} size="md">
651+ {fact.text}
652+ </Badge>
653+ )}
654+ </li>
655+ ))}
656+ </ul>
657+ {away.settled.length > 0 && (
658+ <div className="border-t border-line">
659+ <Folded
660+ items={away.settled}
661+ render={(row) => (
662+ <li key={row.key} className={rowClass}>
663+ <CheckCheck size={15} className="shrink-0 text-success" aria-hidden="true" />
664+ <div className="min-w-0 grow">
665+ <Link to={row.to} className="block truncate text-sm text-fg hover:underline">
666+ {row.title}
667+ </Link>
668+ <p className="truncate text-xs text-muted">
669+ <span className="text-fg-soft">{row.why}</span>
670+ {row.where && ` · ${row.where}`}
671+ </p>
672+ </div>
673+ <span className="shrink-0 text-xs text-faint tabular-nums" suppressHydrationWarning>
674+ {waited(row.at, now)} ago
675+ </span>
676+ </li>
677+ )}
678+ />
679+ </div>
680+ )}
681+ </Card>
682+ );
683+}
684+
521685 export function DeploysCard({ deploys, complete, code, now, tz }: { deploys: DeployRow[] | null; complete: boolean; code: boolean; now: number; tz: string | null }) {
522686 // A workspace that doesn't deploy has nothing to say here.
523687 if (!code || (deploys && deploys.length === 0)) return null;
+8−1
1616 import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
1717 import { cn } from "../lib/cn";
1818 import { isPending } from "../lib/pending";
19−import { INBOX_TABS, type InboxTab, REASON_LABEL, SEVERITY_LABEL, SNOOZES, bellCount, emptyFor, isUnread, tabCount, updatesLabel, whenShort } from "../lib/inbox";
19+import { INBOX_TABS, type InboxTab, REASON_LABEL, SEVERITY_LABEL, SNOOZES, bellCount, emptyFor, isUnread, settledLine, tabCount, updatesLabel, whenShort } from "../lib/inbox";
2020 import { useOwnPresence } from "../lib/notify-client";
2121 import { dndOn } from "../lib/presence";
2222 import type { InboxPanelData } from "../routes/inbox-json";
101101 {item.title}
102102 </Link>
103103 {item.body && <p className="mt-0.5 truncate text-xs text-muted">{item.body}</p>}
104+ {/* Done by itself: what ended it, and who. */}
105+ {settledLine(item) && (
106+ <p className="mt-1.5 flex min-w-0 items-center gap-1.5 text-xs text-fg-soft">
107+ <CheckCheck size={12} aria-hidden="true" className="shrink-0 text-success" />
108+ <span className="truncate">{settledLine(item)}</span>
109+ </p>
110+ )}
104111 <div className="mt-2 flex min-w-0 items-center gap-2 text-xs text-faint [@media(hover:none)]:pr-24">
105112 <time dateTime={item.updatedAt} suppressHydrationWarning className="shrink-0 whitespace-nowrap">
106113 {whenShort(item.updatedAt, now)}
+15−4
1−import { BookOpen, Check, ChevronDown, Code2, CornerLeftUp, File, FileArchive, Folder, FolderGit2, GitBranch, History, Search, SquareTerminal, Tag as TagIcon } from "lucide-react";
1+import { BookOpen, Check, ChevronDown, Code2, CornerLeftUp, File, FileArchive, Folder, FolderGit2, GitBranch, History, LoaderCircle, Search, SquareTerminal, Tag as TagIcon } from "lucide-react";
22 import { type ReactNode, Suspense } from "react";
33 import { Await, Form, Link } from "react-router";
44
449449 blob: Blob;
450450 /** Syntax-highlighted HTML per line, when the language is known. */
451451 html: string[] | null;
452− /** Shown instead of the plain file when asked for, with HTML per line. */
453− blame?: { blame: ShownBlame; lines: string[] | null } | null;
452+ /** Shown instead of the plain file when asked for, with HTML per line;
453+ * "waiting" while it streams in, "failed" when it could not be had (the
454+ * plain file shows meanwhile). */
455+ blame?: { blame: ShownBlame; lines: string[] | null } | "waiting" | "failed" | null;
454456 }) {
455457 const { repo, ref, path, size, text } = blob;
456458 const lines = text?.replace(/\n$/, "").split("\n");
460462 const toggle = (label: string, on: boolean, search: string) => (
461463 <Link
462464 to={{ search }}
465+ // Blame is costly to work out and adds nothing to a search index.
466+ rel={search ? "nofollow" : undefined}
463467 preventScrollReset
464468 className={`rounded px-2.5 py-1.5 transition-colors sm:px-2 sm:py-0.5 ${on ? "bg-raised text-fg" : "hover:text-fg"}`}
465469 >
482486 <Card tone="plain" className="overflow-hidden">
483487 <div className="flex items-center gap-3 border-b border-line bg-surface px-3 py-2 text-xs text-muted sm:px-4 sm:py-2.5">
484488 {lines && <span>{lines.length.toLocaleString("en-US")} lines</span>}
489+ {blame === "waiting" && (
490+ <span className="flex items-center gap-1.5" role="status">
491+ <LoaderCircle size={12} className="animate-spin" aria-hidden />
492+ Working out who changed each line
493+ </span>
494+ )}
495+ {blame === "failed" && <span role="status">Blame could not be worked out; try again in a moment</span>}
485496 <span className="hidden min-[400px]:inline">{size.toLocaleString("en-US")} bytes</span>
486497 <span className="ml-auto flex items-center gap-2">
487498 {lines && (
495506 </a>
496507 </span>
497508 </div>
498− {blame && lines ? (
509+ {typeof blame === "object" && blame && lines ? (
499510 <BlameView base={base} path={path} lines={lines} html={blame.lines} blame={blame.blame} />
500511 ) : lines ? (
501512 <CodeLines lines={lines} html={html} marked={marked} />
+9−1
210210 * that the squircle tile alone says it). The same mark everywhere, so
211211 * nobody has to read a name to know they are looking at an agent.
212212 */
213−export function AgentMarker({ size, ring = "var(--color-bg)", className }: { size: number; ring?: string; className?: string }) {
213+export function AgentMarker({ size, ring = "var(--color-bg)", className, inline }: { size: number; ring?: string; className?: string; inline?: boolean }) {
214214 const disc = Math.max(9, Math.min(22, Math.round(size * 0.4)));
215215 const icon = Math.max(6, Math.round(disc * 0.66));
216216 const inset = Math.round(disc * 0.28);
217+ // `inline`: the marker on its own in a line of text, beside what an agent made, rather than on an avatar's corner.
218+ if (inline) {
219+ return (
220+ <span aria-hidden="true" className={`inline-flex shrink-0 items-center justify-center rounded-full bg-accent text-bg ${className ?? ""}`} style={{ width: size, height: size }}>
221+ <Bot size={Math.max(8, Math.round(size * 0.66))} strokeWidth={2.6} absoluteStrokeWidth={false} />
222+ </span>
223+ );
224+ }
217225 return (
218226 <span
219227 aria-hidden="true"
+123−2
88 import {
99 type ActivityDigest,
1010 type ChatActivity,
11+ type Commit,
1112 type Deployment,
13+ type G1tEvent,
14+ type InboxItem,
1215 MAX_DIGEST_REPOS,
1316 type ProjectDeploys,
17+ type PullSessions,
1418 type Repo,
1519 type RepoPath,
20+ type ShippedRepo,
1621 type User,
1722 type WorkflowRun,
1823 notifyClient,
1924 } from "@g1t/contracts";
2025
21−import { type CodePull, type CodeWork, G1T_ACTOR, type RepoRef, type Span, type Spend, deployActorKey, spanMonths, spendIn } from "./home";
26+import {
27+ type CodePull,
28+ type CodeWork,
29+ type DirectPush,
30+ G1T_ACTOR,
31+ type LandedChange,
32+ type RepoRef,
33+ type Span,
34+ type Spend,
35+ deployActorKey,
36+ shippedAsks,
37+ spanMonths,
38+ spendIn,
39+} from "./home";
2240 import { monthSpan } from "./usage";
23−import { actions, billing, chat, deployments, events, identity, repos, work } from "./services.server";
41+import { actions, billing, chat, deployments, events, identity, inbox, repos, work } from "./services.server";
2442
2543 /** Pull requests read per project and list: `LIST_PAGE` in services/work. */
2644 const PULL_PAGE = 100;
97115 for (const pull of [...entry.open, ...entry.closed]) {
98116 pulls.push({
99117 repo: path,
118+ repoId: repo.id,
119+ mergeCommit: pull.mergeCommit ?? null,
100120 number: pull.number,
101121 title: pull.title,
102122 status: pull.status,
263283 );
264284 }
265285
286+/** The most projects whose pushes to the default branch are listed one by one, pushes read per project, and commits read back. */
287+const MAX_PUSH_PROJECTS = 10;
288+const PUSHES_READ = 100;
289+const PUSH_HISTORY = 200;
290+
291+/**
292+ * Each person's push to a default branch in the span, for the projects
293+ * the digest says were pushed to (those pushed to most, at most
294+ * MAX_PUSH_PROJECTS), with each project's default branch read back from
295+ * its newest push to place each push's commits. `by` is the pusher's
296+ * member key, named by the caller. Null when events did not answer.
297+ */
298+export async function loadDirectPushes(
299+ viewer: User,
300+ activity: ActivityDigest | null,
301+ owned: Repo[] | null,
302+ span: Pick<Span, "from" | "now">,
303+): Promise<{ pushes: (Omit<DirectPush, "by"> & { actorKey: string })[]; history: Record<string, Commit[]>; complete: boolean } | null> {
304+ if (!activity || !owned) return null;
305+ const byId = new Map(owned.map((repo) => [repo.id, repo]));
306+ const pushedTo = activity.repos
307+ .filter((digest) => digest.pushes.default_branch.count > 0 && byId.has(digest.repo_id))
308+ .sort((a, b) => b.pushes.default_branch.count - a.pushes.default_branch.count || a.repo_id.localeCompare(b.repo_id));
309+ const read = pushedTo.slice(0, MAX_PUSH_PROJECTS);
310+ const lists = await Promise.all(
311+ read.map(async (digest) => {
312+ const repo = byId.get(digest.repo_id)!;
313+ const log = await events.list({ repoId: repo.id, types: ["git.push"], limit: PUSHES_READ }).catch(warn(`pushes of ${repo.name}`));
314+ if (!log) return null;
315+ const pushes = log.filter(
316+ (event): event is G1tEvent<"git.push"> =>
317+ event.type === "git.push" && (event as G1tEvent<"git.push">).data.defaultBranch && Date.parse(event.time) >= span.from && Date.parse(event.time) <= span.now,
318+ );
319+ if (pushes.length === 0) return { repo, pushes, history: [] as Commit[] };
320+ const path = { namespace: repo.namespace, name: repo.name };
321+ const history = await repos.log(path, viewer, pushes[0]!.data.after, PUSH_HISTORY).catch(warn(`history of ${repo.name}`));
322+ return { repo, pushes, history: history?.ok ? history.value : [] };
323+ }),
324+ );
325+ const pushes: (Omit<DirectPush, "by"> & { actorKey: string })[] = [];
326+ const history: Record<string, Commit[]> = {};
327+ for (const entry of lists) {
328+ if (!entry) continue;
329+ history[entry.repo.id] = entry.history;
330+ const ref: RepoRef = { namespace: entry.repo.namespace, name: entry.repo.name, defaultBranch: entry.repo.defaultBranch };
331+ for (const event of entry.pushes) {
332+ pushes.push({
333+ repoId: entry.repo.id,
334+ repo: ref,
335+ push: { id: event.id, time: event.time, actor: event.actor ?? null, data: event.data },
336+ actorKey: event.actor ? deployActorKey(event.actor) ?? "" : "",
337+ });
338+ }
339+ }
340+ return { pushes, history, complete: lists.every((entry) => entry != null) && pushedTo.length <= MAX_PUSH_PROJECTS };
341+}
342+
343+/**
344+ * Whether each merge and push that landed went live, from deployments'
345+ * record of what each production deployment shipped. Null when
346+ * deployments did not answer.
347+ */
348+export async function loadShipped(viewer: User, slug: string, rows: LandedChange[]): Promise<ShippedRepo[] | null> {
349+ const asks = shippedAsks(rows).slice(0, 20);
350+ if (asks.length === 0) return [];
351+ const answer = await deployments.shipped(slug, viewer, asks).catch(warn("shipped"));
352+ return answer?.ok ? answer.value : null;
353+}
354+
355+/** The harness sessions tied to each merged pull request, per project; null when work did not answer for any. */
356+export async function loadPullSessions(viewer: User, pulls: CodePull[]): Promise<{ repo: RepoPath; pulls: PullSessions[] }[] | null> {
357+ const byRepo = new Map<string, { repo: RepoPath; numbers: number[] }>();
358+ for (const pull of pulls) {
359+ if (pull.status !== "merged") continue;
360+ const key = `${pull.repo.namespace}/${pull.repo.name}`.toLowerCase();
361+ const entry = byRepo.get(key) ?? { repo: pull.repo, numbers: [] };
362+ entry.numbers.push(pull.number);
363+ byRepo.set(key, entry);
364+ }
365+ if (byRepo.size === 0) return [];
366+ const lists = await Promise.all(
367+ [...byRepo.values()].slice(0, MAX_PUSH_PROJECTS).map(({ repo, numbers }) =>
368+ work
369+ .pullSessions(repo, viewer, numbers.slice(0, 100))
370+ .then((result) => (result.ok ? { repo, pulls: result.value } : null))
371+ .catch(warn(`sessions of ${repo.name}`)),
372+ ),
373+ );
374+ const found = lists.filter((entry) => entry != null);
375+ return found.length === 0 && lists.length > 0 ? null : found;
376+}
377+
378+/** Notifications read for what settled by itself in the span. */
379+const SETTLED_READ = 100;
380+
381+/** The viewer's notifications done since the span began, newest activity first; null when the inbox did not answer. */
382+export async function loadSettled(viewer: User, span: Pick<Span, "from">): Promise<InboxItem[] | null> {
383+ const page = await inbox.list(viewer, { view: "done", limit: SETTLED_READ }).catch(warn("settled notifications"));
384+ return page ? page.items.filter((item) => item.doneAt != null && Date.parse(item.doneAt) >= span.from) : null;
385+}
386+
266387 /** How far back Code is read: 7 days before the span, for the comparison. */
267388 export function reachFor(span: Pick<Span, "from">): number {
268389 return span.from - 7 * DAY;
+134−0
77 type AttentionRow,
88 type CodeNeed,
99 type CodePull,
10+ type LandedChange,
1011 actorKind,
12+ agentLabel,
1113 attention,
14+ awayOf,
15+ landedByDay,
16+ pushRows,
17+ sessionTrailers,
18+ settledIn,
19+ shippedAsks,
20+ shippedLabel,
21+ withSessions,
22+ withShipped,
1223 chatRow,
1324 codeRow,
1425 dayIn,
646657 assert.equal(spend.monthMicros, 13_609_000);
647658 assert.equal(spendIn([september, october], { from: Date.parse("2026-09-29T15:00:00Z"), now: NOW }, null).monthMicros, null);
648659 });
660+
661+
662+// --- What landed: shipped, made in a session, day by day -----------------------
663+
664+const merge = (over: Partial<LandedChange> = {}): LandedChange => ({
665+ key: "landed:acme/web#12",
666+ kind: "merge",
667+ title: "Add retries",
668+ detail: "web#12 · by ana",
669+ to: "/acme/web/pull/12",
670+ at: NOW - 2 * HOUR,
671+ sha: "m12",
672+ repoId: "rep_web",
673+ ...over,
674+});
675+
676+test("each merge and push says whether it is live, from what deployments recorded", () => {
677+ const push = merge({ key: "push:rep_web:evt_1", kind: "push", sha: "p1", at: NOW - HOUR });
678+ const release = merge({ key: "release:rep_web:v1", kind: "release", sha: null });
679+ assert.deepEqual(shippedAsks([merge(), push, release]), [
680+ { repo_id: "rep_web", commits: [{ sha: "m12", landed_at: iso(NOW - 2 * HOUR) }, { sha: "p1", landed_at: iso(NOW - HOUR) }] },
681+ ]);
682+ const answers = [
683+ {
684+ repo_id: "rep_web",
685+ deploys: true,
686+ commits: [
687+ { sha: "m12", state: "live" as const, deployed_at: iso(NOW - 2 * HOUR + 9 * 60_000), url: "/acme/web/actions/runs/run_1", environment: "production" },
688+ { sha: "p1", state: "waiting" as const, deployed_at: null, url: null, environment: null },
689+ ],
690+ },
691+ ];
692+ const [shippedMerge, shippedPush, untouched] = withShipped([merge(), push, release], answers);
693+ assert.deepEqual(shippedMerge!.shipped, { state: "live", at: NOW - 2 * HOUR + 9 * 60_000, to: "/acme/web/actions/runs/run_1" });
694+ assert.equal(shippedLabel(shippedMerge!.shipped!, shippedMerge!), "Live 9 min after merge");
695+ assert.equal(shippedLabel(shippedPush!.shipped!, shippedPush!), "Waiting to deploy");
696+ assert.equal(shippedLabel({ state: "failed", at: null, to: "/x" }, push), "Deploy failed");
697+ assert.equal(shippedLabel({ state: "deploying", at: null, to: null }, push), "Deploying");
698+ assert.equal(shippedLabel({ state: "live", at: push.at + 3 * HOUR, to: null }, push), "Live 3 h after push");
699+ assert.equal(untouched!.shipped, undefined);
700+ // A project that does not deploy says nothing; deployments not answering leaves rows as they were.
701+ assert.equal(withShipped([merge()], [{ repo_id: "rep_web", deploys: false, commits: [] }])[0]!.shipped, undefined);
702+ assert.deepEqual(withShipped([merge()], null), [merge()]);
703+});
704+
705+test("a change made in a harness session carries the agent mark, linked to the session", () => {
706+ const [marked, plain] = withSessions([merge(), merge({ key: "landed:acme/web#13", sha: "m13" })], [
707+ { repo: { namespace: "acme", name: "web" }, pulls: [{ number: 12, sessions: [{ id: "ses_1", harness: "claude_code" }, { id: "ses_2", harness: "codex" }] }] },
708+ ]);
709+ assert.deepEqual(marked!.agent, { harness: "claude_code", to: "/acme/web/work/sessions/ses_1" });
710+ assert.equal(agentLabel(marked!.agent!), "Made in a Claude Code session");
711+ assert.equal(plain!.agent, undefined);
712+ assert.equal(agentLabel({ harness: null, to: null }), "Made in a session");
713+ assert.deepEqual(sessionTrailers("Fix the bell\n\nG1t-Session: ses_01ABC\ng1t-session: ses_01abc\nG1t-Session: ses_02x"), ["ses_01abc", "ses_02x"]);
714+ assert.deepEqual(sessionTrailers("Mentions G1t-Session: ses_1 in prose"), []);
715+});
716+
717+test("each person's push to a default branch is a row: its commit, who pushed, and its session", () => {
718+ const repo = { namespace: "acme", name: "web", defaultBranch: "main" };
719+ const history = {
720+ rep_web: [
721+ { hash: "c3", parents: ["c2"], message: "Fix the bell's count\n\nG1t-Session: ses_9" },
722+ { hash: "c2", parents: ["c1"], message: "Tidy" },
723+ { hash: "c1", parents: ["c0"], message: "Start" },
724+ { hash: "c0", parents: [], message: "Root" },
725+ ],
726+ };
727+ const push = (id: string, after: string, before: string, at: number, actor = "usr_ana") => ({
728+ repoId: "rep_web",
729+ repo,
730+ push: { id, time: iso(at), actor, data: { after, before } },
731+ by: "ana",
732+ });
733+ const span = { from: NOW - DAY, now: NOW };
734+ const rows = pushRows([push("evt_2", "c3", "c2", NOW - HOUR), push("evt_1", "c2", "c0", NOW - 2 * HOUR), push("evt_0", "c1", "c0", NOW - 2 * DAY)], history, span);
735+ assert.deepEqual(
736+ rows.map((row) => [row.title, row.detail, row.sha, row.to, row.agent?.to ?? null]),
737+ [
738+ ["Fix the bell's count", "web · pushed to main · by ana", "c3", "/acme/web/commit/c3", "/acme/web/work/sessions/ses_9"],
739+ ["2 commits pushed to main", "web · Tidy · by ana", "c2", "/acme/web/activity", null],
740+ ],
741+ );
742+ // g1t's own pushes (a pull request landing) are listed as the merge instead.
743+ assert.deepEqual(pushRows([push("evt_3", "c3", "c2", NOW - HOUR, "usr_g1t_agent")], history, span), []);
744+});
745+
746+test("what landed over the week reads day by day, newest first", () => {
747+ const tz = "America/Los_Angeles";
748+ const days = landedByDay([merge({ at: NOW - DAY }), merge({ key: "b", at: NOW - HOUR }), merge({ key: "c", at: NOW - 3 * DAY })], NOW, tz);
749+ assert.deepEqual(days.map((day) => [day.label, day.rows.length]), [["Today", 1], ["Yesterday", 1], ["Tue, Oct 6", 1]]);
750+});
751+
752+test("while you were away: what settled by itself, what shipped, and what still needs you", () => {
753+ const span = { from: NOW - DAY, now: NOW };
754+ const settled = settledIn(
755+ [
756+ item({ id: "n1", doneAt: iso(NOW - HOUR), settledReason: "CI passed in run 330" }),
757+ item({ id: "n2", doneAt: iso(NOW - 2 * HOUR), settledReason: "Merged", settledBy: "ana" }),
758+ // Marked done by hand: yours, not g1t's.
759+ item({ id: "n3", doneAt: iso(NOW - HOUR) }),
760+ // Before the span, or another workspace's.
761+ item({ id: "n4", doneAt: iso(NOW - 2 * DAY), settledReason: "Merged" }),
762+ item({ id: "n5", doneAt: iso(NOW - HOUR), settledReason: "Merged", workspace: "other", repo: "other/x" }),
763+ ],
764+ "acme",
765+ true,
766+ span,
767+ );
768+ assert.deepEqual(
769+ settled.map((row) => [row.key, row.why, row.where]),
770+ [
771+ ["settled:n1", "CI passed in run 330", "acme/web#12"],
772+ ["settled:n2", "Merged by @ana", "acme/web#12"],
773+ ],
774+ );
775+ assert.equal(settledIn([item({ doneAt: iso(NOW - HOUR), settledReason: "Merged" })], "acme", false, span).length, 0, "nothing about repositories without Code");
776+ const away = awayOf({
777+ settled,
778+ landed: [merge({ shipped: { state: "live", at: NOW, to: null } }), merge({ key: "x", shipped: { state: "waiting", at: null, to: null } }), merge({ key: "y" })],
779+ needs: 3,
780+ });
781+ assert.deepEqual({ ...away, settled: away.settled.length }, { settled: 2, shipped: 1, waiting: 1, deploying: 0, failed: 0, needs: 3 });
782+});
+243−0
1111 ActivityDigest,
1212 ActorCount,
1313 ChatActivity,
14+ Commit,
1415 Deployment,
16+ Harness,
1517 InboxItem,
1618 ProjectDeploys,
1719 Pull,
20+ PullSessions,
1821 RepoDigest,
1922 RepoPath,
23+ ShipState,
24+ ShippedRepo,
2025 Statement,
2126 WorkflowRun,
2227 } from "@g1t/contracts";
2328
29+import { HARNESS_NAMES } from "./coordination.ts";
30+import { type PushRecord, pushedByPerson, pushedCommits } from "./mission-control.ts";
31+import { settledLine } from "./inbox.ts";
32+
2433 const MINUTE = 60_000;
2534 const HOUR = 60 * MINUTE;
2635 const DAY = 24 * HOUR;
164173 /** A pull request with where it is: what Home reads from Code. */
165174 export type CodePull = Pick<Pull, "number" | "title" | "status" | "mergedAt" | "createdAt" | "updatedAt"> & {
166175 repo: RepoPath;
176+ /** The repository's id, and the commit the merge put on the base branch: what deployments ship. */
177+ repoId?: string;
178+ mergeCommit?: string | null;
167179 author: { username: string; kind?: string };
168180 /** Who merged it, when it merged. */
169181 mergedBy?: string | null;
504516 detail: string;
505517 to: string;
506518 at: number;
519+ /** A merge's or a push's commit on the default branch, and its repository's id: what a deployment ships. */
520+ sha?: string | null;
521+ repoId?: string | null;
522+ /** Whether it is live yet (`withShipped`); null when its project does not deploy, or it could not be read. */
523+ shipped?: ShipInfo | null;
524+ /** Made in a harness session (`withSessions`, or a `G1t-Session:` trailer): the agent marker, and the session. */
525+ agent?: AgentMark | null;
507526 };
508527
528+/** Whether a change is live: shipped by a production deployment, on its way, waiting, or the deployment after it failed. */
529+export type ShipInfo = {
530+ state: ShipState;
531+ /** Epoch ms: when the deployment that shipped it went live. */
532+ at: number | null;
533+ /** The deployment (or the run that deploys it). */
534+ to: string | null;
535+};
536+
537+/** A change made in a harness session: which harness when known, and the session's page. */
538+export type AgentMark = { harness: Harness | null; to: string | null };
539+
509540 /** Every pull request that merged in the span, newest first. */
510541 export function landedIn(pulls: CodePull[], span: Pick<Span, "from" | "now">): LandedChange[] {
511542 return pulls
520551 detail: `${pull.repo.name}#${pull.number} · by ${agent ? `@${pull.author.username}` : pull.author.username}${mergedBy}`,
521552 to: `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`,
522553 at: Date.parse(pull.mergedAt!),
554+ sha: pull.mergeCommit ?? null,
555+ repoId: pull.repoId ?? null,
523556 };
524557 })
525558 .sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
601634 return parts.flat().sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
602635 }
603636
637+// --- Pushes straight to a default branch, each ---------------------------------
638+
639+/** A push to a default branch, with the history it brought read back. */
640+export type DirectPush = {
641+ repoId: string;
642+ repo: RepoRef;
643+ push: PushRecord & { id: string };
644+ /** Who pushed, by name; `someone` when it could not be read. */
645+ by: string;
646+};
647+
648+/** The sessions a commit message names in `G1t-Session:` trailers. */
649+export function sessionTrailers(message: string): string[] {
650+ const ids = [...message.matchAll(/^G1t-Session:\s*(ses_[0-9a-z]+)\s*$/gim)].map((match) => match[1]!.toLowerCase());
651+ return [...new Set(ids)];
652+}
653+
654+/** A commit message's first line. */
655+const subject = (message: string) => message.split("\n", 1)[0]!.trim();
656+
657+/**
658+ * Each person's push to a default branch in the span, newest first, as a
659+ * landed change: its commit when it brought one ("Fix the bell's count"),
660+ * else how many; the session a commit names in a `G1t-Session:` trailer
661+ * as its agent mark. `history` is each repository's default branch read
662+ * back from its newest push, newest first; a push whose commits are not
663+ * in it (rewritten since) is left out, and so is g1t's own (a pull
664+ * request landing: Landed lists the merge).
665+ */
666+export function pushRows(
667+ pushes: DirectPush[],
668+ history: Record<string, Pick<Commit, "hash" | "parents" | "message">[]>,
669+ span: Pick<Span, "from" | "now">,
670+ byG1t: (commit: Pick<Commit, "hash" | "parents" | "message">) => boolean = () => false,
671+): LandedChange[] {
672+ const rows: LandedChange[] = [];
673+ for (const { repoId, repo, push, by } of pushes) {
674+ if (!pushedByPerson(push) || !within(push.time, span)) continue;
675+ const commits = history[repoId] ?? [];
676+ const start = commits.findIndex((commit) => commit.hash === push.data.after);
677+ if (start === -1) continue;
678+ const brought = pushedCommits(commits.slice(start), push.data.before, byG1t);
679+ if (brought.length === 0) continue;
680+ const newest = brought[0]!;
681+ const session = brought.flatMap((commit) => sessionTrailers(commit.message))[0] ?? null;
682+ const branch = repo.defaultBranch;
683+ rows.push({
684+ key: `push:${repoId}:${push.id}`,
685+ kind: "push",
686+ title: brought.length === 1 ? subject(newest.message) || `1 commit pushed to ${branch}` : `${plural(brought.length, "commit")} pushed to ${branch}`,
687+ detail: `${repo.name} · ${brought.length === 1 ? `pushed to ${branch}` : subject(newest.message)} · by ${by}`,
688+ to: brought.length === 1 ? `/${repo.namespace}/${repo.name}/commit/${newest.hash}` : `/${repo.namespace}/${repo.name}/activity`,
689+ at: Date.parse(push.time),
690+ sha: push.data.after,
691+ repoId,
692+ agent: session ? { harness: null, to: `/${repo.namespace}/${repo.name}/work/sessions/${session}` } : null,
693+ });
694+ }
695+ return rows;
696+}
697+
698+// --- Shipped: merge to deploy --------------------------------------------------
699+
700+/**
701+ * Each merge and push with whether it is live yet, from what deployments
702+ * recorded as production deployed (`shipped`). A project that does not
703+ * deploy says nothing; a change deployments did not answer for keeps none.
704+ */
705+export function withShipped(rows: LandedChange[], answers: ShippedRepo[] | null): LandedChange[] {
706+ if (!answers) return rows;
707+ const byRepo = new Map(answers.map((answer) => [answer.repo_id, answer]));
708+ return rows.map((row) => {
709+ if (!row.sha || !row.repoId || (row.kind !== "merge" && row.kind !== "push")) return row;
710+ const answer = byRepo.get(row.repoId);
711+ const commit = answer?.deploys ? answer.commits.find((c) => c.sha === row.sha) : null;
712+ if (!commit) return row;
713+ return { ...row, shipped: { state: commit.state, at: commit.deployed_at ? Date.parse(commit.deployed_at) : null, to: commit.url } };
714+ });
715+}
716+
717+/** What to ask deployments: each repository's merges and pushes with their commit and when they landed. */
718+export function shippedAsks(rows: LandedChange[]): { repo_id: string; commits: { sha: string; landed_at: string }[] }[] {
719+ const byRepo = new Map<string, { sha: string; landed_at: string }[]>();
720+ for (const row of rows) {
721+ if (!row.sha || !row.repoId || (row.kind !== "merge" && row.kind !== "push")) continue;
722+ const list = byRepo.get(row.repoId) ?? [];
723+ list.push({ sha: row.sha, landed_at: new Date(row.at).toISOString() });
724+ byRepo.set(row.repoId, list);
725+ }
726+ return [...byRepo].map(([repo_id, commits]) => ({ repo_id, commits }));
727+}
728+
729+/** How long something took, short: "1 min", "45 min", "3 h", "2 d". */
730+function took(ms: number): string {
731+ const minutes = Math.max(1, Math.round(ms / MINUTE));
732+ if (minutes < 60) return `${minutes} min`;
733+ const hours = Math.round(minutes / 60);
734+ if (hours < 48) return `${hours} h`;
735+ return `${Math.round(hours / 24)} d`;
736+}
737+
738+/** "Live 9 min after merge", "Waiting to deploy", "Deploying", "Deploy failed". */
739+export function shippedLabel(ship: ShipInfo, row: Pick<LandedChange, "kind" | "at">): string {
740+ switch (ship.state) {
741+ case "live":
742+ return ship.at != null ? `Live ${took(ship.at - row.at)} after ${row.kind === "merge" ? "merge" : "push"}` : "Live";
743+ case "deploying":
744+ return "Deploying";
745+ case "failed":
746+ return "Deploy failed";
747+ default:
748+ return "Waiting to deploy";
749+ }
750+}
751+
752+// --- Done with an agent --------------------------------------------------------
753+
754+/**
755+ * Each merged pull request with the harness session that made it, when
756+ * work tied one to it (`pullSessions`): the first, by when it started.
757+ */
758+export function withSessions(rows: LandedChange[], sessions: { repo: RepoPath; pulls: PullSessions[] }[] | null): LandedChange[] {
759+ if (!sessions) return rows;
760+ const found = new Map<string, AgentMark>();
761+ for (const { repo, pulls } of sessions) {
762+ for (const pull of pulls) {
763+ const first = pull.sessions[0];
764+ if (first) found.set(`landed:${pullKey(repo, pull.number)}`, { harness: first.harness, to: `/${repo.namespace}/${repo.name}/work/sessions/${first.id}` });
765+ }
766+ }
767+ return rows.map((row) => (row.agent || !found.has(row.key) ? row : { ...row, agent: found.get(row.key)! }));
768+}
769+
770+/** The agent mark's words: "Made in a Claude Code session", or "Made in a session". */
771+export function agentLabel(mark: AgentMark): string {
772+ return mark.harness ? `Made in a ${HARNESS_NAMES[mark.harness] ?? "harness"} session` : "Made in a session";
773+}
774+
775+// --- Day by day ----------------------------------------------------------------
776+
777+/** What landed on one day, newest first: "Today", "Yesterday", "Mon, Oct 12". */
778+export type LandedDay = { key: string; label: string; rows: LandedChange[] };
779+
780+/** What landed, by the day it landed in the reader's time zone, newest day first. */
781+export function landedByDay(rows: LandedChange[], now: number, timeZone: string | null): LandedDay[] {
782+ const days: LandedDay[] = [];
783+ const today = dayIn(now, timeZone);
784+ const yesterday = dayIn(now - DAY, timeZone);
785+ for (const row of [...rows].sort((a, b) => b.at - a.at || a.key.localeCompare(b.key))) {
786+ const key = dayIn(row.at, timeZone);
787+ let day = days.find((d) => d.key === key);
788+ if (!day) {
789+ const label = key === today ? "Today" : key === yesterday ? "Yesterday" : formatIn(row.at, timeZone, { weekday: "short", month: "short", day: "numeric" });
790+ day = { key, label, rows: [] };
791+ days.push(day);
792+ }
793+ day.rows.push(row);
794+ }
795+ return days;
796+}
797+
798+// --- While you were away -------------------------------------------------------
799+
800+/** Something that settled by itself in the span: what it was, why it settled, where. */
801+export type SettledRow = { key: string; title: string; why: string; where: string | null; to: string; at: number };
802+
803+/** What g1t settled for you, what shipped, and what still needs you, in the span. */
804+export type Away = {
805+ settled: SettledRow[];
806+ /** Merges and pushes in the span that went live, are waiting, deploying, or whose deployment failed. */
807+ shipped: number;
808+ waiting: number;
809+ deploying: number;
810+ failed: number;
811+ /** Rows in Where you're needed. */
812+ needs: number;
813+};
814+
815+/**
816+ * Your notifications that settled by themselves in the span (a later run
817+ * passed, it merged, it deployed), newest first; one marked done by hand
818+ * is yours, not g1t's. Only this workspace's, and none about a repository
819+ * for a member without Code access.
820+ */
821+export function settledIn(items: InboxItem[] | null, slug: string, code: boolean, span: Pick<Span, "from" | "now">): SettledRow[] {
822+ return (items ?? [])
823+ .filter((item) => item.doneAt != null && settledLine(item) && within(item.doneAt, span))
824+ .filter((item) => (item.workspace ?? item.repo?.split("/")[0] ?? "").toLowerCase() === slug && (code || !item.repo))
825+ .map((item) => ({
826+ key: `settled:${item.id}`,
827+ // An issue's or pull request's own title; else the item's ("CI failed on main in acme/web").
828+ title: item.number != null ? item.body || item.title : item.title,
829+ why: settledLine(item),
830+ where: item.repo ? `${item.repo}${item.number != null ? `#${item.number}` : ""}` : null,
831+ to: item.url,
832+ at: Date.parse(item.doneAt!),
833+ }))
834+ .sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
835+}
836+
837+export function awayOf(input: { settled: SettledRow[]; landed: LandedChange[] | null; needs: number }): Away {
838+ const count = (state: ShipState) => (input.landed ?? []).filter((row) => row.shipped?.state === state).length;
839+ return { settled: input.settled, shipped: count("live"), waiting: count("waiting"), deploying: count("deploying"), failed: count("failed"), needs: input.needs };
840+}
841+
604842 /** A deployment's maker as a member key: an id becomes `user:<id>`, a key stays as it is. */
605843 export function deployActorKey(createdBy: string | null | undefined): string | null {
606844 const by = (createdBy ?? "").trim();
11011339 return `${Math.floor(minutes / (24 * 60))} d`;
11021340 }
11031341
1342+/** "3:40 PM": the time of day alone, for a row under its day's heading. */
1343+export function timeOfDay(at: number, timeZone: string | null): string {
1344+ return formatIn(at, timeZone, { hour: "numeric", minute: "2-digit" });
1345+}
1346+
11041347 /** "3:40 PM" when it is the same day as `now`, else "Oct 7". */
11051348 export function whenShort(at: number, now: number, timeZone: string | null): string {
11061349 if (dayIn(at, timeZone) === dayIn(now, timeZone)) return formatIn(at, timeZone, { hour: "numeric", minute: "2-digit" });
+10−0
2222 subscriptionFromForm,
2323 subscriptionLine,
2424 tabCount,
25+ settledLine,
2526 updatesLabel,
2627 watchFromForm,
2728 watchLabel,
157158 assert.equal(updatesLabel(4), "4 updates");
158159 });
159160
161+test("a done item that settled by itself says why, and who did it", () => {
162+ const done = "2026-10-14T12:00:00.000Z";
163+ assert.equal(settledLine({ doneAt: done, settledReason: "CI passed in run 330", settledBy: null }), "CI passed in run 330");
164+ assert.equal(settledLine({ doneAt: done, settledReason: "Merged", settledBy: "@syntaqx" }), "Merged by @syntaqx");
165+ // Marked done by hand, or not done: nothing.
166+ assert.equal(settledLine({ doneAt: done, settledReason: null, settledBy: null }), "");
167+ assert.equal(settledLine({ doneAt: null, settledReason: "Merged", settledBy: "ana" }), "");
168+});
169+
160170 test("watching is read from the menu, and a custom watch of nothing is the default", () => {
161171 const form = (fields: [string, string][]) => {
162172 const data = new FormData();
+12−0
209209 return count && count > 1 ? `${count} updates` : "";
210210 }
211211
212+/**
213+ * Why a done item settled by itself, as its card says it: the reason, and
214+ * who did it when someone did ("Merged by @ana", "CI passed in run 330").
215+ * Empty for an item not done, or marked done by hand.
216+ */
217+export function settledLine(item: Pick<InboxItem, "doneAt" | "settledReason" | "settledBy">): string {
218+ const reason = item.settledReason?.trim();
219+ if (!item.doneAt || !reason) return "";
220+ const by = item.settledBy?.trim().replace(/^@/, "");
221+ return by ? `${reason} by @${by}` : reason;
222+}
223+
212224 /** The ways to watch a repository, in the order the menu shows them. */
213225 export const WATCH_CHOICES: { level: WatchLevel; label: string; detail: string }[] = [
214226 { level: "participating", label: "Participating and @mentions", detail: "Only what you take part in, or are mentioned in." },
+5−2
495495 case "preferences":
496496 set({ preferences: event.preferences });
497497 break;
498− case "inbox":
499− set({ inbox: event.unread });
498+ case "inbox": {
499+ // Items that settled by themselves: their notifications go from every tab's recent list.
500+ const settled = new Set(Array.isArray(event.settled) ? event.settled : []);
501+ set(settled.size > 0 ? { inbox: event.unread, recent: state.recent.filter((n) => !n.inbox_thread || !settled.has(n.inbox_thread)) } : { inbox: event.unread });
500502 break;
503+ }
501504 case "presence": {
502505 const people = mergePeople(state.people, event.people, event.full);
503506 if (people !== state.people) set({ people });
+19−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { robotsTxt } from "./robots.ts";
5+
6+test("everything may be crawled but a file's blame", () => {
7+ const text = robotsTxt("https://g1t.example");
8+ assert.match(text, /^User-agent: \*\nAllow: \/\n/);
9+ assert.match(text, /^Disallow: \/\*\?blame=$/m);
10+ assert.match(text, /^Disallow: \/\*&blame=$/m);
11+ assert.match(text, /^Disallow: \/\*\/\*\/blame\/$/m);
12+ assert.match(text, /^Sitemap: https:\/\/g1t\.example\/sitemap\.xml$/m);
13+ assert.doesNotMatch(text, /docs\.g1t\.sh/, "a self-hosted g1t has no docs site");
14+ assert.ok(text.endsWith("\n"));
15+});
16+
17+test("g1t.sh names its docs' map too", () => {
18+ assert.match(robotsTxt("https://g1t.sh"), /^Sitemap: https:\/\/docs\.g1t\.sh\/sitemap-index\.xml$/m);
19+});
+23−0
1+/**
2+ * What /robots.txt says: everything may be crawled but a file's blame
3+ * (`?blame=1`, and the `/<owner>/<repo>/blame/…` data it fetches), and
4+ * where the site's maps are.
5+ *
6+ * Blame is the costliest file view to work out, one per file per commit a
7+ * crawler finds, and adds nothing an index needs that the file itself does
8+ * not have: on 2026-10-14 crawlers asked for 1,150 blame pages in four
9+ * hours, against a handful from people. The links to it are `nofollow` too,
10+ * and its page is `noindex` (routes/repo/blob.tsx).
11+ */
12+
13+/** g1t.sh's docs keep their own map; a self-hosted g1t has no docs site. */
14+const DOCS_SITEMAP = "https://docs.g1t.sh/sitemap-index.xml";
15+
16+/** Paths crawlers are kept from, wherever the query puts `blame`. */
17+export const DISALLOWED = ["/*?blame=", "/*&blame=", "/*/*/blame/"];
18+
19+export function robotsTxt(site: string): string {
20+ const lines = ["User-agent: *", "Allow: /", ...DISALLOWED.map((path) => `Disallow: ${path}`), "", `Sitemap: ${site}/sitemap.xml`];
21+ if (site === "https://g1t.sh") lines.push(`Sitemap: ${DOCS_SITEMAP}`);
22+ return `${lines.join("\n")}\n`;
23+}
+21−0
1+import { useEffect, useState } from "react";
2+
3+/**
4+ * `initial` until `promise` settles, then what it resolved to: for a value
5+ * a loader streams in after the page, shown in place when it arrives. A
6+ * promise that rejects leaves `initial`.
7+ */
8+export function useArrived<T, I = T>(promise: Promise<T>, initial: I): T | I {
9+ const [arrived, setArrived] = useState<{ from: Promise<T>; value: T } | null>(null);
10+ useEffect(() => {
11+ let current = true;
12+ promise.then(
13+ (value) => current && setArrived({ from: promise, value }),
14+ () => {},
15+ );
16+ return () => {
17+ current = false;
18+ };
19+ }, [promise]);
20+ return arrived?.from === promise ? arrived.value : initial;
21+}
+2−0
248248 route(":owner/:repo/production-elsewhere.jpg", "routes/repo/production-elsewhere.ts"),
249249 // A file as it is, sent on to the usercontent origin (lib/usercontent.ts).
250250 route(":owner/:repo/raw/:ref/*", "routes/repo/raw.ts"),
251+ // A file's blame as JSON, fetched by its blame view (routes/repo/blob.tsx).
252+ route(":owner/:repo/blame/:ref/*", "routes/repo/blame-data.ts"),
251253 // A project: its overview first, its repository's code under Code. The
252254 // 1:1 project of a repository has the repository's name, so every
253255 // repository address below keeps working.
+32−0
1+/**
2+ * A file's blame at a ref, as JSON, for the blame view to fetch once the
3+ * file is on screen: `/<owner>/<repo>/blame/<ref>/<path>`. A first blame
4+ * walks the file's history in repos and can take longer than the page's
5+ * stream may stay open (entry.server.tsx `streamTimeout`), so it is asked
6+ * for on its own request instead; once worked out, repos keeps it and it
7+ * comes back at once. Kept out of indexes like the blame view
8+ * (lib/robots.ts).
9+ */
10+import type { Route } from "./+types/blame-data";
11+import { showCommits } from "../../lib/commit-people.server";
12+import { repos } from "../../lib/services.server";
13+import { getViewer } from "../../lib/session.server";
14+
15+const HEADERS = {
16+ "cache-control": "private, no-store",
17+ "x-robots-tag": "noindex, nofollow",
18+};
19+
20+export async function loader({ params, context }: Route.LoaderArgs) {
21+ const viewer = getViewer(context);
22+ const path = { namespace: params.owner, name: params.repo };
23+ const file = params["*"] ?? "";
24+ const found = await repos.blame(path, viewer, params.ref, file).catch(() => null);
25+ if (!found?.ok) {
26+ const status = found?.error.code === "not_found" ? 404 : 502;
27+ return Response.json({ blame: null }, { status, headers: HEADERS });
28+ }
29+ // Every commit's people at once.
30+ const blame = { ...found.value, commits: await showCommits(found.value.commits) };
31+ return Response.json({ blame }, { headers: HEADERS });
32+}
+41−27
11 import { isCodeownersPath } from "@g1t/contracts";
2+import { useEffect, useState } from "react";
23
34 import type { Route } from "./+types/blob";
45 import { page } from "../../lib/meta";
56 import { CodeownersFileErrors } from "../../components/codeowners";
67 import { BlobView } from "../../components/repo-view";
8+import type { ShownBlame } from "../../lib/commit-people";
79 import { highlightLines } from "../../lib/highlight.server";
810 import { repos, work } from "../../lib/services.server";
9−import { showCommits } from "../../lib/commit-people.server";
1011 import { redirectIfBranchRenamed } from "../../lib/branch-redirect.server";
1112 import { getViewer, unwrap } from "../../lib/session.server";
1213
1314 export function meta({ params, ...args }: Route.MetaArgs) {
14− return page(args, { title: `${params["*"]} · ${params.owner}/${params.repo} · g1t` });
15+ const tags = page(args, { title: `${params["*"]} · ${params.owner}/${params.repo} · g1t` });
16+ // A file's blame is kept out of search indexes; the file itself is not (lib/robots.ts).
17+ const blame = new URLSearchParams(args.location.search).has("blame");
18+ return blame ? [...tags, { name: "robots", content: "noindex, nofollow" }] : tags;
1519 }
1620
1721 export async function loader({ params, context, request }: Route.LoaderArgs) {
1923 const path = { namespace: params.owner, name: params.repo };
2024 const file = params["*"] ?? "";
2125 const wantsBlame = new URL(request.url).searchParams.has("blame");
22− const [found, blame, list, checked] = await Promise.all([
26+ const [found, list, checked] = await Promise.all([
2327 repos.blob(path, viewer, params.ref, file),
24− wantsBlame ? repos.blame(path, viewer, params.ref, file) : null,
2528 // For the branch menu; the page still shows without it.
2629 repos.branches(path, viewer).catch(() => null),
2730 // A CODEOWNERS file is checked as it is shown; the file shows without it.
3235 // A branch that was renamed: the same file on its new name.
3336 if (!found.ok && found.error.code === "not_found") await redirectIfBranchRenamed(request, path, viewer, params.ref);
3437 const blob = unwrap(found);
35− if (blame?.ok && blob.text != null) {
36− return {
37− blob,
38− html: null,
39− blame: {
40− // Every commit's people at once.
41− blame: { ...blame.value, commits: await showCommits(blame.value.commits) },
42− lines: await highlightLines(blob.path, blob.text),
43− },
44− branches,
45− codeowners,
46− };
47− }
48− return {
49− blob,
50− html: blob.text == null ? null : await highlightLines(blob.path, blob.text),
51− blame: null,
52− branches,
53− codeowners,
54− };
38+ const html = blob.text == null ? null : await highlightLines(blob.path, blob.text);
39+ // The blame itself is fetched by the page once the file shows
40+ // (routes/repo/blame-data.ts): a first one can take longer than the
41+ // page's stream may stay open.
42+ return { blob, html, wantsBlame: wantsBlame && blob.text != null, branches, codeowners };
5543 }
5644
5745 export default function Blob({ loaderData, params }: Route.ComponentProps) {
58− const { codeowners, blob } = loaderData;
46+ const { codeowners, blob, html, wantsBlame } = loaderData;
5947 // Its errors' lines are marked, when they are this file's.
6048 const own = codeowners != null && codeowners.path === blob.path;
49+ const blame = useBlame(wantsBlame ? `/${params.owner}/${params.repo}/blame/${encodeURIComponent(blob.ref)}/${encodePath(blob.path)}` : null);
6150 return (
6251 <BlobView
6352 blob={blob}
64− html={loaderData.html}
65− blame={loaderData.blame}
53+ html={html}
54+ blame={blame === null ? null : typeof blame === "string" ? blame : { blame, lines: html }}
6655 branches={loaderData.branches}
6756 notice={
6857 codeowners && (
7362 />
7463 );
7564 }
65+
66+/** The blame at `url`: "waiting" until it comes, "failed" when it could not
67+ * be had, null when none was asked for. */
68+function useBlame(url: string | null): ShownBlame | "waiting" | "failed" | null {
69+ const [answered, setAnswered] = useState<{ url: string; blame: ShownBlame | "failed" } | null>(null);
70+ useEffect(() => {
71+ if (!url) return;
72+ const stop = new AbortController();
73+ fetch(url, { signal: stop.signal, headers: { accept: "application/json" } })
74+ .then(async (response) => {
75+ const body = (await response.json()) as { blame: ShownBlame | null };
76+ setAnswered({ url, blame: response.ok && body.blame ? body.blame : "failed" });
77+ })
78+ .catch(() => {
79+ if (!stop.signal.aborted) setAnswered({ url, blame: "failed" });
80+ });
81+ return () => stop.abort();
82+ }, [url]);
83+ if (!url) return null;
84+ return answered?.url === url ? answered.blame : "waiting";
85+}
86+
87+function encodePath(path: string): string {
88+ return path.split("/").map(encodeURIComponent).join("/");
89+}
+2−16
2222 Users,
2323 Wrench,
2424 } from "lucide-react";
25−import { Suspense, useEffect, useState } from "react";
25+import { Suspense, useState } from "react";
2626 import { Await, Form, Link, redirect } from "react-router";
2727
2828 import {
4848 import { highlightFirstFiles } from "../../lib/highlight.server";
4949 import { excerpt, page } from "../../lib/meta";
5050 import { openedBy } from "../../lib/opened-by";
51+import { useArrived } from "../../lib/use-arrived";
5152 import { cloneUrl, useAddresses } from "../../lib/addresses";
5253 import { DiffView } from "../../components/diff-view";
5354 import { DetailsDisclosure, detailsSummary } from "../../components/details-disclosure";
685686 }
686687
687688 /** The box at the foot of the conversation saying what stands before a merge. */
688−/** `initial` until `promise` settles, then what it resolved to. */
689−function useArrived<T>(promise: Promise<T>, initial: T): T {
690− const [arrived, setArrived] = useState<{ from: Promise<T>; value: T } | null>(null);
691− useEffect(() => {
692− let current = true;
693− promise.then(
694− (value) => current && setArrived({ from: promise, value }),
695− () => {},
696− );
697− return () => {
698− current = false;
699− };
700− }, [promise]);
701− return arrived?.from === promise ? arrived.value : initial;
702−}
703689
704690 function StatusBox({ children }: { children: React.ReactNode }) {
705691 return (
+4−9
11 /**
2− * /robots.txt: everything may be crawled, and where the site's maps are.
3− * Served from the request's own origin, so a self-hosted g1t names itself.
2+ * /robots.txt (lib/robots.ts). Served from the request's own origin, so a
3+ * self-hosted g1t names itself.
44 */
55 import type { Route } from "./+types/robots-txt";
6−
7−/** g1t.sh's docs keep their own map; a self-hosted g1t has no docs site. */
8−const DOCS_SITEMAP = "https://docs.g1t.sh/sitemap-index.xml";
6+import { robotsTxt } from "../lib/robots";
97
108 export function loader({ request }: Route.LoaderArgs) {
11− const site = new URL(request.url).origin;
12− const lines = ["User-agent: *", "Allow: /", "", `Sitemap: ${site}/sitemap.xml`];
13− if (site === "https://g1t.sh") lines.push(`Sitemap: ${DOCS_SITEMAP}`);
14− return new Response(`${lines.join("\n")}\n`, {
9+ return new Response(robotsTxt(new URL(request.url).origin), {
1510 headers: {
1611 "content-type": "text/plain; charset=utf-8",
1712 "cache-control": "public, max-age=86400",
+56−9
55
66 import type { Route } from "./+types/home";
77 import {
8+ AwayCard,
89 Card,
910 DeploysCard,
1011 LandedCard,
2223 import { sidebarOrNull } from "../../lib/chat.server";
2324 import {
2425 type Attention,
26+ type Away,
2527 type Digest,
2628 attention,
29+ awayOf,
2730 deploysIn,
2831 digestOf,
2932 landed,
3134 landedFromActivity,
3235 landedIn,
3336 parseWindow,
37+ pushRows,
38+ settledIn,
39+ withSessions,
40+ withShipped,
3441 running,
3542 runningDeploys,
3643 runningWorkflows,
3744 spanFor,
45+ within,
3846 spanSentence,
3947 waitingSentence,
4048 } from "../../lib/home";
4452 loadChatActivity,
4553 loadCodeWork,
4654 loadDeploys,
55+ loadDirectPushes,
4756 loadLastVisit,
4857 loadNames,
58+ loadPullSessions,
4959 loadRepos,
60+ loadSettled,
61+ loadShipped,
5062 loadSpend,
5163 loadWorkflowRuns,
5264 reachFor,
117129 const deploysP = code ? loadDeploys(viewer, slug, span.from).catch(() => null) : Promise.resolve(null);
118130 const activityP = reposP.then((owned) => loadActivity(slug, span, owned)).catch(() => null);
119131 const chatActivityP = loadChatActivity(viewer, slug, span).catch(() => null);
120− const namesP = Promise.all([activityP, chatActivityP, deploysP])
121− .then(([activity, chatActivity, deploys]) => loadNames(actorKeys(activity, chatActivity, deploys)))
132+ // Each push to a default branch, with the commits it brought: the projects the digest says were pushed to.
133+ const pushesP = code ? Promise.all([activityP, reposP]).then(([activity, owned]) => loadDirectPushes(viewer, activity, owned, span)).catch(() => null) : Promise.resolve(null);
134+ const namesP = Promise.all([activityP, chatActivityP, deploysP, pushesP])
135+ .then(([activity, chatActivity, deploys, pushes]) => loadNames([...actorKeys(activity, chatActivity, deploys), ...(pushes?.pushes ?? []).map((push) => push.actorKey)]))
122136 .catch(() => ({}) as Record<string, string>);
123137
124138 // What people did, from events and chat.
125139 const digest: Promise<Digest | null> = Promise.all([activityP, chatActivityP, namesP, reposP])
126140 .then(([activity, chatActivity, names, owned]) => digestOf({ slug, repos: repoRefs(owned), activity, chat: chatActivity, names, code }))
127141 .catch(() => null);
128− const happened = Promise.all([codeWorkP, deploysP, activityP, namesP, reposP])
129− .then(([codeWork, deploys, activity, names, owned]) => {
142+ const happened = Promise.all([codeWorkP, deploysP, activityP, namesP, reposP, pushesP])
143+ .then(async ([codeWork, deploys, activity, names, owned, pushes]) => {
130144 const deployRows = deploys ? deploysIn(deploys.projects, slug, span, names) : null;
131145 const merges = codeWork && codeWork !== "no_access" ? landedIn(codeWork.pulls, span) : null;
146+ // Each push on its own where its project's pushes were read; the digest's one row a project elsewhere.
147+ const named = (key: string) => (key ? (names[key] ?? "someone") : "an import");
148+ const each = pushes ? pushRows(pushes.pushes.map((push) => ({ ...push, by: named(push.actorKey) })), pushes.history, span) : [];
149+ const fromActivity = landedFromActivity(activity, repoRefs(owned), names, slug).filter((row) => !(row.kind === "push" && pushes?.history[row.key.slice("push:".length)]));
150+ const all = merges == null && activity == null ? null : landed([merges ?? [], each, fromActivity, landedDeploys(deployRows ?? [])]);
151+ // Whether each merge and push is live, and which pull requests a harness session made: read together.
152+ const [shipped, sessions] = all
153+ ? await Promise.all([
154+ loadShipped(viewer, slug, all).catch(() => null),
155+ codeWork && codeWork !== "no_access" ? loadPullSessions(viewer, codeWork.pulls.filter((pull) => pull.status === "merged" && within(pull.mergedAt, span))).catch(() => null) : null,
156+ ])
157+ : [null, null];
132158 return {
133159 // What landed: merges, direct pushes, releases and packages, production builds; null when nothing of Code could be read.
134− landed: merges == null && activity == null ? null : landed([merges ?? [], landedFromActivity(activity, repoRefs(owned), names, slug), landedDeploys(deployRows ?? [])]),
135− landedMissing: [...(code && merges == null ? ["Pull requests"] : []), ...(code && activity == null ? ["Pushes and releases"] : []), ...(code && deployRows == null ? ["Deployments"] : [])],
160+ landed: all ? withSessions(withShipped(all, shipped), sessions) : null,
161+ landedMissing: [
162+ ...(code && merges == null ? ["Pull requests"] : []),
163+ ...(code && activity == null ? ["Pushes and releases"] : []),
164+ ...(code && deployRows == null ? ["Deployments"] : []),
165+ ],
136166 deploys: deployRows,
137167 deploysComplete: deploys?.complete ?? true,
138168 };
139169 })
140170 .catch(() => null);
171+ // What g1t settled for you in the span, what shipped, and what still needs you.
172+ const settledP = loadSettled(viewer, span).catch(() => null);
173+ const away: Promise<Away | null> = Promise.all([settledP, happened, waiting])
174+ .then(([settled, value, att]) => (settled == null ? null : awayOf({ settled: settledIn(settled, slug, code, span), landed: value?.landed ?? null, needs: att.rows.length })))
175+ .catch(() => null);
141176 const workflowsP = code ? Promise.all([activityP, reposP]).then(([activity, owned]) => loadWorkflowRuns(viewer, activity, owned)).catch(() => null) : Promise.resolve(null);
142177 const runningNow = Promise.all([deploysP, workflowsP])
143178 .then(([deploys, workflows]) => ({
156191 digest,
157192 happened,
158193 attention: waiting,
194+ away,
159195 running: runningNow,
160196 /** The sentence under the heading needs the digest, what landed and what waits. */
161197 head: Promise.all([digest, happened, waiting]),
201237 }
202238
203239 export default function Home({ loaderData }: Route.ComponentProps) {
204− const { slug, now, tz, span, digest, happened, attention: waiting, running: runningNow, head, spend } = loaderData;
240+ const { slug, now, tz, span, digest, happened, attention: waiting, away, running: runningNow, head, spend } = loaderData;
205241 useMarkSeen(slug, now);
206242 return (
207243 <div className="mx-auto w-full max-w-260 space-y-5 px-4 py-5 md:px-10 md:py-8">
226262
227263 {/* 1. Where you're needed now: no window, first and biggest. Start here beside it on wide screens, under it on phones. */}
228264 <div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
229− <div className="lg:col-start-1 lg:row-start-1">
265+ <div id="needed" className="scroll-mt-4 lg:col-start-1 lg:row-start-1">
230266 <Card
231267 title="Where you're needed"
232268 hint="Everything waiting on you, however long ago it started"
260296 {span.key === "last" && span.note == null ? `${span.words} · ${span.length}` : spanNote(span.note)}
261297 </p>
262298 </div>
299+ <Suspense
300+ fallback={
301+ <Card title={span.key === "last" && span.note == null ? "While you were away" : "Settled for you"}>
302+ <RowsSkeleton rows={2} />
303+ </Card>
304+ }
305+ >
306+ <Await resolve={away}>{(value) => <AwayCard away={value} span={span} now={now} />}</Await>
307+ </Suspense>
263308 <Suspense fallback={<SpanSkeleton />}>
264309 <Await resolve={digest}>{(value) => <PeopleCard digest={value} span={span} code={loaderData.code} />}</Await>
265310 </Suspense>
272317 </Card>
273318 }
274319 >
275− <Await resolve={happened}>{(value) => <LandedCard landed={value?.landed ?? null} missing={value?.landedMissing ?? []} code={loaderData.code} now={now} tz={tz} />}</Await>
320+ <Await resolve={happened}>
321+ {(value) => <LandedCard landed={value?.landed ?? null} missing={value?.landedMissing ?? []} code={loaderData.code} now={now} tz={tz} byDay={span.key === "7d"} />}
322+ </Await>
276323 </Suspense>
277324 <Suspense fallback={null}>
278325 <Await resolve={happened}>
+11−0
252252 pub saved: bool,
253253 /// While this is in the future the item is out of the list.
254254 pub snoozed_until: Option<String>,
255+ /// Why it settled by itself, in a few words, such as `CI passed in
256+ /// run 330` or `Merged`: set when the event that left nothing to do
257+ /// marked it done. Absent while it is not done, and when it was marked
258+ /// done by hand.
259+ #[serde(default, skip_serializing_if = "Option::is_none")]
260+ pub settled_reason: Option<String>,
261+ /// Who settled it, when a person or g1t did: a username, or `g1t`.
262+ #[serde(default, skip_serializing_if = "Option::is_none")]
263+ pub settled_by: Option<String>,
255264 }
256265
257266 /// One thing that happened on a thread, as the person was told of it.
816825 done_at: None,
817826 saved: false,
818827 snoozed_until: None,
828+ settled_reason: None,
829+ settled_by: None,
819830 },
820831 activity: Vec::new(),
821832 subscription: None,
+3−2
793793 pub ranges: Vec<BlameRange>,
794794 /// The commits the ranges name, each once.
795795 pub commits: Vec<Commit>,
796− /// True when the history was too long to read in full, so the oldest
797− /// lines are given to the oldest commit read.
796+ /// True when the history was too long to read in full (too many
797+ /// commits, or too long to read), so the oldest lines are given to the
798+ /// oldest commit read that has them.
798799 pub partial: bool,
799800 }
800801
+6−1
8484 "repo.archived",
8585 ],
8686 ),
87− // Previews and production deploys (deployments/src/index.ts queue).
87+ // Previews and production deploys (deployments/src/index.ts queue),
88+ // and what each production deployment shipped (deployments/src/shipped.ts).
8889 (
8990 "SUBSCRIBER_DEPLOYMENTS",
9091 &[
9192 "git.push",
93+ "deployment_status.created",
9294 "branch.renamed",
9395 "repo.default_branch_changed",
9496 "pull.opened",
290292 assert!(!routed("SUBSCRIBER_DOCS", "pull.opened"));
291293 assert!(routed("SUBSCRIBER_PROJECTS", "deployment_status.created"));
292294 assert!(!routed("SUBSCRIBER_PROJECTS", "deployment.created"));
295+ // A production deployment's success writes down what it shipped.
296+ assert!(routed("SUBSCRIBER_DEPLOYMENTS", "deployment_status.created"));
297+ assert!(!routed("SUBSCRIBER_DEPLOYMENTS", "deployment.created"));
293298 // Every subscriber follows what moves or removes a repository.
294299 for (binding, _) in ROUTES {
295300 for kind in LIFECYCLE {
+29−0
767767 pub root: Option<Session>,
768768 }
769769
770+/// `pull_sessions`: the harness sessions tied to some of a repository's
771+/// pull requests (`numbers`, at most 100) any way a session is tied to
772+/// one, for Home's marks on what landed. Returns
773+/// `Outcome<Vec<PullSessions>>`, one entry for each pull request that has
774+/// any, its top-level sessions first: a subagent counts as the session
775+/// that started it.
776+#[derive(Debug, Serialize, Deserialize)]
777+pub struct PullSessionsArgs {
778+ pub viewer: Viewer,
779+ pub repo: RepoPath,
780+ pub numbers: Vec<u32>,
781+}
782+
783+/// The sessions tied to one pull request.
784+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
785+#[serde(rename_all = "camelCase")]
786+pub struct PullSessions {
787+ pub number: u32,
788+ pub sessions: Vec<SessionRef>,
789+}
790+
791+/// A session, as a mark on something it made: its id and harness.
792+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
793+#[serde(rename_all = "camelCase")]
794+pub struct SessionRef {
795+ pub id: String,
796+ pub harness: Harness,
797+}
798+
770799 /// `list_work_sessions`: a repository's sessions, most recently heard from
771800 /// first. Returns `Outcome<Vec<Session>>`. Subagents are left out unless
772801 /// `subagents`; with `issue` or `pull`, the sessions tied to it any way
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…

Comments

Sign in to comment.