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.

Commit

Merge pull request #367 from flagon-io/366-code-org-page

Code opens on the workspace's own page, like a GitHub organization's

syntaqxcommitted Parents44f6f797d885cfBrowse files
13 files+55−10820/13 viewed
+1−1
319319
320320 Signed in without any workspace (you declined an invitation, or left the
321321 only workspace you were in), g1t shows **Create your workspace or ask to
322−join one** in place of Mission control: the invitations waiting for you, if
322+join one** in place of Home: the invitations waiting for you, if
323323 any, and the form to create a workspace. To join a team already on g1t,
324324 ask one of its owners to invite you by your username.
325325
+20−36
1616 someone else's workspace; see
1717 [your first workspace](/guides/authentication/#your-first-workspace).
1818 Signed in without a workspace, g1t shows **Create your workspace or ask to
19−join one** in place of Mission control: the invitations waiting for you,
19+join one** in place of Home: the invitations waiting for you,
2020 and the form below.
2121
2222 To create another:
627627
628628 ## The workspace's page
629629
630−A workspace's own page, `g1t.sh/<workspace>`, has its icon, name, address
631−and description at the top, then its overview: your
630+A workspace's own page, `g1t.sh/<workspace>`, is Code's front page, as an
631+organization's page is on GitHub, for members and visitors alike. It has
632+the workspace's [profile](#the-workspaces-profile) at the top: its
633+picture, name, address, description, location and links. Then how many
634+repositories it has, how many are in production, its open work items and
635+the pull requests in progress; your
632636 [pinned repositories](#pinned-and-recent-repositories), then the most active ones,
633−the pull requests in progress across them, and **All repositories**. Members
634−also see a **Usage** card with this month's spend, and who belongs.
637+and **All repositories**; the pull requests in progress across them; and,
638+beside them, its members. **Code** in the app bar opens it. Where you are
639+needed and what happened while you were away are on [Home](/guides/home/).
635640
636641 The workspace's other pages each have a heading of their own and a row in
637642 [the sidebar](#the-sidebar), lit while you are on them. The trail in the
10011006 **Explore**, **Docs**, **Sign in** and **Sign up**; a repository's pages run in
10021007 the row under its name.
10031008
1004−## Mission control
1009+## Code's front page
10051010
1006−Mission control is Code's **Overview**, at `g1t.sh/<workspace>/-/overview`.
1007−It shows where you are needed in the workspace's code and what landed,
1008−then the week and the activity. What the workspace's work says is on
1009−[Home](#the-workspace-overview).
1010−Signed in, `g1t.sh` itself opens [Home](/guides/home/) in the workspace
1011−you are in.
1012−
1013−**Review N that need you** jumps to the list, and **New work item** opens
1014−a new [work item](/work/work-items/) of the workspace, filed from the
1015−repository you pick.
1016−
1017−| Tab | What it lists |
1018−| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1019−| **Needs you** | Reviews asked of you, changes ready for you to merge, failed checks, failed production builds, repository invitations and a usage limit that is close or reached. |
1020−| **Landed today** | Pull requests merged today in your time zone, and whether a person merged them. |
1021−
1022−Each row in **Needs you** carries the reason it needs you:
1023−
1024−| Reason | Means |
1025−| ---------------- | ------------------------------------------------------------------------------------------------------------- |
1026−| `BLOCKING` | Nothing moves until a person acts: a failed production build, a merge g1t could not make, or the usage limit. |
1027−| `ASKED FOR YOU` | A review or an invitation addressed to you by name. |
1028−| `CHECKS FAILING` | A required check fails. |
1029−| `NEEDS REVIEW` | The repository wants a person's approval. |
1030−| `READY TO MERGE` | Checks passed and it was approved; the repository lands changes only when a person merges them. |
1031−
1032−From a row, **Review and respond** opens it, and where it can be done
1033−without leaving the page you can approve the change, merge it or re-run
1034−its failed jobs. **By impact** puts the most urgent first; **Newest** sorts
1035−by time.
1011+Code opens on [the workspace's page](#the-workspaces-page),
1012+`g1t.sh/<workspace>`: the workspace as an organization, its repositories
1013+and its people. What needs you in the workspace's code (reviews asked of
1014+you, changes ready to merge, failing checks, failed production builds,
1015+repository invitations and a usage limit that is close or reached) is in
1016+[Home's Your move](/guides/home/#your-move), with everything else waiting
1017+on you; what landed is under
1018+[Since you were last here](/guides/home/). Signed in, `g1t.sh` itself
1019+opens [Home](/guides/home/) in the workspace you are in.
10361020
10371021 ## Workspace access tokens
10381022
+1−1
188188 ))}
189189 </ul>
190190 )}
191− <Link to={`/${slug}/-/overview?tab=needs#work`} className="mt-3 inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
191+ <Link to={`/${slug}/-/home#needed`} className="mt-3 inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
192192 The review queue <ArrowRight size={11} />
193193 </Link>
194194 </div>
+0−836
1−import { ArrowDownWideNarrow, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, Plus } from "lucide-react";
2−import { type ReactNode, useEffect, useState } from "react";
3−import { Link, useFetcher, useLocation, useRouteLoaderData, useSearchParams } from "react-router";
4−
5−import { type ActivityGroup, type Verb, isAgent } from "../lib/mission";
6−import {
7− BLOCKING,
8− type Fact,
9− type LandedRow,
10− type NeedRow,
11− type QuickAction,
12− REASON_LABEL,
13− type Reason,
14− type Sort,
15− type Tab,
16− type Week,
17− type Who,
18− change,
19− dateLine,
20− parseSort,
21− parseTab,
22− signedPercent,
23− sortRows,
24−} from "../lib/mission-control";
25−import { cn } from "../lib/cn";
26−import { money } from "../lib/money";
27−import { InboxNeedsCard } from "./inbox";
28−import type { ShellData } from "./shell";
29−import { Unavailable } from "./mission";
30−import { SubmitButton, TimeAgo } from "./ui";
31−import { Avatar } from "./ui/avatar";
32−import { UserAvatar } from "./user-card";
33−import { PersonLink } from "./work";
34−import { Card } from "./ui/card";
35−import { Hint } from "./ui/hint";
36−import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu";
37−import type { Loaded } from "../lib/mission-control.server";
38−import { itemPath, newItemPath } from "../lib/work-items";
39−
40−/*
41− * Code's Overview: where the viewer is needed in the workspace's code,
42− * what landed, and the week. It is a chunk of its own, loaded only for
43− * those signed in. routes/workspace/code-overview.tsx loads its data;
44− * lib/mission-control.ts shapes it.
45− */
46−
47−/** The viewer's time zone, set by the page itself, so the date fits their day. */
48−const TZ_COOKIE = "g1t_tz";
49−/** Rows a tab shows before "See all". */
50−const ROWS = 6;
51−
52−const dollars = (micros: number) => money(Math.max(0, micros));
53−const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
54−
55−// --- Small pieces -----------------------------------------------------------------
56−
57−const CHIP_TONE: Record<Reason, string> = {
58− blocking: "border-danger/35 bg-danger/10 text-danger",
59− checks_failing: "border-danger/35 bg-danger/10 text-danger",
60− stalled: "border-warn/35 bg-warn/10 text-warn",
61− invited: "border-accent/35 bg-accent/10 text-accent",
62− needs_review: "border-info/35 bg-info/10 text-info",
63− ready: "border-success/35 bg-success/10 text-success",
64−};
65−
66−function Chip({ children, tone }: { children: ReactNode; tone: string }) {
67− return (
68− <span
69− className={cn(
70− "inline-flex shrink-0 items-center rounded border px-1.5 py-px font-mono text-[0.625rem] font-medium tracking-wider whitespace-nowrap uppercase",
71− tone,
72− )}
73− >
74− {children}
75− </span>
76− );
77−}
78−
79−/** Someone, by name, with their avatar. */
80−function Person({ who, size = 16, className }: { who: Who; size?: number; className?: string }) {
81− return (
82− <span className={cn("inline-flex min-w-0 items-center gap-1.5", className)}>
83− <Avatar name={who.name} size={size} />
84− <span className="truncate">{who.name}</span>
85− </span>
86− );
87−}
88−
89−function Eyebrow({ children }: { children: ReactNode }) {
90− return <p className="font-mono text-[0.625rem] font-medium tracking-[0.12em] text-faint uppercase">{children}</p>;
91−}
92−
93−const FACT_TONE = { good: "text-success", warn: "text-warn", bad: "text-danger" } as const;
94−
95−function Facts({ facts }: { facts: Fact[] }) {
96− if (facts.length === 0) return <p className="text-sm text-muted">Nothing more is known about it yet.</p>;
97− return (
98− <dl className="grid grid-cols-2 gap-x-4 gap-y-2.5">
99− {facts.map((fact) => (
100− <div key={fact.label} className={cn("min-w-0", fact.wide && "col-span-2")}>
101− <dt className="text-xs text-faint">{fact.label}</dt>
102− <dd
103− className={cn("mt-0.5 text-sm font-medium tabular-nums", fact.wide ? "leading-5" : "truncate", fact.tone ? FACT_TONE[fact.tone] : "text-fg-soft")}
104− >
105− {fact.value}
106− </dd>
107− </div>
108− ))}
109− </dl>
110− );
111−}
112−
113−/**
114− * A form that acts on a pull request from here: working until the action
115− * has answered and the page has reloaded what it changed, then done, or
116− * the error beside it.
117− */
118−function QuickForm({ quick, variant = "outline" }: { quick: QuickAction; variant?: "outline" | "accent" }) {
119− const fetcher = useFetcher<{ error?: string } | null>();
120− const [sent, setSent] = useState(false);
121− const done = sent && fetcher.state === "idle" && !fetcher.data?.error;
122− return (
123− <fetcher.Form method="post" action={quick.to} onSubmit={() => setSent(true)} className="contents">
124− {Object.entries(quick.fields).map(([name, value]) => (
125− <input key={name} type="hidden" name={name} value={value} />
126− ))}
127− <SubmitButton
128− fetcher={fetcher}
129− disabled={done}
130− className={cn(
131− "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-default",
132− variant === "accent" ? "bg-accent text-bg hover:bg-accent-hover" : "border border-line-strong text-fg/90 hover:bg-raised hover:text-fg",
133− done && "border-success/40 text-success",
134− )}
135− >
136− {done ? <Check size={14} /> : null}
137− {done ? quick.done : quick.label}
138− </SubmitButton>
139− {fetcher.data?.error && <span className="basis-full text-xs text-danger">{fetcher.data.error}</span>}
140− </fetcher.Form>
141− );
142−}
143−
144−function ProjectMark({ repo }: { repo: { namespace: string; name: string } | null }) {
145− return <Avatar name={repo ? repo.name : "g1t"} size={32} square />;
146−}
147−
148−/** A row that opens to what is known about it; the first one starts open. */
149−function Row({
150− open,
151− repo,
152− by,
153− title,
154− sub,
155− chip,
156− at,
157− children,
158−}: {
159− open: boolean;
160− repo: { namespace: string; name: string } | null;
161− by: Who | null;
162− title: ReactNode;
163− sub: ReactNode;
164− chip: ReactNode;
165− at: number;
166− children: ReactNode;
167−}) {
168− return (
169− <li>
170− <details open={open} className="group">
171− <summary className="flex cursor-pointer list-none items-center gap-3 px-4 py-3 transition-colors select-none hover:bg-raised/60 sm:px-5 [&::-webkit-details-marker]:hidden">
172− <ProjectMark repo={repo} />
173− <span className="min-w-0 grow">
174− <span className="block truncate text-sm">{title}</span>
175− <span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted">
176− <span className="shrink-0 font-medium text-fg-soft">{repo ? repo.name : "Workspace"}</span>
177− {by && (
178− <>
179− <span className="text-faint">·</span>
180− <Person who={by} size={14} className={cn("shrink-0", by.agent ? "text-accent" : "")} />
181− </>
182− )}
183− {sub && (
184− <>
185− <span className="text-faint">—</span>
186− <span className="truncate">{sub}</span>
187− </>
188− )}
189− </span>
190− </span>
191− <span className="hidden sm:inline-flex">{chip}</span>
192− <span className="hidden w-16 shrink-0 text-right text-xs text-faint sm:block">
193− <TimeAgo at={at} />
194− </span>
195− <ChevronDown size={16} className="shrink-0 text-faint transition-transform group-open:rotate-180" />
196− </summary>
197− <div className="px-4 pb-4 sm:px-5 sm:pb-5">
198− <div className="mb-3 flex items-center gap-2 sm:hidden">
199− {chip}
200− <span className="text-xs text-faint">
201− <TimeAgo at={at} />
202− </span>
203− </div>
204− {children}
205− </div>
206− </details>
207− </li>
208− );
209−}
210−
211−function NeedCard({ row, first }: { row: NeedRow; first: boolean }) {
212− const isPull = row.to.includes("/pull/");
213− return (
214− <Row
215− open={first}
216− repo={row.repo}
217− by={row.by}
218− title={
219− <>
220− <span className="font-medium">{row.title}</span>
221− {row.ref && <span className="ml-1 font-mono text-xs text-faint">{row.ref}</span>}
222− </>
223− }
224− sub={row.ask}
225− chip={<Chip tone={CHIP_TONE[row.reason]}>{REASON_LABEL[row.reason]}</Chip>}
226− at={row.at}
227− >
228− <Card tone="plain" className="grid gap-4 bg-bg/50 p-4 md:grid-cols-2 md:gap-x-6 2xl:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)_minmax(0,0.95fr)]">
229− <div className="min-w-0">
230− <Eyebrow>The ask</Eyebrow>
231− <p className="mt-2 text-sm leading-6 text-fg-soft">{row.ask}</p>
232− <p className="mt-2 text-xs leading-5 text-muted">
233− <Link to={row.to} className="font-medium text-fg hover:underline">
234− {row.repo ? `${row.repo.name}${row.ref ?? ""}` : row.title}
235− </Link>
236− </p>
237− </div>
238− <div className="min-w-0">
239− <Eyebrow>What is known</Eyebrow>
240− <div className="mt-2">
241− <Facts facts={row.facts} />
242− </div>
243− </div>
244− <div className="rounded-lg border border-warn/20 bg-warn/6 p-3.5 md:col-span-2 2xl:col-span-1">
245− <p className="text-xs font-semibold text-warn">Why this needs you</p>
246− <p className="mt-1.5 text-sm leading-6 text-fg-soft">{row.why}</p>
247− <p className="mt-2 text-xs text-faint">
248− Started waiting <TimeAgo at={row.at} />
249− </p>
250− </div>
251− </Card>
252− <div className="mt-3 flex flex-wrap items-center gap-2">
253− <Link
254− to={row.to}
255− prefetch="intent"
256− className="inline-flex items-center gap-1.5 rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg transition-colors hover:bg-fg-hover"
257− >
258− Review and respond <ArrowUpRight size={14} />
259− </Link>
260− {row.quick && <QuickForm quick={row.quick} />}
261− {row.link && (
262− <Link
263− to={row.link.to}
264− className="inline-flex items-center rounded-md border border-line-strong px-3 py-1.5 text-sm font-medium text-fg/90 hover:bg-raised hover:text-fg"
265− >
266− {row.link.label}
267− </Link>
268− )}
269− {row.open !== row.to && (
270− <Link to={row.open} prefetch="intent" className="px-2 py-1.5 text-sm text-muted hover:text-fg">
271− {row.open.includes("/-/work/") ? "Open work item" : isPull ? "See the changes" : "Open pull request"}
272− </Link>
273− )}
274− </div>
275− </Row>
276− );
277−}
278−
279−function LandedCard({ row, first }: { row: LandedRow; first: boolean }) {
280− return (
281− <Row
282− open={first}
283− repo={row.repo}
284− by={row.by}
285− title={
286− <>
287− <span className="font-medium">{row.title}</span>
288− <span className="ml-1 font-mono text-xs text-faint">{row.ref}</span>
289− </>
290− }
291− sub={row.automatic ? "merged automatically" : `merged by ${row.by?.name ?? "a person"}`}
292− chip={
293− row.automatic ? (
294− <Chip tone="border-accent/35 bg-accent/10 text-accent">Auto-merged</Chip>
295− ) : (
296− <Chip tone="border-line-strong bg-raised text-muted">Merged</Chip>
297− )
298− }
299− at={row.at}
300− >
301− <Card tone="plain" className="grid gap-4 bg-bg/50 p-4 md:grid-cols-2 md:gap-6">
302− <div className="min-w-0">
303− <Eyebrow>How it landed</Eyebrow>
304− <p className="mt-2 text-sm leading-6 text-fg-soft">
305− {row.automatic
306− ? "g1t merged it once everything the repository asks for was met, with no one pressing merge."
307− : `${row.by?.name ?? "A person"} merged it.`}
308− </p>
309− </div>
310− <div className="min-w-0">
311− <Eyebrow>What changed</Eyebrow>
312− <div className="mt-2">
313− <Facts facts={row.facts} />
314− </div>
315− </div>
316− </Card>
317− <div className="mt-3">
318− <Link
319− to={row.to}
320− prefetch="intent"
321− className="inline-flex items-center gap-1.5 rounded-md border border-line-strong px-3 py-1.5 text-sm font-medium text-fg/90 hover:bg-raised hover:text-fg"
322− >
323− Open pull request <ArrowUpRight size={14} />
324− </Link>
325− </div>
326− </Row>
327− );
328−}
329−
330−function List({ children }: { children: ReactNode }) {
331− return <ul className="divide-y divide-line">{children}</ul>;
332−}
333−
334−function Empty({ children, action }: { children: ReactNode; action?: ReactNode }) {
335− return (
336− <div className="px-5 py-10 text-center">
337− <div className="mx-auto max-w-md text-sm leading-6 text-muted">{children}</div>
338− {action && <div className="mt-4 flex flex-wrap justify-center gap-2">{action}</div>}
339− </div>
340− );
341−}
342−
343−// --- The stat strip -----------------------------------------------------------------
344−
345−function Stat({ label, value, hint, dot, title }: { label: string; value: string; hint: ReactNode; dot?: string; title?: string }) {
346− return (
347− <Hint label={title}>
348− <div className="min-w-0 bg-surface px-4 py-3.5 sm:px-5 sm:py-4">
349− <p className="flex items-center gap-1.5 truncate text-xs text-muted">
350− {dot && <span className={cn("size-1.5 shrink-0 rounded-full", dot)} />}
351− {label}
352− </p>
353− <p className="mt-1 font-display text-2xl font-semibold tracking-tight tabular-nums sm:text-[1.75rem]">{value}</p>
354− <p className="mt-0.5 truncate text-xs text-faint">{hint}</p>
355− </div>
356− </Hint>
357− );
358−}
359−
360−// --- This week ----------------------------------------------------------------------
361−
362−/** Seven days of landed changes: merged pull requests and commits pushed straight to a default branch. */
363−function WeekChart({ week }: { week: Week }) {
364− const max = Math.max(1, ...week.days.map((d) => d.count));
365− const height = 112;
366− const delta = change(week.total, week.previous);
367− return (
368− <div>
369− <div className="flex items-end justify-between gap-3">
370− <div>
371− <p className="font-display text-3xl font-semibold tracking-tight tabular-nums">{week.total.toLocaleString("en-US")}</p>
372− <p className="text-xs text-muted">changes landed in 7 days</p>
373− </div>
374− {delta != null ? (
375− <span
376− className={cn(
377− "rounded-full px-2 py-0.5 text-xs font-medium tabular-nums ring-1",
378− delta >= 0 ? "text-success ring-success/30" : "text-warn ring-warn/30",
379− )}
380− >
381− {signedPercent(delta)} vs last week
382− </span>
383− ) : week.previous === 0 && week.total > 0 ? (
384− <span className="text-xs text-faint">none the week before</span>
385− ) : null}
386− </div>
387− {week.total === 0 ? (
388− <Card asChild tone="plain" radius="lg" className="mt-4 border-dashed px-4 py-6 text-center text-sm leading-6 text-muted">
389− <p>Nothing landed in the last 7 days. Each merged pull request and each push to a default branch shows here, by day.</p>
390− </Card>
391− ) : (
392− <div className="relative mt-5" style={{ height: height + 20 }}>
393− <div className="absolute inset-x-0 border-t border-line" style={{ top: height }} />
394− <div className="absolute inset-x-0 top-0 flex items-end justify-between gap-1" style={{ height }}>
395− {week.days.map((day, index) => {
396− const today = index === week.days.length - 1;
397− return (
398− <div key={day.key} className="group relative flex h-full flex-1 flex-col items-center justify-end">
399− {day.count > 0 && (
400− <span
401− className={cn("mb-1 text-[0.625rem] tabular-nums", today ? "text-fg-soft" : "text-faint opacity-0 group-hover:opacity-100 max-md:hidden")}
402− >
403− {day.count}
404− </span>
405− )}
406− <div className="flex w-full max-w-6 flex-col items-stretch">
407− {day.count > 0 ? (
408− <span className="block rounded-t bg-accent" style={{ height: Math.max(3, Math.round((day.count / max) * (height - 4))) }} />
409− ) : (
410− <span className="block h-0.5 rounded-full bg-line-strong" />
411− )}
412− </div>
413− <div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 hidden -translate-x-1/2 rounded-md border border-line-strong bg-raised px-2.5 py-1.5 text-xs whitespace-nowrap shadow-lg shadow-black/40 md:group-hover:block">
414− <p className="font-medium text-fg">{day.label}</p>
415− <p className="mt-0.5 text-muted">{plural(day.count, "change")} landed</p>
416− </div>
417− </div>
418− );
419− })}
420− </div>
421− <div className="absolute inset-x-0 bottom-0 flex justify-between gap-1">
422− {week.days.map((day, index) => (
423− <span
424− key={day.key}
425− className={cn("flex-1 text-center text-[0.6875rem]", index === week.days.length - 1 ? "font-medium text-fg-soft" : "text-faint")}
426− >
427− {day.label}
428− </span>
429− ))}
430− </div>
431− </div>
432− )}
433− {/* A table does not shrink to sr-only's 1px; its wrapper does. */}
434− <div className="sr-only">
435− <table>
436− <caption>Changes landed each day</caption>
437− <thead>
438− <tr>
439− <th>Day</th>
440− <th>Changes</th>
441− </tr>
442− </thead>
443− <tbody>
444− {week.days.map((day) => (
445− <tr key={day.key}>
446− <td>{day.key}</td>
447− <td>{day.count}</td>
448− </tr>
449− ))}
450− </tbody>
451− </table>
452− </div>
453− </div>
454− );
455−}
456−
457−// --- Activity -----------------------------------------------------------------------
458−
459−const VERB: Record<Verb, string> = {
460− landed: "landed",
461− opened_item: "opened",
462− closed_item: "closed",
463− started: "started on",
464− ready: "marked ready",
465− checks_passed: "checks passed on",
466− checks_failed: "checks failed on",
467− approved: "approved",
468− changes_requested: "asked for changes on",
469− commented: "commented on",
470− deployed: "deployed production",
471− deploy_failed: "production build failed",
472−};
473−
474−/** One line of the feed: who, what, and the one thing it was about, by name. */
475−function FeedLine({ group, titles }: { group: ActivityGroup; titles: Record<string, string> }) {
476− const part = group.parts[0];
477− const base = `/${group.repo.namespace}/${group.repo.name}`;
478− const actor = group.actor ?? "g1t";
479− const agent = group.actor == null || isAgent(group.actor);
480− const number = part.numbers[0];
481− const title = number != null ? titles[`${group.repo.namespace}/${group.repo.name}#${number}`.toLowerCase()] : undefined;
482− const more = group.parts.length - 1 + Math.max(0, part.numbers.length - 1);
483− return (
484− <li className="flex gap-2.5 py-2">
485− <span className="mt-px">{agent ? <Avatar name={actor} image={null} size={22} /> : <UserAvatar username={actor} size={22} />}</span>
486− <span className="min-w-0 grow text-[0.8125rem] leading-5 text-muted">
487− {agent ? <span className="font-medium text-accent">{actor}</span> : <PersonLink name={actor} className="font-medium text-fg hover:text-accent" />}{" "}
488− {VERB[part.verb]}{" "}
489− {number != null ? (
490− <Link
491− to={part.verb === "opened_item" || part.verb === "closed_item" ? itemPath(group.repo.namespace, number) : `${base}/pull/${number}`}
492− prefetch="intent"
493− className="text-fg-soft hover:text-fg"
494− >
495− <span className="font-mono text-xs">#{number}</span>
496− {title && <> {title}</>}
497− </Link>
498− ) : part.to ? (
499− <Link to={part.to} className="text-fg-soft hover:text-fg">
500− {group.repo.name}
501− </Link>
502− ) : null}
503− {more > 0 && <span className="text-faint"> and {more} more</span>}
504− <span className="block text-xs text-faint">
505− <span className="font-mono">{group.repo.name}</span> · <TimeAgo at={group.at} />
506− </span>
507− </span>
508− </li>
509− );
510−}
511−
512−// --- The page -----------------------------------------------------------------------
513−
514−const TAB_LABEL: Record<Tab, string> = { needs: "Needs you", landed: "Landed today" };
515−const TAB_SHORT: Record<Tab, string> = { needs: "Needs you", landed: "Today" };
516−const SORT_LABEL: Record<Sort, string> = { impact: "By impact", newest: "Newest" };
517−
518−export default function MissionControl({ loaderData }: { loaderData: Loaded }) {
519− const { pathname } = useLocation();
520− const shell = useRouteLoaderData("root")?.shell as ShellData | null | undefined;
521− const loaded: Loaded = loaderData;
522− const [params] = useSearchParams();
523− const [date, setDate] = useState(loaded.date);
524− useEffect(() => {
525− // The date follows the viewer's own clock, and the server learns their zone for next time.
526− try {
527− const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
528− setDate(dateLine(Date.now(), zone ?? null));
529− if (zone) document.cookie = `${TZ_COOKIE}=${encodeURIComponent(zone)}; Path=/; Max-Age=31536000; SameSite=Lax`;
530− } catch {
531− // Nothing to remember.
532− }
533− }, []);
534−
535− const { viewer, repos, landed, week, groups, titles, stats } = loaded;
536− const workspace = shell?.workspace?.slug ?? loaded.workspace ?? viewer.workspaces?.[0]?.slug ?? null;
537−
538− // The usage limit, as the sidebar knows it, heads the list when it bites.
539− const limit = shell?.limit && !shell.limit.comped && shell.limit.state !== "ok" && workspace ? shell.limit : null;
540− const limitRow: NeedRow[] = limit
541− ? [
542− (() => {
543− const detail =
544− limit.state === "stopped"
545− ? "Paid usage is paused until the limit is raised or a card is added."
546− : `${dollars(limit.exposureMicros)} of ${dollars(limit.ceilingMicros ?? 0)} used. Add a card so work keeps going.`;
547− return {
548− key: "limit",
549− reason: "blocking" as const,
550− repo: null,
551− ref: null,
552− title: limit.state === "stopped" ? "The usage limit was reached" : "Usage is nearing its limit",
553− ask: detail,
554− by: null,
555− at: Date.now(),
556− to: `/${workspace}/-/settings/billing`,
557− open: `/${workspace}/-/settings/billing`,
558− facts: [{ label: "Used", value: `${dollars(limit.exposureMicros)} of ${dollars(limit.ceilingMicros ?? 0)}`, tone: "warn" as const }],
559− why: "Workflows, checks and deployments that are paid for stop at the workspace's usage limit. Only an owner can raise it or add a card.",
560− quick: null,
561− link: null,
562− };
563− })(),
564− ]
565− : [];
566− const needs = [...limitRow, ...loaded.needs];
567− const blocking = needs.filter((row) => BLOCKING.has(row.reason)).length;
568−
569− const counts: Record<Tab, number> = { needs: needs.length, landed: landed.length };
570− const tab = parseTab(params.get("tab")) ?? "needs";
571− const sort = parseSort(params.get("sort"));
572− const all = params.get("all") === "1";
573− const everyActivity = params.get("activity") === "all";
574− const link = (changes: Record<string, string | null>) => {
575− const next = new URLSearchParams(params);
576− for (const [key, value] of Object.entries(changes)) {
577− if (value == null) next.delete(key);
578− else next.set(key, value);
579− }
580− const text = next.toString();
581− return text ? `?${text}` : pathname;
582− };
583−
584− const noProjects = repos.length === 0;
585−
586− const rows = tab === "needs" ? sortRows(needs, sort) : sortRows(landed, sort);
587− const shown = all ? rows : rows.slice(0, ROWS);
588− const delta = change(week.total, week.previous);
589− const feed = everyActivity ? groups : groups.slice(0, 8);
590−
591− const newItem =
592− repos.length > 0 ? (
593− <DropdownMenu>
594− <DropdownMenuTrigger className="inline-flex items-center gap-1.5 rounded-md border border-line-strong px-3 py-2 text-sm font-medium text-fg/90 transition-colors hover:bg-raised hover:text-fg">
595− <Plus size={14} /> New work item
596− </DropdownMenuTrigger>
597− <DropdownMenuContent align="start" className="max-h-80 overflow-y-auto">
598− <DropdownMenuLabel>Filed from which repository?</DropdownMenuLabel>
599− {repos.map((repo) => (
600− <DropdownMenuItem key={repo.name} asChild>
601− <Link to={newItemPath(repo.namespace, repo.name)}>
602− <Avatar name={repo.name} size={18} square />
603− {repo.name}
604− </Link>
605− </DropdownMenuItem>
606− ))}
607− </DropdownMenuContent>
608− </DropdownMenu>
609− ) : (
610− <Link
611− to={"/new"}
612− className="inline-flex items-center gap-1.5 rounded-md border border-line-strong px-3 py-2 text-sm font-medium text-fg/90 hover:bg-raised hover:text-fg"
613− >
614− <Plus size={14} /> New repository
615− </Link>
616− );
617−
618− return (
619− <main className="mx-auto max-w-7xl space-y-6 px-4 py-8 sm:px-6 sm:py-10">
620− <header className="flex flex-wrap items-end justify-between gap-x-6 gap-y-4">
621− <div className="min-w-0">
622− <h1 className="text-2xl leading-tight font-semibold tracking-tight">Overview</h1>
623− <p className="mt-1.5 text-sm text-muted" suppressHydrationWarning>
624− <span className="text-fg-soft">{date}</span> · {loaded.summary}
625− </p>
626− </div>
627− </header>
628−
629− <section aria-label="Start work" className="flex flex-wrap items-center gap-2">
630− {newItem}
631− {needs.length > 0 && (
632− <Link
633− to={`${link({ tab: "needs", all: null })}#work`}
634− preventScrollReset
635− className="inline-flex items-center gap-1.5 rounded-md bg-accent px-3.5 py-2 text-sm font-semibold text-bg transition-colors hover:bg-accent-hover"
636− >
637− Review {needs.length} that need{needs.length === 1 ? "s" : ""} you <ArrowRight size={14} />
638− </Link>
639− )}
640− </section>
641−
642− <Card asChild tone="plain" className="grid grid-cols-2 gap-px overflow-hidden bg-line lg:grid-cols-4">
643− <section aria-label="At a glance">
644− <Stat
645− label="Repositories"
646− value={stats.projects == null ? "—" : String(stats.projects)}
647− hint={
648− stats.projectsThisMonth == null
649− ? "in this workspace"
650− : stats.projectsThisMonth > 0
651− ? `+${stats.projectsThisMonth} this month`
652− : "none new this month"
653− }
654− />
655− <Stat label="Open pull requests" value={stats.openPulls == null ? "—" : String(stats.openPulls)} hint="drafts included" />
656− <Stat
657− label="Changes this week"
658− value={loaded.perRepoLoaded ? String(week.total) : "—"}
659− hint={delta != null ? `${signedPercent(delta)} vs last week` : week.previous === 0 ? "none the week before" : "merges and direct pushes"}
660− />
661− <Stat
662− label="Needs you"
663− dot="bg-warn"
664− value={String(needs.length)}
665− hint={blocking > 0 ? `${blocking} blocking` : needs.length > 0 ? "nothing blocking" : "all clear"}
666− />
667− </section>
668− </Card>
669−
670− <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] xl:grid-cols-[minmax(0,1fr)_23rem]">
671− <Card asChild className="min-w-0 scroll-mt-[calc(var(--topbar-h)+1rem)] self-start overflow-hidden">
672− <section id="work">
673− <div className="flex items-center gap-2 border-b border-line px-2 sm:px-3">
674− <nav className="-mb-px flex min-w-0 grow gap-1 overflow-x-auto scrollbar-none" aria-label="Overview">
675− {(["needs", "landed"] as const).map((value) => (
676− <Link
677− key={value}
678− to={link({ tab: value, all: null })}
679− preventScrollReset
680− aria-current={tab === value ? "page" : undefined}
681− className={cn(
682− "flex shrink-0 items-center gap-1.5 border-b-2 px-2 py-3 text-sm whitespace-nowrap transition-colors sm:px-2.5",
683− tab === value ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg",
684− )}
685− >
686− <span className="sm:hidden">{TAB_SHORT[value]}</span>
687− <span className="hidden sm:inline">{TAB_LABEL[value]}</span>
688− <span
689− className={cn(
690− "rounded-full px-1.5 text-[0.6875rem] tabular-nums",
691− tab === value && value === "needs" && counts.needs > 0 ? "bg-warn/15 text-warn" : "bg-line text-muted",
692− )}
693− >
694− {counts[value]}
695− </span>
696− </Link>
697− ))}
698− </nav>
699− <DropdownMenu>
700− <DropdownMenuTrigger
701− className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted hover:bg-raised hover:text-fg"
702− aria-label="Sort"
703− >
704− <ArrowDownWideNarrow size={14} />
705− <span className="hidden sm:inline">{SORT_LABEL[sort]}</span>
706− </DropdownMenuTrigger>
707− <DropdownMenuContent align="end">
708− {(["impact", "newest"] as const).map((value) => (
709− <DropdownMenuItem key={value} asChild>
710− <Link to={link({ sort: value === "impact" ? null : value })} preventScrollReset>
711− {sort === value ? <Check /> : <span className="size-4" />}
712− {SORT_LABEL[value]}
713− </Link>
714− </DropdownMenuItem>
715− ))}
716− </DropdownMenuContent>
717− </DropdownMenu>
718− </div>
719−
720− {!loaded.perRepoLoaded ? (
721− <div className="p-4">
722− <Unavailable what={tab === "needs" ? "Some of what needs you" : "What landed"} />
723− </div>
724− ) : null}
725−
726− {tab === "needs" &&
727− (needs.length === 0 ? (
728− noProjects ? (
729− <Empty
730− action={
731− <Link to={"/new"} className="rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg hover:bg-fg-hover">
732− Create or import a repository
733− </Link>
734− }
735− >
736− <p className="font-medium text-fg">No repositories yet</p>
737− <p className="mt-1">Create a repository or import one. Reviews, failed deploys and invitations for you show up here.</p>
738− </Empty>
739− ) : (
740− <>
741− <Empty>
742− <p className="font-medium text-fg">Nothing needs you</p>
743− <p className="mt-1">{landed.length > 0 ? "Here's what landed today." : "Reviews, failed deploys and invitations show up here first."}</p>
744− </Empty>
745− {landed.length > 0 && (
746− <div className="border-t border-line">
747− <List>
748− {landed.slice(0, ROWS).map((row) => (
749− <LandedCard key={row.key} row={row} first={false} />
750− ))}
751− </List>
752− </div>
753− )}
754− </>
755− )
756− ) : (
757− <List>
758− {(shown as NeedRow[]).map((row, index) => (
759− <NeedCard key={row.key} row={row} first={index === 0} />
760− ))}
761− </List>
762− ))}
763−
764− {tab === "landed" &&
765− loaded.perRepoLoaded &&
766− (landed.length === 0 ? (
767− <Empty>
768− <p className="font-medium text-fg">Nothing has landed today yet</p>
769− <p className="mt-1">
770− {week.total > 0 ? `${plural(week.total, "change")} landed in the last 7 days.` : "Merged pull requests show up here the moment they land."}
771− </p>
772− </Empty>
773− ) : (
774− <List>
775− {(shown as LandedRow[]).map((row, index) => (
776− <LandedCard key={row.key} row={row} first={index === 0} />
777− ))}
778− </List>
779− ))}
780−
781− {rows.length > ROWS && (
782− <div className="flex items-center justify-between border-t border-line px-4 py-2.5 text-xs text-muted sm:px-5">
783− <span>
784− Showing {shown.length} of {rows.length}
785− </span>
786− <Link to={link({ all: all ? null : "1" })} preventScrollReset className="inline-flex items-center gap-1 font-medium text-fg-soft hover:text-fg">
787− {all ? "Show fewer" : "See all"} <ChevronRight size={12} />
788− </Link>
789− </div>
790− )}
791− </section>
792− </Card>
793−
794− <aside className="min-w-0 space-y-6">
795− <InboxNeedsCard items={loaderData.inboxNeeds.items} total={loaderData.inboxNeeds.total} />
796− <Card asChild className="p-5">
797− <section>
798− <h2 className="text-base font-semibold tracking-tight">This week</h2>
799− <div className="mt-4">{loaded.perRepoLoaded ? <WeekChart week={week} /> : <Unavailable what="The week" />}</div>
800− </section>
801− </Card>
802−
803− <Card asChild className="scroll-mt-[calc(var(--topbar-h)+1rem)] p-5">
804− <section id="activity">
805− <div className="flex items-center justify-between">
806− <h2 className="text-base font-semibold tracking-tight">Activity</h2>
807− {groups.length > 8 && (
808− <Link
809− to={`${link({ activity: everyActivity ? null : "all" })}#activity`}
810− preventScrollReset
811− className="inline-flex items-center gap-0.5 text-xs text-muted hover:text-fg"
812− >
813− {everyActivity ? "Less" : "All activity"} <ChevronRight size={12} />
814− </Link>
815− )}
816− </div>
817− {!loaded.perRepoLoaded ? (
818− <div className="mt-3">
819− <Unavailable what="Activity" />
820− </div>
821− ) : feed.length === 0 ? (
822− <p className="mt-3 text-sm leading-6 text-muted">Nothing has moved yet. Merges, deploys, checks and reviews show up here.</p>
823− ) : (
824− <ol className="mt-2 divide-y divide-line/70">
825− {feed.map((group) => (
826− <FeedLine key={group.id} group={group} titles={titles} />
827− ))}
828− </ol>
829− )}
830− </section>
831− </Card>
832− </aside>
833− </div>
834− </main>
835− );
836−}
+3−3
12281228 ? { to: repositoriesPath(home.slug), label: "All repositories" }
12291229 : browsing
12301230 ? { to: repositoriesPath(menuRepo.namespace), label: "Repositories", context: menuRepo.namespace }
1231− : { to: ws ? `/${ws.slug}/-/overview` : "/explore", label: "Code" };
1231+ : { to: ws ? `/${ws.slug}` : "/explore", label: "Code" };
12321232 trail.push({
12331233 key,
12341234 node: (
14261426 return (
14271427 <nav aria-label="Code" className={PANEL}>
14281428 <NavRows>
1429− <SidebarLink to={`/${slug}/-/overview`} icon={<LayoutDashboard size={15} />} current={at("overview")}>
1429+ <SidebarLink to={`/${slug}`} icon={<LayoutDashboard size={15} />} current={path === `/${slug}`}>
14301430 Overview
14311431 </SidebarLink>
14321432 </NavRows>
20772077 { label: "Labels", hint: "Work", to: workPath(ws.slug, "labels"), icon: <ListTodo size={15} /> },
20782078 { label: "Milestones", hint: "Work", to: workPath(ws.slug, "milestones"), icon: <ListTodo size={15} /> },
20792079 { label: "Chat", to: `/${ws.slug}/-/chat`, icon: <MessagesSquare size={15} /> },
2080− ...(hasCodeAccess(ws) ? [{ label: "Code overview", to: `/${ws.slug}/-/overview`, icon: <Code2 size={15} /> }] : []),
2080+ ...(hasCodeAccess(ws) ? [{ label: "Code overview", to: `/${ws.slug}`, icon: <Code2 size={15} /> }] : []),
20812081 { label: "Library", to: `/${ws.slug}/-/library/artifacts`, icon: <LibraryBig size={15} /> },
20822082 { label: "Routines", hint: "Standing work for @g1t (soon)", to: `/${ws.slug}/-/routines`, icon: <Repeat size={15} /> },
20832083 { label: "Apps", hint: "Everything installed that you can use", to: `/${ws.slug}/-/apps`, icon: <LayoutGrid size={15} /> },
+2−1
4545 { key: "work", name: "Work", path: under("work") },
4646 { key: "chat", name: "Chat", path: under("chat") },
4747 { key: "notifications", name: "Notifications", path: () => "/notifications" },
48− { key: "code", name: "Code", path: under("overview"), code: true },
48+ // The workspace's own page, as an organization's is on GitHub.
49+ { key: "code", name: "Code", path: (slug) => `/${slug}`, code: true },
4950 // Artifacts are the Library's: docs now, and slides, designs and dashboards next.
5051 { key: "artifacts", name: "Library", path: under("library/artifacts") },
5152 { key: "routines", name: "Routines", path: under("routines") },
+4−4
323323 series: weeks.map(agentChanges),
324324 points: weeks.map((week, i) => `${labels[i]}: ${week.agentMerged} merged, ${week.agentCommits ?? 0} pushed directly, of ${allChanges(week)} changes`),
325325 hint: "Agents' merged pull requests, and the commits their sessions pushed outside one",
326− to: `/${slug}/-/overview?tab=landed`,
326+ to: `/${slug}/-/home?window=7d#since`,
327327 },
328328 {
329329 key: "spend",
343343 series: weeks.map((week) => week.stalled),
344344 points: weeks.map((week, i) => `${labels[i]}: ${week.stalled} with nothing for three days`),
345345 hint: "Open pull requests with no push, comment, review, check or session for three days",
346− to: `/${slug}/-/overview?tab=needs#work`,
346+ to: `/${slug}/-/home#needed`,
347347 },
348348 {
349349 key: "rework",
453453 caption: "Time to first review",
454454 range: `${short(first)} → ${short(wait)} over ${weeks.length} weeks`,
455455 };
456− const link = { label: "See the review queue", to: `/${ctx.slug}/-/overview?tab=needs#work` };
456+ const link = { label: "See the review queue", to: `/${ctx.slug}/-/home#needed` };
457457 if (draft != null && wait >= 4 * draft && wait >= 4 * 3600) {
458458 return {
459459 key: "review",
611611 key: "stalled",
612612 headline: `${countWord(now.stalled)} open changes have gone three days with nothing happening, ${more} more than last week.`,
613613 facts: [`${before.stalled} last week`, `${now.stalled} now`],
614− link: { label: "See what's stalled", to: `/${ctx.slug}/-/overview?tab=needs#work` },
614+ link: { label: "See what's stalled", to: `/${ctx.slug}/-/home#needed` },
615615 trend: {
616616 values: ctx.weeks.map((week) => week.stalled),
617617 points: ctx.weeks.map((week) => `Week of ${weekLabel(week.start)}: ${week.stalled}`),
+4−3
8484 test("the rail's mode follows the address", async () => {
8585 const { modeOf, modeHome, homePath } = await import("./workspace-nav.ts");
8686 assert.equal(modeOf("/", "acme"), "home");
87− assert.equal(modeOf("/acme", "acme"), "home");
87+ // The workspace's own page is Code's front page.
88+ assert.equal(modeOf("/acme", "acme"), "code");
8889 assert.equal(modeOf("/acme/-/home", "acme"), "home");
8990 // g1t's own public pages light no mode.
9091 assert.equal(modeOf("/explore", "acme"), "site");
127128 assert.equal(modeHome("notifications", "acme"), "/notifications");
128129 assert.equal(modeHome("people", "acme"), "/acme/-/people");
129130 assert.equal(modeHome("apps", "acme"), "/acme/-/apps");
130− assert.equal(modeHome("code", "acme"), "/acme/-/overview");
131+ assert.equal(modeHome("code", "acme"), "/acme");
131132 assert.equal(modeHome("workspace", "acme"), "/acme/-/settings/profile");
132133 assert.equal(homePath("acme"), "/acme/-/home");
133− assert.equal(homePath("acme", "?tab=landed&_routes=x"), "/acme/-/overview?tab=landed");
134+ assert.equal(homePath("acme", "?tab=landed&_routes=x"), "/acme/-/home");
134135 });
135136
136137 test("Workspace mode's sidebar knows which group and page an address is", async () => {
+4−4
347347 const parts = path.split("/").filter(Boolean);
348348 if (SITE_PAGES.has(parts[0] ?? "")) return "site";
349349 if (slug && parts[0]?.toLowerCase() === slug.toLowerCase()) {
350− if (parts.length === 1) return "home";
350+ // The workspace's own page is Code's front page, as an organization's is on GitHub.
351+ if (parts.length === 1) return "code";
351352 if (parts[1] === "-") {
352353 // The workspace's integrations are its apps: Apps is lit on them, beside the settings' sidebar.
353354 if (parts[2] === "settings" && parts[3] === "integrations") return "apps";
381382 case "routines":
382383 return `/${slug}/-/routines`;
383384 case "code":
384− return `/${slug}/-/overview`;
385+ return `/${slug}`;
385386 case "people":
386387 return `/${slug}/-/people`;
387388 case "workspace":
515516 params.delete("_routes");
516517 params.delete("index");
517518 const query = params.toString();
518− if (params.has("tab") || params.has("sort")) return `/${slug}/-/overview?${query}`;
519519 return homePagePath(slug);
520520 }
521521
528528 const WORK_REDIRECTS = new Set(["issues", "labels", "milestones", "ref"]);
529529
530530 /** The workspace pages under `-/` that are Code's: closed to a member without Code access. */
531−const CODE_PAGES = new Set(["overview", "code", "projects", "repositories", "packages", "security", "rules", "runners", "actions", "insights", "soon"]);
531+const CODE_PAGES = new Set(["code", "projects", "repositories", "packages", "security", "rules", "runners", "actions", "insights", "soon"]);
532532
533533 /** The workspace's settings pages (under `-/settings/`) that are Code's, closed as Code's pages are. */
534534 const CODE_SETTINGS = new Set(["repositories", "rules", "runners", "actions", "security"]);
+0−1
143143 // it or skipping a step (components/onboarding.tsx).
144144 route("-/onboarding", "routes/workspace/onboarding.ts"),
145145 // Code's Overview: Mission control's code panels, for this workspace.
146− route("-/overview", "routes/workspace/code-overview.tsx"),
147146 // The workspace itself, at a glance: members, plan and spend.
148147 // The workspace's settings, every page under `-/settings/`, open on its Profile.
149148 route("-/settings", "routes/workspace/settings-index.ts"),
+0−122
1−import { Suspense, lazy } from "react";
2−import { type ShouldRevalidateFunctionArgs, data } from "react-router";
3−
4−import type { Route } from "./+types/code-overview";
5−import { page } from "../../lib/meta";
6−import { loadMissionControl } from "../../lib/mission-control.server";
7−import { requireUser, roleIn } from "../../lib/session.server";
8−import { Card } from "../../components/ui/card";
9−import { Skeleton, SkeletonCard, SkeletonLine, SkeletonStat } from "../../components/ui/skeleton";
10−
11−export function meta({ params, ...args }: Route.MetaArgs) {
12− return page(args, { title: `Overview · Code · ${params.owner} · g1t` });
13−}
14−
15−export function headers({ loaderHeaders }: Route.HeadersArgs) {
16− const out = new Headers();
17− const timing = loaderHeaders.get("Server-Timing");
18− if (timing) out.set("Server-Timing", timing);
19− return out;
20−}
21−
22−/**
23− * Code's Overview, for this workspace: what needs you and what landed,
24− * the week's numbers, activity, and a way to open a work item.
25− */
26−export async function loader({ params, context, request }: Route.LoaderArgs) {
27− const viewer = requireUser(context, request);
28− if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
29− const { value, timing } = await loadMissionControl(viewer, request, params.owner);
30− return data(value, { headers: { "Server-Timing": timing } });
31−}
32−
33−/**
34− * Switching tabs, the sort or the activity list changes only the address:
35− * everything is already loaded. Marking an inbox item read changes nothing
36− * else here.
37− */
38−export function shouldRevalidate({ currentUrl, nextUrl, formMethod, formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
39− if (!formMethod && currentUrl.pathname === nextUrl.pathname && currentUrl.search !== nextUrl.search) return false;
40− if (formAction === "/notifications") return false;
41− return defaultShouldRevalidate;
42−}
43−
44−/** Loaded once the browser is idle (components/shell.tsx), so the skeleton below is rarely seen. */
45−const MissionControl = lazy(() => import("../../components/mission-control"));
46−
47−export default function CodeOverview({ loaderData }: Route.ComponentProps) {
48− return (
49− <Suspense fallback={<MissionControlSkeleton />}>
50− <MissionControl loaderData={loaderData} />
51− </Suspense>
52− );
53−}
54−
55−/**
56− * The Overview's outline while its code arrives, piece for piece as
57− * components/mission-control.tsx lays it out: the title, the actions
58− * under it, the stats, then the work list beside the week. Each
59− * piece has the padding and line heights of the real one, so nothing moves
60− * when it arrives.
61− */
62−export function MissionControlSkeleton() {
63− return (
64− <main aria-busy="true" className="mx-auto max-w-7xl space-y-6 px-4 py-8 sm:px-6 sm:py-10">
65− <span role="status" className="sr-only">
66− Loading…
67− </span>
68− <header>
69− <SkeletonLine className="w-72 max-w-full text-[1.75rem] leading-tight sm:text-[2rem]" barClassName="h-8" />
70− {/* The date and the week's summary: one line wide, three on a phone. */}
71− <div className="mt-1.5 text-sm">
72− <SkeletonLine className="w-xl max-w-full" barClassName="h-3.5" />
73− <SkeletonLine className="w-full sm:hidden" barClassName="h-3.5" />
74− <SkeletonLine className="w-2/3 sm:hidden" barClassName="h-3.5" />
75− </div>
76− </header>
77− <div className="flex flex-wrap items-center gap-2">
78− <Skeleton className="h-9.5 w-28 rounded-md" />
79− <Skeleton className="h-9.5 w-44 rounded-md" />
80− </div>
81− <Card tone="plain" className="grid grid-cols-2 gap-px overflow-hidden bg-line lg:grid-cols-4">
82− {Array.from({ length: 4 }, (_, index) => (
83− <SkeletonStat key={index} />
84− ))}
85− </Card>
86− <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] xl:grid-cols-[minmax(0,1fr)_23rem]">
87− <Card className="self-start overflow-hidden">
88− <div className="flex items-center gap-4 border-b border-line px-4 py-3 text-sm sm:px-5">
89− <SkeletonLine className="w-24" />
90− <SkeletonLine className="w-32" />
91− <SkeletonLine className="hidden w-28 sm:flex" />
92− </div>
93− <div className="divide-y divide-line">
94− {Array.from({ length: 5 }, (_, index) => (
95− <div key={index}>
96− <div className="flex items-center gap-3 px-4 py-3 sm:px-5">
97− <Skeleton className="size-8 shrink-0 rounded-md" />
98− <span className="min-w-0 grow">
99− <SkeletonLine className="text-sm" width={`${70 - ((index * 17) % 30)}%`} />
100− <SkeletonLine className="mt-0.5 text-xs" width={`${50 - ((index * 11) % 20)}%`} />
101− </span>
102− <Skeleton className="hidden h-4 w-24 shrink-0 sm:block" />
103− </div>
104− {/* The first row stands open, as the real list's does. */}
105− {index === 0 && (
106− <div className="px-4 pb-4 sm:px-5 sm:pb-5">
107− <Skeleton className="h-68 rounded-xl" />
108− <Skeleton className="mt-3 h-8 w-44 rounded-md" />
109− </div>
110− )}
111− </div>
112− ))}
113− </div>
114− </Card>
115− <div className="space-y-6">
116− <SkeletonCard lines={0} className="h-80 p-5" />
117− <SkeletonCard lines={6} className="p-5" />
118− </div>
119− </div>
120− </main>
121− );
122−}
+0−2
246246 // are columns of their own (components/chat/channel.tsx). Its sessions
247247 // are pages like any other, with the page's margins.
248248 if (parts[0] === "chat" && parts[1] !== "sessions" && !next) return <Outlet />;
249− // Code's Overview lays itself out, as Mission control did.
250− if (parts[0] === "overview" && parts.length === 1 && !next) return <Outlet />;
251249 // An open artifact, or a repository's doc, fills the panel: its header runs edge to edge right under
252250 // the top bar (components/folios/shell.tsx), and its page sets its own width.
253251 if (isArtifactPage(parts) && !next) {
+16−68
1−import { ArrowRight, ArrowUpRight, Box, CircleDot, GitBranch, GitPullRequest, KeyRound, Lock, Pin, Plus, Rocket } from "lucide-react";
2−import { Link, redirect } from "react-router";
1+import { ArrowRight, ArrowUpRight, Box, CircleDot, GitBranch, GitPullRequest, Lock, Pin, Plus, Rocket } from "lucide-react";
2+import { Link } from "react-router";
33
44 import type { PackageSummary, Project, ProjectDeploys } from "@g1t/contracts";
55
66 import type { Route } from "./+types/overview";
77 import { DeployLink, host, StatusDot } from "../../components/deploy";
88 import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
9−import { Avatar } from "../../components/ui/avatar";
109 import { Badge } from "../../components/ui/badge";
1110 import { Card } from "../../components/ui/card";
12−import { UsageCard } from "../../components/usage-card";
1311 import { PullIcon } from "../../components/work-icons";
1412 import { openedBy } from "../../lib/opened-by";
1513 import { kindLabel, libraryPackages, packageLine, packagePath, primaryLink } from "../../lib/project-kind";
1614 import { PinButton } from "../../components/pin-button";
1715 import { sortProjects } from "../../lib/project-list";
18−import { usageFor } from "../../lib/workspace-usage.server";
1916 import { deployments, identity, packages, projects as projectsApi, work } from "../../lib/services.server";
2017 import { getViewer, roleIn } from "../../lib/session.server";
2118 import { workspaceProjects } from "../../lib/workspace-projects.server";
2219 import { repositoriesPath } from "../../lib/workspace-nav";
2320 import { Unavailable } from "../../components/boundary";
21+import { MembersCard } from "../../components/members-card";
2422 import { isFailed, part } from "../../lib/partial";
2523
2624 /** Projects whose open work items and pull requests are counted: the most active. */
3028 /** Projects whose pull requests are listed under "In progress". */
3129 const MAX_LISTED = 8;
3230 const MAX_PULLS = 8;
33−const MAX_FACES = 8;
31+
32+/**
33+ * The workspace's own page, and Code's front page, as an organization's is
34+ * on GitHub: under its profile (the layout's header), how many
35+ * repositories it has, what is live, open and in progress; then the
36+ * viewer's pinned repositories, the recently active ones and every one;
37+ * the pull requests in progress; and who is in it. The same page for a
38+ * member and a visitor, who sees what is public and nothing to pin.
39+ * Where you're needed and what happened while you were away are Home's.
40+ */
3441 export async function loader({ params, context }: Route.LoaderArgs) {
3542 const viewer = getViewer(context);
3643 const slug = params.owner.toLowerCase();
3744 const role = roleIn(viewer, slug);
38− // A member's way in is Home; this page
39− // is the workspace as a visitor sees it.
40− if (role) throw redirect(`/${slug}/-/home`);
41− const [listed, members, deploys, usage, published, shortcuts, items] = await Promise.all([
45+ const [listed, members, deploys, published, shortcuts, items] = await Promise.all([
4246 // The listing the sidebar and the Projects tab share.
4347 workspaceProjects(slug, viewer),
4448 role ? identity.listMembers(slug, viewer) : null,
4549 role ? deployments.overview(slug, viewer) : null,
46− role ? usageFor(slug, viewer) : null,
4750 // One listing for every card: a library's card shows its package where an app's shows production.
4851 packages.list(slug, viewer).catch(() => null),
4952 // The viewer's pins lead the page, as they lead the sidebar.
118121 openItems: !isFailed(items) && items.ok ? items.value.length : 0,
119122 pullsFailed,
120123 members: members?.ok ? members.value : null,
121− usage,
122124 };
123125 }
124126
245247 }
246248
247249 export default function WorkspaceOverview({ loaderData }: Route.ComponentProps) {
248− const { slug, role, total, pinned, active, open, deploys, pulls, members, usage } = loaderData;
250+ const { slug, role, total, pinned, active, open, deploys, pulls, members } = loaderData;
249251 const card = (project: Project, isPinned: boolean) => (
250252 <ProjectCard
251253 key={project.id}
370372 </div>
371373
372374 <aside className="space-y-6">
373− {role && usage && <UsageCard slug={slug} glance={usage} owner={role === "owner"} />}
374−
375375 {members ? (
376− <section>
377− <div className="flex items-center justify-between">
378− <h2 className="text-sm font-medium">Members</h2>
379− <Link to={role === "owner" ? `/${slug}/-/settings/members` : `/${slug}/-/people`} className="text-xs text-muted hover:text-fg">
380− {role === "owner" ? "Manage" : "See all"}
381− </Link>
382− </div>
383− <ul className="mt-3 space-y-1.5">
384− {members.slice(0, MAX_FACES).map((member) => (
385− <li key={member.username} className="flex items-center gap-2 text-sm">
386− <Avatar name={member.username} />
387− <span className="grow truncate font-mono">{member.username}</span>
388− {member.role === "owner" && <Badge size="md">owner</Badge>}
389− </li>
390− ))}
391− </ul>
392− {members.length > MAX_FACES && <p className="mt-2 text-xs text-faint">and {members.length - MAX_FACES} more</p>}
393− </section>
376+ <MembersCard slug={slug} owner={role === "owner"} members={members} />
394377 ) : (
395378 <p className="text-sm text-muted">
396379 Members of this workspace build its repositories. Anyone can file a work item or open a pull request on a public one.
397380 </p>
398− )}
399−
400− {role && (
401− <Card asChild className="p-5">
402− <section>
403− <h2 className="flex items-center gap-2 font-medium">
404− <Rocket size={15} className="text-faint" />
405− Deploy on g1t.page
406− </h2>
407− <p className="mt-1.5 text-sm text-muted">
408− Off until you turn them on for a repository, under its Settings. Then production builds from its default branch, with a live preview for every
409− pull request. Apps cost nothing while no one visits.
410− </p>
411− <Link prefetch="intent" to={`/${slug}/-/settings/billing`} className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline">
412− Billing and plans <ArrowRight size={13} />
413− </Link>
414− </section>
415− </Card>
416− )}
417−
418− {role && (
419− <Card asChild className="p-5">
420− <section>
421− <h2 className="flex items-center gap-2 font-medium">
422− <KeyRound size={15} className="text-faint" />
423− Automate without a service account
424− </h2>
425− <p className="mt-1.5 text-sm text-muted">
426− A workspace has access tokens of its own for CI, integrations and agents. They act as the workspace, not as a person.
427− </p>
428− <Link prefetch="intent" to={`/${slug}/-/settings/tokens`} className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline">
429− Access tokens <ArrowRight size={13} />
430− </Link>
431− </section>
432− </Card>
433381 )}
434382 </aside>
435383 </div>

Comments

Sign in to comment.