Skip to content

Commit

Merge branch 'worktree-agent-a9934473c8cf2bca6'

syntaqxcommitted Parents85421338876797Browse files
12 files+336−1540/12 viewed
+8−1
220220 const g1tBusy = withG1t && anyAnswering(messages);
221221 // References in g1t's answers point into the repository the latest question was asked from, if any.
222222 const askedRepo = useMemo(() => [...messages].reverse().find((m) => m.page)?.page?.repo ?? null, [messages]);
223− const answerContext = useMemo<ResponseContext>(() => ({ slug, repo: askedRepo }), [slug, askedRepo]);
223+ // Kept while it names the same repository: each answer draws itself again
224+ // (and React replaces all of it) whenever this changes, so a new message
225+ // or a refresh, which hand over new objects, must not change it.
226+ const answerContext = useMemo<ResponseContext>(
227+ () => ({ slug, repo: askedRepo }),
228+ // eslint-disable-next-line react-hooks/exhaustive-deps -- the repository by name, not the object it came in
229+ [slug, askedRepo?.namespace, askedRepo?.name],
230+ );
224231 const lastAnswer = withG1t ? ([...messages].reverse().find((m) => !m.deleted_at && !m.pending) ?? null) : null;
225232 const g1tSend = useCallback(
226233 async (intent: "g1t_retry" | "g1t_stop" | "g1t_reset") => {
+23−9
1616
1717 import type { Comment, Comparison, DiffLine } from "@g1t/contracts";
1818
19−import type {
20− HighlightedFile,
21− HighlightedLine,
19+import {
20+ type FileHtml,
21+ type HighlightedFile,
22+ type HighlightedLine,
23+ diffContent,
24+ htmlOfFile,
25+ withHtml,
2226 } from "../lib/diff";
2327 import { Markdown } from "./markdown";
2428 import { EmptyState, SubmitButton, Textarea, TimeAgo } from "./ui";
398402 // Shown plain at once, and highlighted in the browser when it comes near
399403 // the screen: the page never waits for colours.
400404 const section = useRef<HTMLElement>(null);
401− const [highlighted, setHighlighted] = useState<HighlightedFile | null>(null);
405+ const [highlighted, setHighlighted] = useState<{ content: string; html: FileHtml } | null>(null);
406+ // What the colours depend on. A refresh hands the page a new `file` with
407+ // the same lines: it keeps its colours rather than going plain and being
408+ // highlighted again, which flashed every coloured diff on the page.
409+ const content = useMemo(() => `${file.path}\n${diffContent(file)}`, [file]);
410+ const latest = useRef(file);
411+ latest.current = file;
412+ const kept = highlighted?.content === content ? highlighted.html : null;
413+ const drawn = useMemo(() => (kept ? withHtml(file, kept) : file), [file, kept]);
402414 useEffect(() => {
403− setHighlighted(null);
404415 const element = section.current;
416+ const file = latest.current;
405417 // Highlighted by the server already: one of the change's first files.
406418 const done = file.hunks.some((hunk) => hunk.lines.some((line) => (line as HighlightedLine).html != null));
407− if (!element || collapsed || done) return;
419+ if (!element || collapsed || done || kept) return;
408420 let cancelled = false;
409421 const observer = new IntersectionObserver(
410422 ([entry]) => {
413425 void import("../lib/shiki")
414426 .then(({ highlightFile }) => highlightFile(file))
415427 .then((result) => {
416− if (!cancelled && result) setHighlighted(result);
428+ if (!cancelled && result) setHighlighted({ content, html: htmlOfFile(result) });
417429 })
418430 .catch(() => {});
419431 },
424436 cancelled = true;
425437 observer.disconnect();
426438 };
427− }, [file, collapsed]);
428− const props = { file: highlighted ?? file, comments, canComment: review?.canComment ?? false };
439+ // `kept` is whether `content` is highlighted already: the lines, not the object they came in.
440+ // eslint-disable-next-line react-hooks/exhaustive-deps
441+ }, [content, collapsed, kept != null]);
442+ const props = { file: drawn, comments, canComment: review?.canComment ?? false };
429443 return (
430444 <Card asChild tone="plain" className="scroll-mt-[calc(4rem+var(--diff-toolbar,3rem))]">
431445 <section
+111−93
1010 } from "lucide-react";
1111 import type { Root } from "hast";
1212 import type { Components } from "react-markdown";
13−import { type ReactNode, isValidElement, useEffect, useMemo, useRef, useState } from "react";
13+import { type ReactNode, createContext, isValidElement, useContext, useEffect, useMemo, useRef, useState } from "react";
1414 import { Link } from "react-router";
1515 import rehypeRaw from "rehype-raw";
1616 import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
206206 }) {
207207 // The same text renders to the same elements with the same components, so
208208 // a page that revalidates (a live refresh) leaves the text as it is
209− // rather than mounting it again and reloading its pictures.
209+ // rather than mounting it again and reloading its pictures. The
210+ // components never change: where links and pictures point comes from
211+ // context, so a new commit (a README's pictures are that commit's files)
212+ // updates their addresses in place rather than replacing every element.
210213 const tree = useMemo(() => treeOf(source, repo), [source, repo?.namespace, repo?.name]);
211− const components = useMemo(() => markdownComponents(base, rawBase), [base, rawBase]);
212− const rendered = useMemo(() => renderMarkdownTree(tree, components), [tree, components]);
214+ const rendered = useMemo(() => renderMarkdownTree(tree, COMPONENTS), [tree]);
215+ const bases = useMemo(() => ({ base, rawBase }), [base, rawBase]);
213216 return (
214− // A long word or address breaks rather than widening the page.
215− <div className="prose wrap-break-word">{rendered}</div>
217+ <MarkdownBases.Provider value={bases}>
218+ {/* A long word or address breaks rather than widening the page. */}
219+ <div className="prose wrap-break-word">{rendered}</div>
220+ </MarkdownBases.Provider>
216221 );
217222 }
218223
219−/** How each element is drawn: links relative to `base`, pictures under `rawBase`. */
220−function markdownComponents(base: string | undefined, rawBase: string | undefined): Components {
221− return {
222− h1: ({ children }) => <Heading level={1}>{children}</Heading>,
223− h2: ({ children }) => <Heading level={2}>{children}</Heading>,
224− h3: ({ children }) => <Heading level={3}>{children}</Heading>,
225− h4: ({ children }) => <Heading level={4}>{children}</Heading>,
226− a({ href = "", children, node }) {
227− const ref = (node?.properties as { dataRef?: string } | undefined)?.dataRef;
228− if (ref === "mention") {
229− // `@name` may be a person (with a card) or a workspace (none).
230− const name = href === G1T_MENTION_HREF ? "g1t" : href.replace(/^\//, "");
231− return (
232− <UserCard username={name}>
233− <Link to={href} prefetch="intent" className="font-medium">
234− {children}
235− </Link>
236− </UserCard>
237− );
238− }
239− if (ref) {
240− return (
241− <Link
242− to={href}
243− prefetch="intent"
244− className={ref === "commit" ? "font-mono text-[0.9em]" : ref === "mention" ? "font-medium" : ""}
245− >
246− {children}
247− </Link>
248− );
249− }
250− if (href.startsWith("#")) return <a href={href}>{children}</a>;
251− if (isExternal(href)) {
252− return (
253− <a href={href} rel="noreferrer nofollow ugc" target="_blank">
254− {children}
255− </a>
256− );
257− }
258− // A link within the site, or relative to the document's folder.
259− const to = href.startsWith("/") || !base ? href : `${base}/${href.replace(/^\.\//, "")}`;
260− return <Link to={to}>{children}</Link>;
261− },
262− blockquote({ children, node }) {
263− const kind = (node?.properties as { dataAlert?: AlertKind } | undefined)?.dataAlert;
264− if (!kind || !ALERT[kind]) return <blockquote>{children}</blockquote>;
265− const alert = ALERT[kind];
266− return (
267− <div className={`markdown-alert border-l-2 py-1 pl-4 ${alert.tone}`}>
268− <p className="alert-title flex items-center gap-2 text-sm font-medium">
269− {alert.icon}
270− {alert.title}
271− </p>
272− <div className="mt-1 [&>*+*]:mt-3">{children}</div>
273− </div>
274− );
275− },
276− pre({ children }) {
277− const code = Array.isArray(children) ? children[0] : children;
278− if (isValidElement<{ className?: string; children?: ReactNode }>(code)) {
279− const language = /language-([\w+-]+)/.exec(code.props.className ?? "")?.[1] ?? null;
280− return <CodeBlock language={language} code={textOf(code.props.children).replace(/\n$/, "")} />;
281− }
282− return <pre>{children}</pre>;
283− },
284− // A wide table scrolls on its own, inside the text, not the page.
285− table: ({ children }) => (
286− <div className="overflow-x-auto">
287− <table className="max-sm:[&_td]:min-w-32 max-sm:[&_th]:whitespace-nowrap">{children}</table>
224+/** Where a document's relative links (`base`) and pictures (`rawBase`) point. */
225+const MarkdownBases = createContext<{ base?: string; rawBase?: string }>({});
226+
227+/** A link: a reference or mention within the site, an anchor, an address elsewhere, or a file beside the document. */
228+function MarkdownLink({ href = "", children, node }: { href?: string; children?: ReactNode; node?: { properties?: unknown } }) {
229+ const { base } = useContext(MarkdownBases);
230+ const ref = (node?.properties as { dataRef?: string } | undefined)?.dataRef;
231+ if (ref === "mention") {
232+ // `@name` may be a person (with a card) or a workspace (none).
233+ const name = href === G1T_MENTION_HREF ? "g1t" : href.replace(/^\//, "");
234+ return (
235+ <UserCard username={name}>
236+ <Link to={href} prefetch="intent" className="font-medium">
237+ {children}
238+ </Link>
239+ </UserCard>
240+ );
241+ }
242+ if (ref) {
243+ return (
244+ <Link
245+ to={href}
246+ prefetch="intent"
247+ className={ref === "commit" ? "font-mono text-[0.9em]" : ref === "mention" ? "font-medium" : ""}
248+ >
249+ {children}
250+ </Link>
251+ );
252+ }
253+ if (href.startsWith("#")) return <a href={href}>{children}</a>;
254+ if (isExternal(href)) {
255+ return (
256+ <a href={href} rel="noreferrer nofollow ugc" target="_blank">
257+ {children}
258+ </a>
259+ );
260+ }
261+ // A link within the site, or relative to the document's folder.
262+ const to = href.startsWith("/") || !base ? href : `${base}/${href.replace(/^\.\//, "")}`;
263+ return <Link to={to}>{children}</Link>;
264+}
265+
266+/** A picture; a relative one is the document's commit's file. */
267+function MarkdownImage({ src, alt }: { src?: unknown; alt?: string }) {
268+ const { rawBase } = useContext(MarkdownBases);
269+ const at = typeof src === "string" ? imageSource(src, rawBase) : undefined;
270+ return <img src={at} alt={alt ?? ""} loading="lazy" className="inline max-w-full rounded" />;
271+}
272+
273+/**
274+ * How each element is drawn, made once: a component that is the same on
275+ * every render keeps the elements it drew, where a new one (even an
276+ * identical function) has React take them down and put them back.
277+ */
278+const COMPONENTS: Components = {
279+ h1: ({ children }) => <Heading level={1}>{children}</Heading>,
280+ h2: ({ children }) => <Heading level={2}>{children}</Heading>,
281+ h3: ({ children }) => <Heading level={3}>{children}</Heading>,
282+ h4: ({ children }) => <Heading level={4}>{children}</Heading>,
283+ a: MarkdownLink,
284+ blockquote({ children, node }) {
285+ const kind = (node?.properties as { dataAlert?: AlertKind } | undefined)?.dataAlert;
286+ if (!kind || !ALERT[kind]) return <blockquote>{children}</blockquote>;
287+ const alert = ALERT[kind];
288+ return (
289+ <div className={`markdown-alert border-l-2 py-1 pl-4 ${alert.tone}`}>
290+ <p className="alert-title flex items-center gap-2 text-sm font-medium">
291+ {alert.icon}
292+ {alert.title}
293+ </p>
294+ <div className="mt-1 [&>*+*]:mt-3">{children}</div>
288295 </div>
289− ),
290− input({ type, checked, disabled }) {
291− // Task list boxes: shown, not editable.
292− return type === "checkbox" ? (
293− <Checkbox
294− checked={checked === true}
295− disabled={disabled !== false}
296− aria-label={checked ? "Done" : "Not done"}
297− className="mr-1.5 inline-flex translate-y-0.5 disabled:cursor-default disabled:opacity-100"
298− />
299− ) : null;
300− },
301− img({ src, alt }) {
302− const at = typeof src === "string" ? imageSource(src, rawBase) : undefined;
303− return <img src={at} alt={alt ?? ""} loading="lazy" className="inline max-w-full rounded" />;
304− },
305− } satisfies Components;
306−}
296+ );
297+ },
298+ pre({ children }) {
299+ const code = Array.isArray(children) ? children[0] : children;
300+ if (isValidElement<{ className?: string; children?: ReactNode }>(code)) {
301+ const language = /language-([\w+-]+)/.exec(code.props.className ?? "")?.[1] ?? null;
302+ return <CodeBlock language={language} code={textOf(code.props.children).replace(/\n$/, "")} />;
303+ }
304+ return <pre>{children}</pre>;
305+ },
306+ // A wide table scrolls on its own, inside the text, not the page.
307+ table: ({ children }) => (
308+ <div className="overflow-x-auto">
309+ <table className="max-sm:[&_td]:min-w-32 max-sm:[&_th]:whitespace-nowrap">{children}</table>
310+ </div>
311+ ),
312+ input({ type, checked, disabled }) {
313+ // Task list boxes: shown, not editable.
314+ return type === "checkbox" ? (
315+ <Checkbox
316+ checked={checked === true}
317+ disabled={disabled !== false}
318+ aria-label={checked ? "Done" : "Not done"}
319+ className="mr-1.5 inline-flex translate-y-0.5 disabled:cursor-default disabled:opacity-100"
320+ />
321+ ) : null;
322+ },
323+ img: MarkdownImage,
324+};
+25−1
3636 const image = useRef<HTMLImageElement>(null);
3737 const [at, setAt] = useState(0);
3838 const [loaded, setLoaded] = useState(false);
39+ // The picture last shown, kept under the next one until that has loaded
40+ // and faded in: a new deploy's screenshot replaces the old one rather
41+ // than the frame going blank while it comes.
42+ const [shown, setShown] = useState<CardSource | null>(null);
3943 const key = sources.map((source) => source.src).join("\n");
4044 const source = sources[at] ?? null;
45+ const under = source && shown && shown.src !== source.src ? shown : null;
4146
4247 // Other sources (another deploy, a new upload): from the first again.
4348 useEffect(() => {
5358 else setAt((index) => index + 1);
5459 }, [source?.src]);
5560
61+ // Once the new picture has faded in over it (300 ms), the old one goes.
62+ useEffect(() => {
63+ if (!loaded || !source || shown?.src === source.src) return;
64+ const timer = setTimeout(() => setShown(source), 350);
65+ return () => clearTimeout(timer);
66+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `source` is the one `src` names
67+ }, [loaded, source?.src, shown?.src]);
68+
5669 const empty = source == null;
5770 const waiting = !empty && !loaded ? (source.kind === "screenshot" ? "Taking a screenshot…" : "Loading the image…") : null;
5871
6376 href && "transition-colors group-hover:border-line-strong",
6477 )}
6578 >
66− {!loaded && <Placeholder label={label} waiting={waiting} note={empty && !addImage ? (note ?? null) : null} />}
79+ {!loaded && !under && <Placeholder label={label} waiting={waiting} note={empty && !addImage ? (note ?? null) : null} />}
80+ {under && (
81+ <img
82+ key={under.src}
83+ src={under.src}
84+ alt=""
85+ aria-hidden="true"
86+ width={1280}
87+ height={800}
88+ className="absolute inset-0 size-full object-cover object-top"
89+ />
90+ )}
6791 {source && (
6892 <img
6993 key={source.src}
+25−8
55 export const REFRESH_MS = 4000;
66
77 /**
8− * Revalidates the page every `everyMs` while `on` and the tab is visible.
8+ * Revalidates the page `everyMs` after it last loaded while `on` and the
9+ * tab is visible. Whatever refreshed it last (this, a live change, a form,
10+ * a navigation) starts the count again, so a page that also follows live
11+ * changes is not refreshed twice in a row. Out of view it waits, and asks
12+ * once when the tab comes back if the time has passed meanwhile.
13+ *
914 * Never while a navigation is pending: a revalidation then restarts the
1015 * navigation, aborting its loaders, so a click to a page slower than the
1116 * interval would never arrive. Never on top of a revalidation still going.
1217 */
1318 export function useRefreshWhile(on: boolean, everyMs = REFRESH_MS) {
1419 const revalidator = useRevalidator();
15− const navigating = useNavigation().state !== "idle";
20+ const idle = useNavigation().state === "idle" && revalidator.state === "idle";
1621 useEffect(() => {
17− if (!on || navigating) return;
18− const timer = setInterval(() => {
19− if (document.visibilityState === "visible" && revalidator.state === "idle") revalidator.revalidate();
20− }, everyMs);
21− return () => clearInterval(timer);
22− }, [on, navigating, everyMs, revalidator]);
22+ if (!on || !idle) return;
23+ let due = false;
24+ const ask = () => {
25+ if (document.visibilityState === "visible") revalidator.revalidate();
26+ else due = true;
27+ };
28+ const timer = setTimeout(ask, everyMs);
29+ const back = () => {
30+ if (due && document.visibilityState === "visible") ask();
31+ };
32+ document.addEventListener("visibilitychange", back);
33+ return () => {
34+ clearTimeout(timer);
35+ document.removeEventListener("visibilitychange", back);
36+ };
37+ // `revalidator` is a new object whenever its state changes, which `idle` already follows.
38+ // eslint-disable-next-line react-hooks/exhaustive-deps
39+ }, [on, idle, everyMs]);
2340 }
+66−7
44 import {
55 FALLBACK_FOCUS_MS,
66 FALLBACK_POLL_MS,
7− HIDDEN_MIN_MS,
87 LIVE_DEBOUNCE_MS,
98 LIVE_HOLD_CHECK_MS,
109 LIVE_MAX_WAIT_MS,
10+ LIVE_MIN_GAP_MS,
1111 type RepoChange,
1212 atBottom,
1313 branchOf,
5353 assert.equal(overview(change("comment.created", { number: 3 })), false);
5454 });
5555
56+test("the overview hears its own commit's checks, and every pull request's finished ones", () => {
57+ const overview = repoPages.overview("abc");
58+ assert.equal(overview(change("status.created", { sha: "abc" })), true);
59+ assert.equal(overview(change("run.updated", { sha: "abc", run: "run_1" })), true);
60+ assert.equal(overview(change("status.created", { sha: "f00d" })), false, "another commit's");
61+ assert.equal(overview(change("run.updated", { sha: "f00d", run: "run_2" })), false);
62+ assert.equal(overview(change("checks.completed", { sha: "f00d", number: 4 })), true, "how often checks pass");
63+ assert.equal(overview(change("status.created")), true, "a check that names no commit may be its");
64+});
65+
5666 test("the files page follows refs, checks, releases and deployments", () => {
5767 const files = repoPages.files();
5868 for (const c of [push("main"), push("other"), change("git.deleted", { ref: "refs/heads/old" }), change("status.created", { sha: "abc" }), change("release.published"), change("deployment_status.created")]) {
6272 assert.equal(files(change("comment.created", { number: 3 })), false);
6373 });
6474
75+test("a page of files hears its own branch and its commit's checks, not every branch's", () => {
76+ const files = repoPages.files({ branch: "main", branches: ["main", "feature"], sha: "abc" });
77+ assert.equal(files(push("main")), true, "its files and latest commit");
78+ assert.equal(files(push("feature")), false, "another branch moving changes nothing here");
79+ assert.equal(files(push("brand-new")), true, "a new branch: the menu and count");
80+ assert.equal(files(change("git.deleted", { ref: "refs/heads/feature" })), true, "one of the menu's gone");
81+ assert.equal(files(change("git.deleted", { ref: "refs/heads/never-listed" })), false);
82+ assert.equal(files(change("git.push", { ref: "refs/tags/v2" })), true, "the About's tags");
83+ assert.equal(files(change("branch.renamed", { ref: "refs/heads/feature" })), true);
84+ assert.equal(files(change("repo.default_branch_changed")), true);
85+ assert.equal(files(change("status.created", { sha: "abc" })), true, "its latest commit's checks");
86+ assert.equal(files(change("check_run.completed", { sha: "f00d" })), false, "another commit's checks");
87+ assert.equal(files(change("run.updated", { run: "run_1" })), true, "a check that names no commit may be its");
88+ assert.equal(files(change("deployment_status.created")), true);
89+ assert.equal(files(change("release.published")), true);
90+ // A tag or a commit shown: no branch of its own moves it.
91+ const tag = repoPages.files({ branch: null, branches: ["main"], sha: "abc" });
92+ assert.equal(tag(push("main")), false);
93+ // Without the branches, any branch may be new: only its own and deletions count.
94+ const unknown = repoPages.files({ branch: "main", branches: null, sha: "abc" });
95+ assert.equal(unknown(push("other")), false);
96+ assert.equal(unknown(change("git.deleted", { ref: "refs/heads/other" })), true);
97+});
98+
6599 test("the commits page hears pushes to its own branch only", () => {
66100 const commits = repoPages.commits("main");
67101 assert.equal(commits(push("main")), true);
69103 assert.equal(commits(change("git.push", { ref: "refs/tags/v1" })), false);
70104 assert.equal(commits(change("workflow.completed", { sha: "abc", run: "run_1" })), true, "checks on its commits");
71105 assert.equal(commits(change("pull.opened", { number: 1 })), false);
106+ const shown = repoPages.commits("main", ["abc", "def"]);
107+ assert.equal(shown(change("status.created", { sha: "def" })), true, "checks on a commit it lists");
108+ assert.equal(shown(change("status.created", { sha: "f00d" })), false, "not on one it does not");
72109 });
73110
74111 test("branches, tags, and the lists of pull requests and issues", () => {
183220 assert.deepEqual(gate(due, { now: NOW, visible: true, busy: false, navigating: false }), { action: "run" });
184221 });
185222
186−test("a tab out of view refreshes at most once a minute", () => {
223+test("a burst of changes refreshes at most every two seconds", () => {
224+ const idle = { now: NOW, visible: true, busy: false, navigating: false };
225+ // Quiet for a second, but the page refreshed half a second ago: it waits out the gap.
226+ const justRan = { pendingSince: NOW - LIVE_DEBOUNCE_MS, lastChangeAt: NOW - LIVE_DEBOUNCE_MS, lastRunAt: NOW - 500 };
227+ assert.deepEqual(gate(justRan, idle), { action: "wait", ms: LIVE_MIN_GAP_MS - 500 });
228+ assert.deepEqual(gate(justRan, { ...idle, now: NOW - 500 + LIVE_MIN_GAP_MS }), { action: "run" });
229+ // A steady stream after a refresh: one more at most five seconds on, never sooner than the gap.
230+ const stream = { pendingSince: NOW - LIVE_MAX_WAIT_MS, lastChangeAt: NOW, lastRunAt: NOW - LIVE_MAX_WAIT_MS - 10 };
231+ assert.deepEqual(gate(stream, idle), { action: "run" });
232+ // Changes arriving every 100 ms for a minute refresh no more than once per gap.
233+ let state = { pendingSince: null as number | null, lastChangeAt: null as number | null, lastRunAt: NOW };
234+ let runs = 0;
235+ for (let t = NOW; t < NOW + 60_000; t += 100) {
236+ state = { ...state, pendingSince: state.pendingSince ?? t, lastChangeAt: t };
237+ if (gate(state, { ...idle, now: t }).action === "run") {
238+ runs++;
239+ state = { pendingSince: null, lastChangeAt: null, lastRunAt: t };
240+ }
241+ }
242+ assert.ok(runs <= 60_000 / LIVE_MIN_GAP_MS, `${runs} runs`);
243+ assert.ok(runs >= 60_000 / LIVE_MAX_WAIT_MS - 1, `${runs} runs: still kept up to date`);
244+});
245+
246+test("a tab out of view never refreshes; back in view, it catches up at once", () => {
187247 const hidden = { now: NOW, visible: false, busy: false, navigating: false };
188− const ranLately = { pendingSince: NOW - 5000, lastChangeAt: NOW - 5000, lastRunAt: NOW - 20_000 };
189− assert.deepEqual(gate(ranLately, hidden), { action: "wait", ms: HIDDEN_MIN_MS - 20_000 });
190− assert.deepEqual(gate({ ...ranLately, lastRunAt: NOW - HIDDEN_MIN_MS }, hidden), { action: "run" });
191− // Back in view: at once.
192− assert.deepEqual(gate(ranLately, { ...hidden, visible: true }), { action: "run" });
248+ const waiting = { pendingSince: NOW - 5000, lastChangeAt: NOW - 5000, lastRunAt: NOW - 20 * 60_000 };
249+ assert.deepEqual(gate(waiting, hidden), { action: "hold" }, "no timer: coming back asks again");
250+ assert.deepEqual(gate({ pendingSince: null, lastChangeAt: null, lastRunAt: null }, hidden), { action: "idle" });
251+ assert.deepEqual(gate(waiting, { ...hidden, visible: true }), { action: "run" });
193252 });
194253
195254 test("without the socket: back in view every 30 s at most, a poll every minute in view, never with it", () => {
+62−26
88 * then every push, pull request, issue, comment, review, check, run,
99 * release and security alert there arrives as a small `RepoChange`. A page
1010 * refreshes its loader when a change matches it, a second after the last
11− * one, and never under someone typing or with a dialog open. Signed out,
12− * or while the socket is down, it refreshes when the tab comes back into
13− * view and once a minute while it is in view instead.
11+ * one, at most every two seconds, never under someone typing or with a
12+ * dialog open, and not at all while the tab is out of view: it catches up
13+ * once when it comes back. A busy repository sends changes all the time
14+ * (every branch's pushes, every commit's checks), so each page matches
15+ * only what it shows: the Code page its own branch and the checks of the
16+ * commit it shows, not another branch's. Signed out, or while the socket
17+ * is down, it refreshes when the tab comes back into view and once a
18+ * minute while it is in view instead.
1419 */
1520 import type { RepoChange } from "@g1t/contracts";
1621
2328 export const LIVE_DEBOUNCE_MS = 1000;
2429 /** However steady the changes, a page waits no longer than this before refreshing. */
2530 export const LIVE_MAX_WAIT_MS = 5000;
31+/** However many changes arrive, a page refreshes at most this often. */
32+export const LIVE_MIN_GAP_MS = 2000;
2633 /** How often a page held back (someone typing, a dialog open) looks again. */
2734 export const LIVE_HOLD_CHECK_MS = 1000;
28−/** A tab out of view refreshes at most this often. */
29−export const HIDDEN_MIN_MS = 60_000;
3035 /** Without the socket: coming back into view refreshes at most this often. */
3136 export const FALLBACK_FOCUS_MS = 30_000;
3237 /** Without the socket: how often a page in view refreshes. */
7378
7479 const ALERTS = ["secret_scanning_alert", "code_scanning_alert", "vulnerability_alert"];
7580
81+/** A check on one of `shas`: any check when they are not known, and one that names no commit. */
82+function isCheckOn(change: RepoChange, shas: readonly string[] | null | undefined): boolean {
83+ return isCheck(change) && (shas == null || !change.sha || shas.includes(change.sha));
84+}
85+
86+/** What a page of files shows, for matching only what can change it. */
87+export type ShownFiles = {
88+ /** The branch shown; null for a tag or a commit. */
89+ branch: string | null;
90+ /** Every branch, for the branch menu and count: a new one, or one of them deleted, changes them. */
91+ branches?: readonly string[] | null;
92+ /** The commit shown, whose checks are on the page. */
93+ sha?: string | null;
94+};
95+
7696 /** What each kind of repository page shows. */
7797 export const repoPages = {
7898 /**
79− * Files and a folder: any ref moving (the files and latest
80− * commit, the branch menu, recent pushes), checks on commits, releases
81− * and deployments.
99+ * Files and a folder: its own branch moving (the files and the latest
100+ * commit), a branch made, deleted or renamed and the default changing
101+ * (the branch menu and count), any tag (the About's), checks on the
102+ * commit shown, releases and deployments. Without `shown`, any ref and
103+ * any check.
82104 */
83− files(): RepoMatch {
84− return (change) => isRefChange(change) || isCheck(change) || ["release", "deployment", "deployment_status"].includes(family(change));
105+ files(shown?: ShownFiles): RepoMatch {
106+ return (change) => {
107+ if (["release", "deployment", "deployment_status"].includes(family(change))) return true;
108+ if (!shown) return isRefChange(change) || isCheck(change);
109+ if (isCheck(change)) return isCheckOn(change, shown.sha ? [shown.sha] : null);
110+ if (!isRefChange(change)) return false;
111+ const branch = branchOf(change.ref);
112+ if (branch == null || change.kind === "branch.renamed" || change.kind === "repo.default_branch_changed") return true;
113+ if (branch === shown.branch) return true;
114+ // Another branch: only made or deleted matters, by the menu it is in or not.
115+ const known = shown.branches == null || shown.branches.includes(branch);
116+ return change.kind === "git.deleted" ? known : !known;
117+ };
85118 },
86119 /**
87120 * A project's overview: what it runs and how it is doing (deployments,
88121 * checks, releases), who is working on it (claims and sessions), and the
89122 * pull requests that may need someone (opened, readied, reviewed, merged).
123+ * Of the checks, only the shown commit's (`sha`) and finished pull
124+ * requests' (how often checks pass); without `sha`, all.
90125 */
91− overview(): RepoMatch {
126+ overview(sha?: string | null): RepoMatch {
92127 return (change) =>
93− isCheck(change) ||
128+ (change.kind === "checks.completed" || isCheckOn(change, sha ? [sha] : null)) ||
94129 ["release", "deployment", "deployment_status", "work", "pull", "review"].includes(family(change)) ||
95130 SESSION_KINDS.includes(change.kind);
96131 },
97− /** Commits: pushes to the branch shown, and checks on commits. */
98− commits(branch: string | null): RepoMatch {
99− return (change) => (branch == null ? change.kind === "git.push" && !tagOf(change.ref) : isPushTo(change, branch)) || isCheck(change);
132+ /** Commits: pushes to the branch shown, and checks on the commits shown (on any without `shas`). */
133+ commits(branch: string | null, shas?: readonly string[] | null): RepoMatch {
134+ return (change) => (branch == null ? change.kind === "git.push" && !tagOf(change.ref) : isPushTo(change, branch)) || isCheckOn(change, shas);
100135 },
101136 /** Branches and tags: any ref moving, pull requests (a branch's), checks. */
102137 refs(): RepoMatch {
196231 navigating: boolean;
197232 };
198233
199−export type GateDecision = { action: "idle" } | { action: "run" } | { action: "wait"; ms: number };
234+/**
235+ * `hold`: nothing to do until the tab comes back into view, which asks
236+ * again; no timer runs meanwhile.
237+ */
238+export type GateDecision = { action: "idle" } | { action: "run" } | { action: "hold" } | { action: "wait"; ms: number };
200239
201240 /**
202241 * Whether to refresh now for the changes waiting, or how long to wait
203242 * before asking again. A second after the last change (at most five after
204− * the first), never under someone typing or with a dialog open, and at
205− * most once a minute for a tab out of view, which refreshes as soon as it
206− * comes back instead.
243+ * the first), at most every two seconds, never under someone typing or
244+ * with a dialog open, and never for a tab out of view: what waited is
245+ * fetched once, as soon as it comes back.
207246 */
208247 export function gate(state: GateState, input: GateInput): GateDecision {
209248 if (state.pendingSince == null) return { action: "idle" };
249+ if (!input.visible) return { action: "hold" };
210250 if (input.busy || input.navigating) return { action: "wait", ms: LIVE_HOLD_CHECK_MS };
211− if (!input.visible && state.lastRunAt != null && input.now - state.lastRunAt < HIDDEN_MIN_MS) {
212− return { action: "wait", ms: HIDDEN_MIN_MS - (input.now - state.lastRunAt) };
213− }
214251 const quiet = input.now - (state.lastChangeAt ?? state.pendingSince);
215252 const waited = input.now - state.pendingSince;
216− if (quiet < LIVE_DEBOUNCE_MS && waited < LIVE_MAX_WAIT_MS) {
217− return { action: "wait", ms: Math.min(LIVE_DEBOUNCE_MS - quiet, LIVE_MAX_WAIT_MS - waited) };
218− }
219− return { action: "run" };
253+ 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);
255+ return ms > 0 ? { action: "wait", ms } : { action: "run" };
220256 }
221257
222258 /**
+5−3
66 * Signed in, the tab watches the repository over its live socket: the
77 * site checks the person may read it (`/-/notify`, `watch_repo`), then
88 * every change there arrives as it happens, and the page's loader runs
9− * again a second after the last matching one. Never under someone typing
10− * on the page or with a dialog open (it waits until they are done), and
11− * at most once a minute for a tab out of view. Signed out, or while the
9+ * again a second after the last matching one, at most every two seconds.
10+ * Never under someone typing on the page or with a dialog open (it waits
11+ * until they are done), and never for a tab out of view: it catches up
12+ * once when the tab comes back. Signed out, or while the
1213 * socket is down, the page refreshes when the tab comes back into view
1314 * (at most every 30 s) and once a minute while it is in view.
1415 *
147148 const decision = gate(state.current, { now: Date.now(), visible: visibleNow(), busy: busyNow(), navigating: latest.current.navigating });
148149 if (decision.action === "run") run();
149150 else if (decision.action === "wait") timer.current = setTimeout(check, decision.ms);
151+ // "hold": out of view; coming back into view (`back` below) asks again.
150152 }, [run]);
151153
152154 /** Something to refresh for: `now` skips the second's wait for more. */
+4−2
4141
4242 export default function Code({ loaderData }: Route.ComponentProps) {
4343 const project = useProject();
44− // The files and latest commit follow pushes; checks update as they report.
45− useRepoLive({ match: repoPages.files() });
44+ // The files and latest commit follow pushes to the branch; the latest
45+ // commit's checks update as they report.
46+ const { tree, branches } = loaderData;
47+ useRepoLive({ match: repoPages.files({ branch: tree.ref, branches: branches?.map((branch) => branch.name), sha: tree.head?.hash }) });
4648 return (
4749 <TreeView
4850 tree={loaderData.tree}
+1−1
6464 // moving the list under the reader; checks on the shown ones update.
6565 const branch = useProject()?.repo.defaultBranch ?? null;
6666 const pushed = (change: Parameters<typeof isPushTo>[0]) => change.kind === "git.push" && isPushTo(change, branch);
67− const { held, show } = useRepoLive({ match: repoPages.commits(branch), hold: commits.length > 0 ? pushed : undefined });
67+ const { held, show } = useRepoLive({ match: repoPages.commits(branch, commits.map((commit) => commit.hash)), hold: commits.length > 0 ? pushed : undefined });
6868 if (commits.length === 0) {
6969 return <EmptyState title="No commits yet" />;
7070 }
+1−1
819819 const elsewhereUrl = loaderData.deployment?.url ?? project?.productionUrl ?? null;
820820 const working = claims.length + sessions.length > 0;
821821 // Deployments, checks, releases, claims and sessions show as they happen.
822− useRepoLive({ match: repoPages.overview() });
822+ useRepoLive({ match: repoPages.overview(loaderData.commit?.hash) });
823823 // Claims lapse and sessions go idle with no event to say so.
824824 useRefreshWhile(working, 15_000);
825825 const live = liveByIssue(claims, sessions);
+5−2
4545
4646 export default function Tree({ loaderData }: Route.ComponentProps) {
4747 const project = useProject();
48− // The files and latest commit follow pushes; checks update as they report.
49− useRepoLive({ match: repoPages.files() });
48+ // The files and latest commit follow pushes to the branch (none for a
49+ // tag or a commit); the latest commit's checks update as they report.
50+ const { tree, branches } = loaderData;
51+ const names = branches?.map((branch) => branch.name);
52+ useRepoLive({ match: repoPages.files({ branch: !names || names.includes(tree.ref) ? tree.ref : null, branches: names, sha: tree.head?.hash }) });
5053 return (
5154 <TreeView
5255 tree={loaderData.tree}