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

Home updates live as the workspace moves (#54): what lands, starts running, finishes or comes to need you joins its list while the page is open, sliding in at its place and lit for a moment, without a reload.

Home watches the workspace's projects (the first 25, as the digest reads them) over the same live socket repository pages use, and refreshes when something it lists moves: a push to a default branch or a tag, a pull request, review, issue or comment, a run or job starting or finishing, a deployment, release or package, a session starting or ending. Not a branch's pushes or a commit's checks, which Home doesn't show (lib/home-live.ts). It also refreshes when the inbox count moves or a notification for the workspace arrives, for Where you're needed. Its loader reads many services, so it refreshes at most every five seconds; it keeps use-repo-live's rules (a second after the last change, never under typing or an open dialog, a hidden tab catches up when it comes back, polling once a minute without the socket). useRepoLive's core is now useLive, over any number of repositories, and the site's /-/notify takes watch_repos (at most 25, each checked with the same lookup as watch_repo) so Home asks once. Refreshing in place keeps the span: marks under 30 minutes apart are one visit (services/notify/src/visits.ts). Rows new since the list last drew get animate-arrive (fade down 8px plus the g1t-fresh accent flash, none under reduced motion); the first draw and a span switch animate nothing (arrivedOf).

Checked:
node --test for home-live (7 new) and repo-live (21) pass; tsc -b clean; react-router build emits .animate-arrive; docs build passes.
G1t-Session:
ses_01m4ypk0jhex5t0j2c38w7aq7p
syntaqxcommitted Parente5f11c4Browse files
11 files+352−440/11 viewed
+26−0
314314 | Workflow run | A [workflow](/guides/actions/) run that is queued, waiting, running, or waiting for approval, in the ten projects pushed to most in the span (their newest 20 runs each) |
315315 | Build | A project's newest build, queued or building |
316316
317+## While the page is open
318+
319+Home keeps up with the workspace without a reload. What lands, starts
320+running, finishes or comes to need you while you are on the page joins its
321+list, sliding in at its place and lit for a moment; counts and the
322+sentence under the heading update with it. Runs that finish leave Running
323+now.
324+
325+| Home updates when | From |
326+| --- | --- |
327+| Commits are pushed to a project's default branch, or a tag is pushed | The workspace's projects (the first 25) |
328+| A pull request is opened, reviewed, merged or closed, or an issue or comment changes | The same projects |
329+| A workflow run or a job in it starts or finishes, a build or deployment moves, or a release or package is published | The same projects |
330+| A [session](/guides/sessions/) starts or ends | The same projects |
331+| Something comes to need you, or you deal with it elsewhere | Your notifications |
332+
333+A busy workspace sends changes all the time, so Home refreshes a second
334+after the last one and at most every five seconds. It works like the
335+[pages that update live](/guides/notifications/#pages-that-update-live):
336+never while you are typing in Ask or have a dialog open, and a tab out of
337+view catches up when you come back to it. Pushes to other branches and a
338+commit's checks don't refresh Home, because it doesn't show them. Spend
339+updates with the rest but changes only as usage is metered.
340+
341+Refreshing in place keeps the same span: it is the same visit.
342+
317343 ## Without Code access
318344
319345 A member whose membership does not include Code sees Home without anything
+1−0
207207 | An **issue** | Someone comments on it or changes it, or a pull request linked to it moves. |
208208 | **Actions** and a **run** | A run starts, one of its jobs starts or finishes, or the run finishes. A running job's log streams on its own. |
209209 | **Security** | An alert is opened, fixed, dismissed or reopened. |
210+| Your workspace's **Home** | Something lands, runs, deploys or comes to need you in its projects (the first 25). See [Home](/guides/home/#while-the-page-is-open). |
210211
211212 A page refreshes a second after the last change it shows, so a burst of
212213 them is one refresh. It never refreshes while you are typing in it or have
+10−1
6060 --color-danger: light-dark(var(--g1t-light-danger), var(--g1t-danger));
6161
6262 --animate-fade-up: fade-up 0.6s ease-out both;
63+ /* A row that arrived on a live page (Home): in from above, lit for a moment. */
64+ --animate-arrive: g1t-arrive 0.42s cubic-bezier(0.16, 1, 0.3, 1), g1t-fresh 2s ease-out;
6365 --animate-dash: dash 1.2s linear infinite;
6466
6567 /* Menus, popovers and dialogs (components/ui): in quickly, out quicker. */
138140 }
139141 }
140142
141−/* A row that just arrived on a live page (Observability's logs). */
143+/* A row that just arrived on a live page (Observability's logs, Home). */
142144 @keyframes g1t-fresh {
143145 from {
144146 background-color: color-mix(in srgb, var(--color-accent) 18%, transparent);
145147 }
146148 }
147149
150+@keyframes g1t-arrive {
151+ from {
152+ opacity: 0;
153+ transform: translateY(-8px);
154+ }
155+}
156+
148157 @keyframes fade-in {
149158 from {
150159 opacity: 0;
+37−11
22 * Home's parts (routes/workspace/home.tsx): Where you're needed, Start
33 * here, what happened in the span (what people did, what landed, builds,
44 * spend) and Running now. Each takes what
5− * lib/home.ts worked out and only draws it.
5+ * lib/home.ts worked out and only draws it; a row that arrived while the
6+ * page was open comes in (`animate-arrive`, lib/use-home-live.ts).
67 */
78 import {
89 AlertTriangle,
5354 whenShort,
5455 } from "../lib/home";
5556 import { askG1t } from "../lib/ask";
57+import { useArrived } from "../lib/use-home-live";
5658 import { cn } from "../lib/cn";
5759 import { money } from "../lib/money";
5860 import { CommitText } from "./commit-message";
165167 /** Rows shown before the rest fold away. */
166168 const ROWS = 8;
167169
168−function NeedItem({ row, now }: { row: AttentionRow; now: number }) {
170+/** A row that arrived while the page was open: in from above, lit for a moment. */
171+const ARRIVED = "animate-arrive motion-reduce:animate-none";
172+
173+function NeedItem({ row, now, arrived }: { row: AttentionRow; now: number; arrived?: boolean }) {
169174 return (
170− <li className="flex items-start gap-3 border-t border-line px-5 py-3 first:border-t-0">
175+ <li className={cn("flex items-start gap-3 border-t border-line px-5 py-3 first:border-t-0", arrived && ARRIVED)}>
171176 <span className="mt-0.5 shrink-0">{ICON[row.kind]}</span>
172177 <div className="min-w-0 grow">
173178 <Link to={row.action.to} className="line-clamp-2 text-sm font-semibold text-fg hover:underline sm:block sm:truncate">
202207 /** Everything waiting on you, most pressing and longest waiting first; past the first few, the rest fold away. */
203208 export function NeedsList({ attention, now }: { attention: Attention; now: number }) {
204209 const { rows, missing } = attention;
210+ const arrived = useArrived(rows.map((row) => row.key));
205211 return (
206212 <>
207213 {rows.length === 0 ? (
209215 ) : (
210216 <ul>
211217 {rows.slice(0, ROWS).map((row) => (
212− <NeedItem key={row.key} row={row} now={now} />
218+ <NeedItem key={row.key} row={row} now={now} arrived={arrived.has(row.key)} />
213219 ))}
214220 </ul>
215221 )}
221227 </summary>
222228 <ul className="border-t border-line">
223229 {rows.slice(ROWS).map((row) => (
224− <NeedItem key={row.key} row={row} now={now} />
230+ <NeedItem key={row.key} row={row} now={now} arrived={arrived.has(row.key)} />
225231 ))}
226232 </ul>
227233 </details>
565571 );
566572 }
567573
568−function LandedItem({ change, now, tz, timeOnly }: { change: LandedChange; now: number; tz: string | null; timeOnly?: boolean }) {
574+function LandedItem({ change, now, tz, timeOnly, arrived }: { change: LandedChange; now: number; tz: string | null; timeOnly?: boolean; arrived?: boolean }) {
569575 return (
570− <li className={cn(rowClass, "items-start")}>
576+ <li className={cn(rowClass, "items-start", arrived && ARRIVED)}>
571577 <span className="mt-0.5 shrink-0">{LANDED_ICON[change.kind]}</span>
572578 <div className="min-w-0 grow">
573579 <div className="flex min-w-0 items-center gap-1.5">
608614 now,
609615 tz,
610616 byDay = false,
617+ scope,
611618 }: {
612619 landed: LandedChange[] | null;
613620 missing: string[];
615622 now: number;
616623 tz: string | null;
617624 byDay?: boolean;
625+ /** The span: switching it lists other things, which are not arrivals. */
626+ scope?: string;
618627 }) {
628+ const arrived = useArrived(landed?.map((change) => change.key) ?? [], scope);
619629 if (!code) return null;
620− const render = (change: LandedChange) => <LandedItem key={change.key} change={change} now={now} tz={tz} timeOnly={byDay} />;
630+ const render = (change: LandedChange) => <LandedItem key={change.key} change={change} now={now} tz={tz} timeOnly={byDay} arrived={arrived.has(change.key)} />;
621631 return (
622632 <Card title="Landed" hint={landed && landed.length > 0 ? landedHint(landed) : undefined}>
623633 {!landed ? (
713723 );
714724 }
715725
716−export function DeploysCard({ deploys, complete, code, now, tz }: { deploys: DeployRow[] | null; complete: boolean; code: boolean; now: number; tz: string | null }) {
726+export function DeploysCard({
727+ deploys,
728+ complete,
729+ code,
730+ now,
731+ tz,
732+ scope,
733+}: {
734+ deploys: DeployRow[] | null;
735+ complete: boolean;
736+ code: boolean;
737+ now: number;
738+ tz: string | null;
739+ scope?: string;
740+}) {
741+ const arrived = useArrived(deploys?.map((row) => row.key) ?? [], scope);
717742 // A workspace that doesn't deploy has nothing to say here.
718743 if (!code || (deploys && deploys.length === 0)) return null;
719744 const failed = deploys?.filter((row) => row.outcome === "failed").length ?? 0;
725750 <Folded
726751 items={deploys}
727752 render={(row) => (
728− <li key={row.key} className={rowClass}>
753+ <li key={row.key} className={cn(rowClass, arrived.has(row.key) && ARRIVED)}>
729754 <Rocket size={15} className={cn("shrink-0", row.outcome === "failed" ? "text-danger" : "text-success")} />
730755 <div className="min-w-0 grow">
731756 <Link to={row.to} className="block truncate text-sm text-fg hover:underline">
823848 };
824849
825850 export function RunningList({ rows, missing, now }: { rows: RunningRow[]; missing: string[]; now: number }) {
851+ const arrived = useArrived(rows.map((row) => row.key));
826852 return (
827853 <>
828854 {rows.length === 0 ? (
831857 <Folded
832858 items={rows}
833859 render={(row) => (
834− <li key={row.key} className={rowClass}>
860+ <li key={row.key} className={cn(rowClass, arrived.has(row.key) && ARRIVED)}>
835861 <span className="shrink-0">{RUNNING_ICON[row.kind]}</span>
836862 <div className="min-w-0 grow">
837863 <Link to={row.to} className="block truncate text-sm text-fg hover:underline">
+55−0
1+import assert from "node:assert/strict";
2+import { describe, it } from "node:test";
3+
4+import type { RepoChange } from "@g1t/contracts";
5+
6+import { arrivedOf, homeShows, type HomeRepo } from "./home-live.ts";
7+
8+const repos: HomeRepo[] = [{ id: "r1", path: "acme/web", defaultBranch: "main" }];
9+const change = (kind: string, extra: Partial<RepoChange> = {}): RepoChange => ({ repo_id: "r1", kind, at: "2026-10-15T00:00:00Z", ...extra });
10+
11+describe("homeShows", () => {
12+ it("takes a default branch's pushes and tags, not another branch's", () => {
13+ assert.equal(homeShows(change("git.push", { ref: "refs/heads/main" }), repos), true);
14+ assert.equal(homeShows(change("git.push", { ref: "refs/tags/v1.0.0" }), repos), true);
15+ assert.equal(homeShows(change("git.push", { ref: "refs/heads/feature" }), repos), false);
16+ });
17+
18+ it("takes any branch's push where the default branch is not known", () => {
19+ assert.equal(homeShows({ ...change("git.push", { ref: "refs/heads/feature" }), repo_id: "other" }, repos), true);
20+ });
21+
22+ it("takes what Home lists and leaves a commit's checks", () => {
23+ for (const kind of ["pull.merged", "review.submitted", "issue.opened", "comment.created", "workflow.completed", "run.updated", "deployment_status.created", "release.published", "session.started", "session.ended"]) {
24+ assert.equal(homeShows(change(kind), repos), true, kind);
25+ }
26+ for (const kind of ["status.created", "check_run.completed", "check_suite.completed", "checks.completed", "session.progressed", "git.deleted"]) {
27+ assert.equal(homeShows(change(kind), repos), false, kind);
28+ }
29+ });
30+});
31+
32+describe("arrivedOf", () => {
33+ it("finds nothing new on the first draw", () => {
34+ const { arrived, seen } = arrivedOf(null, "last", ["a", "b"]);
35+ assert.deepEqual([...arrived], []);
36+ assert.deepEqual([...seen.keys], ["a", "b"]);
37+ });
38+
39+ it("finds the rows that were not shown before", () => {
40+ const first = arrivedOf(null, "last", ["a", "b"]).seen;
41+ const { arrived } = arrivedOf(first, "last", ["c", "a", "b"]);
42+ assert.deepEqual([...arrived], ["c"]);
43+ });
44+
45+ it("does not count a row that went and came back as new", () => {
46+ const first = arrivedOf(null, "last", ["a", "b"]).seen;
47+ const second = arrivedOf(first, "last", ["b"]).seen;
48+ assert.deepEqual([...arrivedOf(second, "last", ["a", "b"]).arrived], []);
49+ });
50+
51+ it("starts over when the span switches", () => {
52+ const first = arrivedOf(null, "last", ["a"]).seen;
53+ assert.deepEqual([...arrivedOf(first, "7d", ["a", "b", "c"]).arrived], []);
54+ });
55+});
+54−0
1+/**
2+ * Home that updates live: which of its repositories' changes it shows,
3+ * and which rows are new since it last drew. Pure; the hook that uses
4+ * them is lib/use-home-live.ts.
5+ *
6+ * Home watches the workspace's projects (the first HOME_WATCH_MAX, as the
7+ * digest reads them) over the live socket, as a repository page
8+ * watches its own (lib/repo-live.ts), and refreshes when something it
9+ * lists moves: a push to a default branch, a pull request or review, an
10+ * issue or comment, a run starting or finishing, a deployment or release,
11+ * a session starting or ending. Not a branch's pushes or a commit's
12+ * checks: a busy workspace sends those all the time, and nothing on Home
13+ * shows them. Its loader reads many services, so it refreshes at most
14+ * every HOME_GAP_MS.
15+ */
16+import type { RepoChange } from "@g1t/contracts";
17+
18+import { branchOf } from "./repo-live.ts";
19+
20+/** The most projects Home watches (a person watches at most 50, other tabs' pages too). */
21+export const HOME_WATCH_MAX = 25;
22+/** However many changes arrive, Home refreshes at most this often. */
23+export const HOME_GAP_MS = 5000;
24+
25+/** A project Home watches: its id, `owner/name` and default branch. */
26+export type HomeRepo = { id: string; path: string; defaultBranch: string | null };
27+
28+const FAMILIES = ["pull", "review", "issue", "comment", "workflow", "run", "deployment", "deployment_status", "release", "package"];
29+const KINDS = ["session.started", "session.ended", "queue.changed"];
30+
31+/** Whether a change moves something Home shows. */
32+export function homeShows(change: RepoChange, repos: readonly HomeRepo[]): boolean {
33+ if (change.kind === "git.push") {
34+ const repo = repos.find((one) => one.id === change.repo_id);
35+ const branch = branchOf(change.ref);
36+ // A default branch's push lands; a tag's is a release.
37+ return branch == null || !repo?.defaultBranch || branch === repo.defaultBranch;
38+ }
39+ return FAMILIES.includes(change.kind.split(".")[0] ?? "") || KINDS.includes(change.kind);
40+}
41+
42+/** What a list on the page remembers between draws: the keys it has shown, under which scope. */
43+export type Seen = { scope: string; keys: ReadonlySet<string> };
44+
45+/**
46+ * The rows of `keys` that were not on the list when it last drew, and
47+ * what to remember now. On a first draw, or once the scope changed (the
48+ * span switched), none are new: only what arrives while it is open is.
49+ */
50+export function arrivedOf(seen: Seen | null, scope: string, keys: readonly string[]): { arrived: ReadonlySet<string>; seen: Seen } {
51+ if (!seen || seen.scope !== scope) return { arrived: new Set(), seen: { scope, keys: new Set(keys) } };
52+ const arrived = new Set(keys.filter((key) => !seen.keys.has(key)));
53+ return { arrived, seen: { scope, keys: new Set([...seen.keys, ...keys]) } };
54+}
+3−1
229229 busy: boolean;
230230 /** A navigation or a submission is under way: the router reloads the page itself. */
231231 navigating: boolean;
232+ /** The page refreshes at most this often; LIVE_MIN_GAP_MS unless a page's loader is heavier (Home's). */
233+ gap?: number;
232234 };
233235
234236 /**
251253 const quiet = input.now - (state.lastChangeAt ?? state.pendingSince);
252254 const waited = input.now - state.pendingSince;
253255 const since = state.lastRunAt == null ? Infinity : input.now - state.lastRunAt;
254− const ms = Math.max(quiet < LIVE_DEBOUNCE_MS && waited < LIVE_MAX_WAIT_MS ? Math.min(LIVE_DEBOUNCE_MS - quiet, LIVE_MAX_WAIT_MS - waited) : 0, LIVE_MIN_GAP_MS - since);
256+ const ms = Math.max(quiet < LIVE_DEBOUNCE_MS && waited < LIVE_MAX_WAIT_MS ? Math.min(LIVE_DEBOUNCE_MS - quiet, LIVE_MAX_WAIT_MS - waited) : 0, (input.gap ?? LIVE_MIN_GAP_MS) - since);
255257 return ms > 0 ? { action: "wait", ms } : { action: "run" };
256258 }
257259
+45−0
1+/**
2+ * `useHomeLive`: Home refreshing itself as the workspace moves
3+ * (lib/home-live.ts says what it shows), and `useArrived`: which rows of
4+ * a list on it are new, so they come in rather than appear.
5+ *
6+ * Besides its projects' changes, Home refreshes when the inbox moves or a
7+ * notification for this workspace arrives: Where you're needed reads them.
8+ */
9+import { useEffect, useRef } from "react";
10+
11+import { HOME_GAP_MS, type HomeRepo, type Seen, arrivedOf, homeShows } from "./home-live";
12+import { useNotifyState } from "./notify-client";
13+import { useLive } from "./use-repo-live";
14+
15+export function useHomeLive(slug: string, repos: readonly HomeRepo[]): boolean {
16+ const { live, nudge } = useLive({ repos, signedIn: true, match: (change) => homeShows(change, repos), gap: HOME_GAP_MS });
17+
18+ const { inbox, recent } = useNotifyState();
19+ const newest = recent.find((n) => n.workspace.toLowerCase() === slug)?.id ?? null;
20+ const before = useRef<{ inbox: number | null; newest: string | null } | null>(null);
21+ useEffect(() => {
22+ const was = before.current;
23+ before.current = { inbox, newest };
24+ // The first the feed says is where the page started, not a change.
25+ if (was && ((was.inbox != null && inbox !== was.inbox) || newest !== was.newest)) nudge();
26+ }, [inbox, newest, nudge]);
27+
28+ return live;
29+}
30+
31+/**
32+ * The keys of a list's rows that were not on it when it last drew: none
33+ * on the first draw, nor right after `scope` (the span) changes. The same
34+ * keys again give the same answer, so a row keeps its entrance for as
35+ * long as it plays.
36+ */
37+export function useArrived(keys: readonly string[], scope = ""): ReadonlySet<string> {
38+ const last = useRef<{ seen: Seen | null; id: string; arrived: ReadonlySet<string> }>({ seen: null, id: "", arrived: new Set() });
39+ const id = `${scope}\n${keys.join("\n")}`;
40+ if (last.current.seen == null || id !== last.current.id) {
41+ const next = arrivedOf(last.current.seen, scope, keys);
42+ last.current = { seen: next.seen, id, arrived: next.arrived };
43+ }
44+ return last.current.arrived;
45+}
+60−19
1313 * socket is down, the page refreshes when the tab comes back into view
1414 * (at most every 30 s) and once a minute while it is in view.
1515 *
16+ * `useLive` is the same for a page over several repositories (Home,
17+ * the workspace's): one `watch_repos` asks for them all.
18+ *
1619 * Browser-only work happens in effects; on the server the hook does
1720 * nothing.
1821 */
6063 show: () => void;
6164 };
6265
66+/** A repository a live page watches: its id, as changes name it, and its `owner/name`, as it is asked for. */
67+export type WatchedRepo = { id: string; path: string };
68+
69+export type LiveOptions = RepoLiveOptions & {
70+ /** The repositories whose changes the page hears. */
71+ repos: readonly WatchedRepo[];
72+ /** Whether the viewer is signed in: only then is there a socket to hear on. */
73+ signedIn: boolean;
74+ /** However many changes arrive, the page refreshes at most this often (LIVE_MIN_GAP_MS). */
75+ gap?: number;
76+};
77+
78+export type Live = RepoLive & {
79+ /** Something outside the repositories changed (the inbox, on Home): refresh as a change would. */
80+ nudge: () => void;
81+};
82+
6383 // ── Watching ─────────────────────────────────────────────────────────────
6484
6585 /** When each repository was last asked for, by `owner/name`, so pages and reconnects ask once. */
6787 /** Asking again sooner than this is the same ask. */
6888 const ASK_AGAIN_MS = 5000;
6989
70−async function watchRepo(path: string, force: boolean): Promise<boolean> {
90+/** Asks to hear `paths`, one request for them all; true when any is watched. */
91+async function watchRepos(paths: readonly string[], force: boolean): Promise<boolean> {
7192 const now = Date.now();
72− const last = asked.get(path);
73− if (!force && last != null && now - last < ASK_AGAIN_MS) return true;
74− asked.set(path, now);
93+ const due = paths.filter((path) => {
94+ const last = asked.get(path);
95+ return force || last == null || now - last >= ASK_AGAIN_MS;
96+ });
97+ if (due.length === 0) return true;
98+ for (const path of due) asked.set(path, now);
7599 try {
76100 const answer = await fetch("/-/notify", {
77101 method: "POST",
78102 headers: { "content-type": "application/json", accept: "application/json" },
79103 credentials: "same-origin",
80− body: JSON.stringify({ intent: "watch_repo", repo: path }),
104+ body: JSON.stringify(due.length === 1 ? { intent: "watch_repo", repo: due[0] } : { intent: "watch_repos", repos: due }),
81105 });
82106 if (!answer.ok) throw new Error(`status ${answer.status}`);
83107 const body = (await answer.json().catch(() => null)) as { ok?: unknown } | null;
84108 if (body?.ok !== true) throw new Error("refused");
85109 return true;
86110 } catch {
87− asked.delete(path);
111+ for (const path of due) asked.delete(path);
88112 return false;
89113 }
90114 }
111135 export function useRepoLive(options: RepoLiveOptions): RepoLive {
112136 const layout = useRouteLoaderData("routes/repo/layout") as LayoutData | undefined;
113137 const repo = layout?.repo ?? null;
114− const signedIn = layout?.signedIn === true;
115− const enabled = options.enabled !== false && !!repo;
116− const path = repo ? `${repo.namespace}/${repo.name}` : null;
117− const repoId = repo?.id ?? null;
138+ const repos = repo ? [{ id: repo.id, path: `${repo.namespace}/${repo.name}` }] : [];
139+ const { live, held, show } = useLive({ ...options, enabled: options.enabled !== false && !!repo, repos, signedIn: layout?.signedIn === true });
140+ return { live, held, show };
141+}
142+
143+/**
144+ * A page that refreshes itself as any of `repos` changes (Home watches
145+ * the workspace's): `useRepoLive` for a page with no repository layout.
146+ */
147+export function useLive(options: LiveOptions): Live {
148+ const { repos, signedIn } = options;
149+ // With no repositories there is nothing to hear: the page refreshes as it would without the socket.
150+ const enabled = options.enabled !== false;
151+ // One string for the effects: the same repositories are the same watch.
152+ const paths = repos.map((repo) => repo.path).join(" ");
153+ const ids = repos.map((repo) => repo.id).join(" ");
118154
119155 const connected = useFeedConnected();
120156 const [watching, setWatching] = useState(false);
145181 const check = useCallback(() => {
146182 if (timer.current) clearTimeout(timer.current);
147183 timer.current = null;
148− const decision = gate(state.current, { now: Date.now(), visible: visibleNow(), busy: busyNow(), navigating: latest.current.navigating });
184+ const decision = gate(state.current, { now: Date.now(), visible: visibleNow(), busy: busyNow(), navigating: latest.current.navigating, gap: latest.current.options.gap });
149185 if (decision.action === "run") run();
150186 else if (decision.action === "wait") timer.current = setTimeout(check, decision.ms);
151187 // "hold": out of view; coming back into view (`back` below) asks again.
162198 [check],
163199 );
164200
165− // Watching the repository: on opening the page, whenever the socket
201+ // Watching the repositories: on opening the page, whenever the socket
166202 // (re)connects, and before the watch runs out.
167203 useEffect(() => {
168− if (!enabled || !signedIn || !connected || !path) {
204+ if (!enabled || !signedIn || !connected || !paths) {
169205 setWatching(false);
170206 return;
171207 }
172208 let cancelled = false;
173209 const ask = (force: boolean) =>
174− void watchRepo(path, force).then((ok) => {
210+ void watchRepos(paths.split(" "), force).then((ok) => {
175211 if (!cancelled) setWatching(ok);
176212 });
177213 ask(true);
180216 cancelled = true;
181217 clearInterval(renew);
182218 };
183− }, [enabled, signedIn, connected, path]);
219+ }, [enabled, signedIn, connected, paths]);
184220
185221 // Changes as they arrive.
186222 useEffect(() => {
187− if (!enabled || !repoId) return;
223+ if (!enabled || !ids) return;
224+ const watched = new Set(ids.split(" "));
188225 return onRepoChanges((changes) => {
189226 const { match, hold } = latest.current.options;
190− const mine = changes.filter((change) => change.repo_id === repoId);
227+ const mine = changes.filter((change) => watched.has(change.repo_id));
191228 const holding = hold ? mine.filter((change) => hold(change)) : [];
192229 const matched = mine.filter((change) => !holding.includes(change) && match(change));
193230 if (holding.length) setHeld((before) => [...before, ...holding]);
194231 // While something is held, the rest waits with it: shown together.
195232 if (matched.length && !holding.length && heldCount.current === 0) queue();
196233 });
197− }, [enabled, repoId, queue]);
234+ }, [enabled, ids, queue]);
198235
199236 // Another branch or page: what was held is no longer about it.
200237 useEffect(() => {
245282 run();
246283 }, [run]);
247284
248− return { live, held, show };
285+ const nudge = useCallback(() => {
286+ if (enabled && heldCount.current === 0) queue();
287+ }, [enabled, queue]);
288+
289+ return { live, held, show, nudge };
249290 }
+28−9
2424 * the account by identity), or `watch_repo`, for a repository page that
2525 * updates live: its tabs then hear what changes there (`repo`, its
2626 * `owner/name`), once the person is found to be able to read it
27− * (lib/repo-live.ts).
27+ * (lib/repo-live.ts); `watch_repos` the same for several at once (`repos`,
28+ * at most WATCH_REPOS_MAX: Home watches its workspace's).
2829 *
2930 * The phone app calls this with its access token and no `Origin`, which
3031 * the same-origin check lets through (lib/same-origin.ts).
4546 preferences?: NotifyPreferencesChange;
4647 change?: PresenceChange | SoundSettingsChange;
4748 repo?: string;
49+ repos?: unknown;
4850 };
4951
52+/** The most repositories one `watch_repos` asks for (notify keeps 50 per person). */
53+const WATCH_REPOS_MAX = 25;
54+
55+/** Watches `path` (`owner/name`) for `user` when they can read it; the repository's id, or null. */
56+async function watchOne(context: Route.ActionArgs["context"], notify: ReturnType<typeof client>, user: ReturnType<typeof requireUser>, path: string) {
57+ // Only a repository they can read: the same lookup its pages make.
58+ const [owner, name, ...rest] = path.split("/");
59+ if (!owner || !name || rest.length) return null;
60+ const found = await repoFor(context, { owner, repo: name });
61+ if (!found.ok) return null;
62+ const repo = found.value;
63+ const watched = await notify.watchRepo(user, repo.id, `${repo.namespace}/${repo.name}`);
64+ return { ...watched, repo_id: repo.id };
65+}
66+
5067 export async function action({ context, request }: Route.ActionArgs) {
5168 assertSameOrigin(request);
5269 const user = requireUser(context, request);
87104 return Response.json(await notify.setPresence(user, { ...change, ...("status" in change ? { status } : {}) }));
88105 }
89106 case "watch_repo": {
90− // Only a repository they can read: the same lookup its pages make.
91− const [owner, name, ...rest] = String(sent.repo ?? "").split("/");
92− if (!owner || !name || rest.length) return Response.json({ ok: false }, { status: 400 });
93− const found = await repoFor(context, { owner, repo: name });
94− if (!found.ok) return Response.json({ ok: false }, { status: 404 });
95− const repo = found.value;
96− const watched = await notify.watchRepo(user, repo.id, `${repo.namespace}/${repo.name}`);
97− return Response.json({ ...watched, repo_id: repo.id });
107+ const path = String(sent.repo ?? "");
108+ if (path.split("/").length !== 2) return Response.json({ ok: false }, { status: 400 });
109+ const watched = await watchOne(context, notify, user, path);
110+ return watched ? Response.json(watched) : Response.json({ ok: false }, { status: 404 });
111+ }
112+ case "watch_repos": {
113+ const paths = Array.isArray(sent.repos) ? [...new Set(sent.repos.filter((path): path is string => typeof path === "string"))] : [];
114+ if (paths.length === 0 || paths.length > WATCH_REPOS_MAX) return Response.json({ ok: false }, { status: 400 });
115+ const watched = (await Promise.all(paths.map((path) => watchOne(context, notify, user, path).catch(() => null)))).filter((one) => one?.ok);
116+ return Response.json({ ok: watched.length > 0, repo_ids: watched.map((one) => one!.repo_id) });
98117 }
99118 default:
100119 return Response.json({ ok: false }, { status: 400 });
+33−3
1−import { Suspense, useEffect } from "react";
1+import { Suspense, useEffect, useState } from "react";
22 import { Await, data } from "react-router";
33
44 import { hasCodeAccess } from "@g1t/contracts";
6565 reachFor,
6666 repoRefs,
6767 } from "../../lib/home.server";
68+import { HOME_WATCH_MAX, type HomeRepo } from "../../lib/home-live";
6869 import { readCookie } from "../../lib/mission";
70+import { useHomeLive } from "../../lib/use-home-live";
6971 import { loadOnboarding } from "../../lib/onboarding.server";
7072 import { OnboardingOnPage } from "../../components/onboarding";
7173 import { loadMissionControl } from "../../lib/mission-control.server";
9395 * at (routes/workspace/home-seen.ts), so a refresh never wipes it. Only
9496 * the last visit (for the span) is waited for; every section streams in
9597 * on its own, and one whose service does not answer says so.
98+ * The page refreshes itself as the workspace moves (lib/home-live.ts),
99+ * and what arrived since it last drew comes in rather than appears.
96100 * Definitions: lib/home.ts and the Home guide.
97101 */
98102 export async function loader({ params, context, request }: Route.LoaderArgs) {
184188 }))
185189 .catch(() => ({ rows: [], missing: ["Workflows", "Deployments"] }));
186190 const spend = loadSpend(viewer, slug, span).catch(() => null);
191+ // The projects the page watches, so it refreshes as they move (lib/home-live.ts).
192+ const watch: Promise<HomeRepo[]> = reposP
193+ .then((owned) => (owned ?? []).slice(0, HOME_WATCH_MAX).map((repo) => ({ id: repo.id, path: `${repo.namespace}/${repo.name}`, defaultBranch: repo.defaultBranch || null })))
194+ .catch(() => []);
187195
188196 return {
189197 slug,
199207 /** The sentence under the heading needs the digest, what landed and what waits. */
200208 head: Promise.all([digest, happened, waiting]),
201209 spend,
210+ watch,
202211 onboarding,
203212 setupAsked,
204213 };
205214 }
206215
216+/** The projects the page watches, once the loader has read them. */
217+function useWatched(watch: Promise<HomeRepo[]>): HomeRepo[] {
218+ const [repos, setRepos] = useState<HomeRepo[]>([]);
219+ useEffect(() => {
220+ let current = true;
221+ void watch.then((list) => {
222+ // A refresh reads them again: keep the same list while it is the same.
223+ if (current) setRepos((before) => (sameRepos(before, list) ? before : list));
224+ });
225+ return () => {
226+ current = false;
227+ };
228+ }, [watch]);
229+ return repos;
230+}
231+
232+function sameRepos(a: HomeRepo[], b: HomeRepo[]): boolean {
233+ return a.length === b.length && a.every((repo, i) => repo.id === b[i]?.id && repo.defaultBranch === b[i]?.defaultBranch);
234+}
235+
207236 /** How long the page must have been in view before it counts as a visit. */
208237 const SEEN_AFTER_MS = 5_000;
209238
242271 export default function Home({ loaderData }: Route.ComponentProps) {
243272 const { slug, now, tz, span, digest, happened, attention: waiting, away, running: runningNow, head, spend } = loaderData;
244273 useMarkSeen(slug, now);
274+ useHomeLive(slug, useWatched(loaderData.watch));
245275 return (
246276 <div className="mx-auto w-full max-w-260 space-y-5 px-4 py-5 md:px-10 md:py-8">
247277 <OnboardingOnPage slug={slug} view={loaderData.onboarding} asked={loaderData.setupAsked} />
325355 }
326356 >
327357 <Await resolve={happened}>
328− {(value) => <LandedCard landed={value?.landed ?? null} missing={value?.landedMissing ?? []} code={loaderData.code} now={now} tz={tz} byDay={span.key === "7d"} />}
358+ {(value) => <LandedCard landed={value?.landed ?? null} missing={value?.landedMissing ?? []} code={loaderData.code} now={now} tz={tz} byDay={span.key === "7d"} scope={span.key} />}
329359 </Await>
330360 </Suspense>
331361 <Suspense fallback={null}>
332362 <Await resolve={happened}>
333− {(value) => <DeploysCard deploys={value?.deploys ?? null} complete={value?.deploysComplete ?? true} code={loaderData.code} now={now} tz={tz} />}
363+ {(value) => <DeploysCard deploys={value?.deploys ?? null} complete={value?.deploysComplete ?? true} code={loaderData.code} now={now} tz={tz} scope={span.key} />}
334364 </Await>
335365 </Suspense>
336366 </div>

Comments

Sign in to comment.